mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
POST /api/intercept/pending/{id}/always-allow derives an argv allow rule from the stored pending row (never from the body), at priority -1000, reusing an identical enabled rule, and leaves the request pending. A code-owned denylist of interpreters, launchers, package managers, orchestration and destructive programs refuses such rules at creation, in the manual rule form, and neutralises argv allow rules at matching; argv deny and ask rules still match.
148 lines
5.0 KiB
Go
148 lines
5.0 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
)
|
|
|
|
// PG test: needs a PostgreSQL database (ARTEX_PG_DSN); skipped without one.
|
|
// POST /api/intercept/pending/{id}/always-allow derives the rule from the
|
|
// stored row, never from the body, and leaves the request pending.
|
|
func TestInterceptAlwaysAllowPG(t *testing.T) {
|
|
dsn, _, err := db.DSN()
|
|
if err != nil {
|
|
t.Skip("no test database configured")
|
|
}
|
|
d, err := db.Open(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
m := &Manager{pg: d, interceptor: intercept.New(d)}
|
|
s := &Server{m: m, jwtKey: []byte("always-allow-test-key")}
|
|
h := s.Handler()
|
|
token, err := signJWT(s.jwtKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const scope = "always-allow-http"
|
|
t.Cleanup(func() {
|
|
_, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, scope)
|
|
_, _ = d.Exec(`DELETE FROM intercept_rules WHERE name LIKE 'always-allow: %'`)
|
|
})
|
|
pending := func(tool, cmd string) int64 {
|
|
t.Helper()
|
|
input, _ := json.Marshal(map[string]string{"command": cmd})
|
|
id, err := d.CreateInterceptPending(0, 0, scope, "test", tool, input, "ask")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
post := func(id int64, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("POST", fmt.Sprintf("/api/intercept/pending/%d/always-allow", id), strings.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
ruleCount := func() int {
|
|
t.Helper()
|
|
rules, err := d.ListInterceptRules()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return len(rules)
|
|
}
|
|
type result struct {
|
|
Rule db.InterceptRule `json:"rule"`
|
|
Created bool `json:"created"`
|
|
}
|
|
|
|
id := pending("Bash", "gh pr list --json title")
|
|
before := ruleCount()
|
|
// The body carries dummy tokens and another command: all of it is ignored.
|
|
dummy := `{"pending_id":1,"command":"rm -rf /","pattern":"[\"rm\"]","token":"dummy-token","access_token":"dummy"}`
|
|
rec := post(id, dummy)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("always-allow: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var got result
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := got.Rule
|
|
if !got.Created || r.Pattern != `["gh","pr","list"]` || r.MatchType != "argv" || r.MatchTarget != "tool_input" ||
|
|
r.Action != "allow" || !r.Enabled || r.Priority != -1000 || r.Name != "always-allow: gh pr list" ||
|
|
!r.TimeoutEnabled || r.TimeoutSeconds != 60 || r.TimeoutAction != "deny" {
|
|
t.Fatalf("rule %+v created=%v", r, got.Created)
|
|
}
|
|
if n := ruleCount(); n != before+1 {
|
|
t.Fatalf("rule count %d, want %d", n, before+1)
|
|
}
|
|
if p, err := d.GetInterceptPending(id); err != nil || p == nil || p.Status != "pending" {
|
|
t.Fatalf("pending row after always-allow: %+v %v", p, err)
|
|
}
|
|
// The cache was invalidated: the new rule matches at once.
|
|
if dec, ok := m.interceptor.Match("Bash", []byte(`{"command":"gh pr list --limit 5"}`)); !ok || dec.RuleID != r.ID {
|
|
t.Fatalf("match after creation: %+v %v", dec, ok)
|
|
}
|
|
|
|
// An identical rule is reused, not duplicated.
|
|
rec = post(pending("Bash", "gh pr list"), "")
|
|
var again result
|
|
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &again) != nil || again.Created || again.Rule.ID != r.ID {
|
|
t.Fatalf("second always-allow: %d %s", rec.Code, rec.Body)
|
|
}
|
|
if n := ruleCount(); n != before+1 {
|
|
t.Fatalf("rule count after reuse %d, want %d", n, before+1)
|
|
}
|
|
|
|
// Refusals: 400 and no rule.
|
|
for name, c := range map[string][2]string{
|
|
"not Bash": {"shell_open", "gh pr list"},
|
|
"compound": {"Bash", "gh pr list; id"},
|
|
"denylisted": {"Bash", "python3.11 -c 'import os'"},
|
|
"path to env": {"Bash", "/usr/bin/env gh pr list"},
|
|
} {
|
|
rid := pending(c[0], c[1])
|
|
if rec := post(rid, dummy); rec.Code != 400 {
|
|
t.Errorf("%s: %d %s, want 400", name, rec.Code, rec.Body)
|
|
}
|
|
if p, _ := d.GetInterceptPending(rid); p == nil || p.Status != "pending" {
|
|
t.Errorf("%s: pending row changed: %+v", name, p)
|
|
}
|
|
}
|
|
if n := ruleCount(); n != before+1 {
|
|
t.Fatalf("rule count after refusals %d, want %d", n, before+1)
|
|
}
|
|
|
|
// A decided request is refused.
|
|
decided := pending("Bash", "gh pr view 1")
|
|
if err := d.DecideInterceptPending(decided, "denied"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := post(decided, ""); rec.Code != 409 {
|
|
t.Fatalf("decided request: %d %s, want 409", rec.Code, rec.Body)
|
|
}
|
|
|
|
// The detail carries the prefix for the button label.
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/api/intercept/history/%d", id), nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
drec := httptest.NewRecorder()
|
|
h.ServeHTTP(drec, req)
|
|
var detail struct {
|
|
Status string `json:"status"`
|
|
AlwaysAllowPrefix string `json:"always_allow_prefix"`
|
|
}
|
|
if drec.Code != 200 || json.Unmarshal(drec.Body.Bytes(), &detail) != nil || detail.AlwaysAllowPrefix != "gh pr list" || detail.Status != "pending" {
|
|
t.Fatalf("detail: %d %s", drec.Code, drec.Body)
|
|
}
|
|
}
|