diff --git a/intercept/alwaysallow_denylist.go b/intercept/alwaysallow_denylist.go new file mode 100644 index 0000000..ea02dac --- /dev/null +++ b/intercept/alwaysallow_denylist.go @@ -0,0 +1,103 @@ +package intercept + +import ( + "encoding/json" + "errors" + "fmt" + "path" + "strings" +) + +// alwaysAllowDenylist holds the programs an argv allow rule may never name: +// with flags unconstrained, an allow rule for one of them runs arbitrary code +// or does unbounded damage. It is code-owned on purpose (no setting, no env +// var). Names are compared after argvProgramName: basename, lower case, no +// version suffix (python3.11 -> python, mkfs.ext4 -> mkfs). +var alwaysAllowDenylist = func() map[string]bool { + m := map[string]bool{} + for _, group := range []string{ + // interpreters and shells + "sh bash zsh dash ksh fish csh tcsh ash mksh rbash pwsh powershell python pypy ipython perl ruby irb " + + "node deno bun php lua luajit rscript tclsh wish expect awk gawk mawk nawk sed jshell osascript", + // launchers: run their arguments (or a flag's value) as a command + "env command builtin exec eval source . nohup timeout nice ionice stdbuf setsid chroot unshare nsenter " + + "systemd-run su sudo doas pkexec runuser setpriv sg watch busybox toybox xargs parallel make gmake " + + "mapfile trap script find ssh scp sftp rsync socat ncat nc netcat flock strace ltrace gdb " + + "taskset chrt unbuffer rlwrap firejail bwrap proot proxychains torsocks tar zip vim vi nvim emacs less more man", + // package managers and build tools: run package scripts and hooks + "npm npx pnpm yarn bunx corepack pip pipx uv uvx poetry conda composer gem bundle bundler rake cargo go " + + "mvn gradle dotnet apt apt-get dpkg dnf yum rpm pacman apk brew snap flatpak", + // orchestration: run images, playbooks and plans + "docker docker-compose podman nerdctl kubectl helm ansible ansible-playbook terraform pulumi vagrant", + // destructive + "rm dd mkfs mkswap wipefs fdisk sfdisk parted shred truncate chmod chown kill pkill killall systemctl service " + + "iptables ip6tables nft ufw mount umount swapoff shutdown reboot poweroff halt init telinit crontab " + + "insmod rmmod modprobe", + } { + for _, name := range strings.Fields(group) { + m[name] = true + } + } + return m +}() + +// argvProgramName normalises a program token for the denylist: the basename, +// in lower case, without a trailing version (python3.11, php8.2, perl5, pip3) +// and with every mkfs.* variant reduced to mkfs. +func argvProgramName(tok string) string { + name := strings.ToLower(path.Base(tok)) + if strings.HasPrefix(name, "mkfs.") { + return "mkfs" + } + // Strip a trailing run of digits and dots, but only after a letter, so a + // name made only of digits (or ".") is kept as is. + end := len(name) + for end > 0 && (name[end-1] == '.' || (name[end-1] >= '0' && name[end-1] <= '9')) { + end-- + } + if end > 0 && end < len(name) { + return name[:end] + } + return name +} + +// ArgvAllowDenied reports whether an argv allow rule whose first token is +// program is refused by the denylist. +func ArgvAllowDenied(program string) bool { + return alwaysAllowDenylist[argvProgramName(program)] +} + +// CheckArgvAllow returns the error shown when an allow rule (manual or from +// "always allow") names a denylisted program, nil otherwise. +func CheckArgvAllow(tokens []string) error { + if len(tokens) > 0 && ArgvAllowDenied(tokens[0]) { + return fmt.Errorf("program %q cannot be always allowed: it can run arbitrary code or cause unbounded damage whatever its arguments", tokens[0]) + } + return nil +} + +// AlwaysAllowPrefix derives the argv pattern an "always allow" of a pending +// call would create: Bash only, one simple command, a non-empty prefix whose +// program is not denylisted. +func AlwaysAllowPrefix(toolName string, input []byte) ([]string, error) { + if toolName != "Bash" { + return nil, errors.New("always allow applies to Bash commands only") + } + var in struct { + Command string `json:"command"` + } + if err := json.Unmarshal(input, &in); err != nil { + return nil, fmt.Errorf("the tool input is not a Bash call: %w", err) + } + prefix, err := ArgvPrefix(in.Command) + if err != nil { + return nil, fmt.Errorf("no prefix can be derived from this command: %w", err) + } + if len(prefix) == 0 { + return nil, errors.New("no prefix can be derived from this command") + } + if err := CheckArgvAllow(prefix); err != nil { + return nil, err + } + return prefix, nil +} diff --git a/intercept/alwaysallow_denylist_test.go b/intercept/alwaysallow_denylist_test.go new file mode 100644 index 0000000..1027c04 --- /dev/null +++ b/intercept/alwaysallow_denylist_test.go @@ -0,0 +1,95 @@ +package intercept + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/Autumn-27/artex/db" +) + +func TestArgvAllowDenylist(t *testing.T) { + for _, prog := range []string{ + "python", "python3", "python3.11", "/usr/bin/python3.11", "php8.2", "ruby3.2", "perl5", "pip3", + "env", "/usr/bin/env", "bash", "/bin/sh", "find", "rm", "mkfs", "mkfs.ext4", "/sbin/mkfs.xfs", + "docker", "kubectl", "npm", "sudo", "xargs", "Rscript", ".", + } { + if !ArgvAllowDenied(prog) { + t.Errorf("%q is not denied", prog) + } + } + for _, prog := range []string{"nmap", "git", "/usr/bin/git", "curl", "ls", "gh", "x", "7z", "pythonic"} { + if ArgvAllowDenied(prog) { + t.Errorf("%q is denied, want allowable", prog) + } + } +} + +func TestAlwaysAllowPrefix(t *testing.T) { + bash := func(cmd string) []byte { + b, _ := json.Marshal(map[string]string{"command": cmd}) + return b + } + if got, err := AlwaysAllowPrefix("Bash", bash("git push --force origin main")); err != nil || !slices.Equal(got, []string{"git", "push"}) { + t.Fatalf("git push: %q %v", got, err) + } + if got, err := AlwaysAllowPrefix("Bash", bash("nmap -p 80 10.0.0.1")); err != nil || !slices.Equal(got, []string{"nmap"}) { + t.Fatalf("nmap: %q %v", got, err) + } + for name, c := range map[string]struct { + tool string + input []byte + }{ + "not Bash": {"shell_open", bash("git push")}, + "compound": {"Bash", bash("git push; id")}, + "substitution": {"Bash", bash("git push $(id)")}, + "redirection": {"Bash", bash("git push > out")}, + "empty": {"Bash", bash("")}, + "no command": {"Bash", []byte(`{}`)}, + "not json": {"Bash", []byte(`not json`)}, + "python": {"Bash", bash("python3.11 -c 'import os'")}, + "path to python": {"Bash", bash("/usr/bin/python3 x.py")}, + "find": {"Bash", bash("find . -exec sh -c id ;")}, + "rm": {"Bash", bash("rm -rf /")}, + "docker": {"Bash", bash("docker run --privileged x")}, + } { + if got, err := AlwaysAllowPrefix(c.tool, c.input); err == nil { + t.Errorf("%s: prefix %q, want a refusal", name, got) + } + } +} + +// The denylist neutralises argv allow rules at matching time (a rule created +// before the denylist, or written straight into the database), and only them: +// an argv deny or ask rule for the same program still matches. +func TestArgvDenylistAtMatching(t *testing.T) { + ask := db.InterceptRule{ID: 99, Name: "ask every Bash call", Enabled: true, Priority: -2000, MatchTarget: "tool_name", + MatchType: "string", Pattern: "Bash", Action: "ask"} + argv := func(id int64, pattern, action string) db.InterceptRule { + return db.InterceptRule{ID: id, Name: "argv " + action + " " + pattern, Enabled: true, Priority: -1000, + MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: action} + } + ic := New(nil) + ic.SetRulesForTest([]db.InterceptRule{ + argv(1, `["python"]`, "allow"), argv(2, `["python3.11"]`, "allow"), argv(3, `["find"]`, "allow"), + argv(4, `["/usr/bin/env"]`, "allow"), argv(5, `["nmap"]`, "allow"), ask, + }) + for _, cmd := range []string{"python x.py", "python3.11 -c 'import os'", "find . -name x", "/usr/bin/env"} { + input, _ := json.Marshal(map[string]string{"command": cmd}) + dec, ok := ic.Match("Bash", input) + if !ok || dec.Action != "ask" || dec.RuleID != 99 { + t.Errorf("%q: %+v %v, want the fallback ask rule", cmd, dec, ok) + } + } + if dec, ok := ic.Match("Bash", []byte(`{"command":"nmap -p 80 x"}`)); !ok || dec.Action != "allow" || dec.RuleID != 5 { + t.Errorf("nmap: %+v %v, want the argv allow", dec, ok) + } + + ic.SetRulesForTest([]db.InterceptRule{argv(1, `["python"]`, "allow"), argv(6, `["python"]`, "deny"), argv(7, `["find"]`, "ask")}) + if dec, ok := ic.Match("Bash", []byte(`{"command":"python x.py"}`)); !ok || dec.Action != "deny" || dec.RuleID != 6 { + t.Errorf("python: %+v %v, want the argv deny", dec, ok) + } + if dec, ok := ic.Match("Bash", []byte(`{"command":"find . -delete"}`)); !ok || dec.Action != "ask" || dec.RuleID != 7 { + t.Errorf("find: %+v %v, want the argv ask", dec, ok) + } +} diff --git a/intercept/intercept.go b/intercept/intercept.go index a9eda9c..70bbf90 100644 --- a/intercept/intercept.go +++ b/intercept/intercept.go @@ -753,6 +753,11 @@ func (i *Interceptor) SetRulesForTest(rules []db.InterceptRule) { func ruleMatches(r compiledRule, toolName string, input []byte) bool { if r.MatchType == "argv" { // Bash only, on the parsed command; match_target is always tool_input. + // The denylist neutralises allow rules only: an argv deny or ask rule + // for a denylisted program still matches. + if r.Action == "allow" && CheckArgvAllow(r.argv) != nil { + return false + } return r.MatchTarget == "tool_input" && argvRuleMatches(r.argv, toolName, input) } var subject string diff --git a/server/intercept.go b/server/intercept.go index 41ff347..701a5d5 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "regexp" + "slices" "strconv" "strings" @@ -413,6 +414,67 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"ok": true}) } +// alwaysAllowPriority places "always allow" rules below every default and +// manual rule, so any deny or ask rule that matches the same call wins. +const alwaysAllowPriority = -1000 + +// interceptAlwaysAllow creates an argv allow rule from a pending Bash request. +// The request body is never read: the command comes from the stored row, so a +// caller cannot choose what gets allowed. The request itself stays pending. +func (s *Server) interceptAlwaysAllow(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + id, ok := pathInt(r, "id") + if !ok { + writeErr(w, 400, "bad pending id") + return + } + p, err := pg.GetInterceptPending(id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if p == nil { + writeErr(w, 404, "not found") + return + } + if p.Status != "pending" { + writeErr(w, 409, "the request is no longer pending") + return + } + prefix, err := intercept.AlwaysAllowPrefix(p.ToolName, p.ToolInput) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + pattern, _ := json.Marshal(prefix) + rules, err := pg.ListInterceptRules() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + for _, rule := range rules { + if rule.MatchType != "argv" || rule.Action != "allow" || !rule.Enabled || rule.MatchTarget != "tool_input" { + continue + } + if tokens, err := intercept.ParseArgvPattern(rule.Pattern); err == nil && slices.Equal(tokens, prefix) { + writeJSON(w, 200, map[string]any{"rule": rule, "created": false}) + return + } + } + // Timeout fields take the rule form's defaults. + rule, err := pg.CreateInterceptRule("always-allow: "+strings.Join(prefix, " "), "tool_input", "argv", string(pattern), + "allow", "", alwaysAllowPriority, true, true, 60, "deny") + if err != nil { + writeErr(w, 500, err.Error()) + return + } + s.m.interceptor.Invalidate() + writeJSON(w, 200, map[string]any{"rule": rule, "created": true}) +} + // --- target pins (hosts a human approved for one task or one conversation) --- // taskPinScope resolves {id} to the pin scope of an existing task. @@ -667,9 +729,15 @@ func validateInterceptRuleReq(req interceptRuleReq) error { if req.MatchTarget != "tool_input" { return fmt.Errorf("an argv rule matches tool_input (the Bash command)") } - if _, err := intercept.ParseArgvPattern(req.Pattern); err != nil { + tokens, err := intercept.ParseArgvPattern(req.Pattern) + if err != nil { return fmt.Errorf("invalid argv pattern: %w", err) } + if req.Action == "allow" { + if err := intercept.CheckArgvAllow(tokens); err != nil { + return err + } + } default: return fmt.Errorf("match_type must be string, regex or argv") } @@ -708,7 +776,18 @@ func (s *Server) interceptDetail(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "not found") return } - writeJSON(w, 200, detail) + // The "always allow" button label: the prefix the endpoint would allow, + // derived here so the UI never parses the command. + out := struct { + *db.InterceptDetail + AlwaysAllowPrefix string `json:"always_allow_prefix,omitempty"` + }{InterceptDetail: detail} + if detail.Status == "pending" { + if prefix, err := intercept.AlwaysAllowPrefix(detail.ToolName, detail.ToolInput); err == nil { + out.AlwaysAllowPrefix = strings.Join(prefix, " ") + } + } + writeJSON(w, 200, out) } // The navigation endpoint returns only the original call and its paired result. diff --git a/server/intercept_always_allow_pg_test.go b/server/intercept_always_allow_pg_test.go new file mode 100644 index 0000000..1c707b3 --- /dev/null +++ b/server/intercept_always_allow_pg_test.go @@ -0,0 +1,147 @@ +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) + } +} diff --git a/server/intercept_argv_test.go b/server/intercept_argv_test.go index a6d1f53..acab9e9 100644 --- a/server/intercept_argv_test.go +++ b/server/intercept_argv_test.go @@ -1,6 +1,10 @@ package server -import "testing" +import ( + "testing" + + "github.com/Autumn-27/artex/intercept" +) func TestValidateArgvRuleReq(t *testing.T) { base := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: `["git","push"]`, Action: "allow"} @@ -23,3 +27,28 @@ func TestValidateArgvRuleReq(t *testing.T) { } } } + +// The manual rule form refuses an argv allow rule for a denylisted program, +// with the message of the "always allow" endpoint; deny and ask stay creatable. +func TestValidateArgvAllowDenylist(t *testing.T) { + for _, pattern := range []string{`["python"]`, `["python3.11"]`, `["/usr/bin/env"]`, `["find"]`, `["docker","run"]`} { + req := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: "allow"} + tokens, _ := intercept.ParseArgvPattern(pattern) + want := intercept.CheckArgvAllow(tokens) + if err := validateInterceptRuleReq(req); err == nil || want == nil || err.Error() != want.Error() { + t.Errorf("allow %s: %v, want %v", pattern, err, want) + } + for _, action := range []string{"deny", "ask"} { + req.Action = action + if err := validateInterceptRuleReq(req); err != nil { + t.Errorf("%s %s refused: %v", action, pattern, err) + } + } + } + for _, pattern := range []string{`["nmap"]`, `["git","push"]`} { + req := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: "allow"} + if err := validateInterceptRuleReq(req); err != nil { + t.Errorf("allow %s refused: %v", pattern, err) + } + } +} diff --git a/server/server.go b/server/server.go index 8b762ae..e857d2a 100644 --- a/server/server.go +++ b/server/server.go @@ -958,6 +958,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/intercept/pending", s.interceptListPending) mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne) mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide) + mux.HandleFunc("POST /api/intercept/pending/{id}/always-allow", s.interceptAlwaysAllow) // creates an argv allow rule; the request stays pending mux.HandleFunc("GET /api/intercept/history", s.interceptHistory) mux.HandleFunc("GET /api/intercept/history/{id}", s.interceptDetail) mux.HandleFunc("GET /api/intercept/history/{id}/execution", s.interceptExecution) diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx index 6d18ec4..d6e6427 100644 --- a/web/src/components/approval-records.tsx +++ b/web/src/components/approval-records.tsx @@ -297,6 +297,38 @@ function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; bus ); } +// AlwaysAllowAction creates an argv allow rule from a pending Bash request. +// The prefix comes from the server; the request itself stays pending. +function AlwaysAllowAction({ id, prefix }: { id: number; prefix?: string }) { + const [busy, setBusy] = React.useState(false); + async function create() { + setBusy(true); + try { + const res = await api.interceptAlwaysAllow(id); + toast.success( + res.created + ? "Rule created, the request is still pending approval" + : "Rule already exists, the request is still pending approval", + ); + } catch (e) { + toast.error((e as Error).message); + } finally { + setBusy(false); + } + } + return ( +
+ The prefix does not constrain flags, and it does not cover what the program does next (git hooks, npm + scripts, docker images). +
+