Compare commits

...
Author SHA1 Message Date
BaaRai 0edf3f0fa7 Merge pull request #211 from BaaRai/feat/always-allow-argv
Intercept: always allow a pending Bash prefix (argv rule, code-owned denylist)
2026-11-13 11:36:25 +01:00
BaaRai 62e65f1235 Intercept: always allow a pending Bash prefix (argv allow rule, code-owned denylist)
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.
2026-11-13 11:24:25 +01:00
BaaRai e2fd4908f7 Merge pull request #210 from BaaRai/feat/argv-prefix-rules
Intercept: argv prefix rules (match_type=argv)
2026-11-13 10:33:04 +01:00
BaaRai 2457a1f103 Intercept: argv prefix rules (match_type=argv) 2026-11-13 10:14:39 +01:00
BaaRai c5116f27ad Merge pull request #208 from BaaRai/feat/closed-question-judge
Intercept judge: closed-question mode (JEV), fail-closed to ask
2026-11-13 09:26:36 +01:00
19 changed files with 1181 additions and 29 deletions
+8
View File
@@ -52,6 +52,14 @@ AI autonomous penetration testing system (Go backend + Next.js frontend)
The global "Approval Records", the in-task "Interception Approvals", and the approval cards in conversations all support expanding to view details. The display structure references the
[AegisHook approval details component](https://github.com/RuoJi6/AegisHook/blob/main/web/src/components/CallDetail.vue), reusing ARTEX's components and theme:
## Argv prefix rules
Besides `string` and `regex`, an interception rule can use `match_type: argv` (Bash only, `match_target: tool_input`). Its pattern is a JSON array of 1 to 8 tokens, for example `["git","push"]`, with no empty token, no token starting with `-` and no shell metacharacter or space.
- **It is a prefix rule.** The command is parsed (mvdan.cc/sh, Bash grammar, as the Bash tool runs `bash -c`) and must be one simple command: no pipe, list (`;`, `&&`, `||`), redirection, prefix assignment, subshell, background job, no wrapper program (`command`, `builtin`, `env`, `exec`, `eval`), and no command or process substitution or parameter expansion anywhere. Its prefix (program plus subcommands, from a code-owned arity table: `git push`, `npm run build`, `nmap`) must equal the pattern exactly. Any other form does not match, and the normal flow (judge, approval) continues.
- **Flags are ignored by construction.** `["git","push"]` also covers `git push --force`; a flag before the subcommand (`git -C /repo push`) matches nothing. An allowed prefix does not cover what the program does internally.
- An argv rule takes its place in the priority order like any rule, and the target gate still runs after an argv allow.
## Traffic capture browser extension
+37
View File
@@ -0,0 +1,37 @@
package db
import "testing"
// A database created before argv rules has the old CHECK constraint on
// match_type. Applying the schema again replaces it, idempotently, so an argv
// rule can be stored; an unknown match_type is still refused.
func TestInterceptRulesArgvConstraintMigration(t *testing.T) {
d := openTargetDB(t)
t.Cleanup(func() {
_, _ = d.Exec(`DELETE FROM intercept_rules WHERE name LIKE 'argv migration test%'`)
_, _ = d.Exec(schemaSQL) // leave the current constraint in place
})
insert := func(name, matchType string) error {
_, err := d.CreateInterceptRule(name, "tool_input", matchType, `["git","push"]`, "allow", "", -1000, true, false, 60, "deny")
return err
}
// The constraint of an existing database, before this change.
if _, err := d.Exec(`ALTER TABLE intercept_rules DROP CONSTRAINT IF EXISTS intercept_rules_match_type_check;
ALTER TABLE intercept_rules ADD CONSTRAINT intercept_rules_match_type_check CHECK (match_type IN ('string', 'regex'))`); err != nil {
t.Fatal(err)
}
if err := insert("argv migration test old", "argv"); err == nil {
t.Fatal("the old constraint accepted an argv rule")
}
for range 2 { // idempotent
if _, err := d.Exec(schemaSQL); err != nil {
t.Fatalf("apply schema: %v", err)
}
}
if err := insert("argv migration test new", "argv"); err != nil {
t.Fatalf("argv rule refused after the migration: %v", err)
}
if err := insert("argv migration test bad", "glob"); err == nil {
t.Fatal("an unknown match_type was accepted")
}
}
+5 -1
View File
@@ -1076,7 +1076,7 @@ CREATE TABLE IF NOT EXISTS intercept_rules (
enabled BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 0,
match_target TEXT NOT NULL CHECK (match_target IN ('tool_name', 'tool_input')),
match_type TEXT NOT NULL CHECK (match_type IN ('string', 'regex')),
match_type TEXT NOT NULL CHECK (match_type IN ('string', 'regex', 'argv')),
pattern TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'ask')),
message TEXT NOT NULL DEFAULT '',
@@ -1086,6 +1086,10 @@ CREATE TABLE IF NOT EXISTS intercept_rules (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Allow argv prefix rules on databases created before them (pattern = JSON array of tokens).
ALTER TABLE intercept_rules DROP CONSTRAINT IF EXISTS intercept_rules_match_type_check;
ALTER TABLE intercept_rules ADD CONSTRAINT intercept_rules_match_type_check
CHECK (match_type IN ('string','regex','argv'));
DROP TRIGGER IF EXISTS trg_intercept_rules_upd ON intercept_rules;
CREATE TRIGGER trg_intercept_rules_upd BEFORE UPDATE ON intercept_rules
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+1 -1
View File
@@ -15,6 +15,7 @@ require (
golang.org/x/net v0.62.0
golang.org/x/text v0.43.0
modernc.org/sqlite v1.52.0
mvdan.cc/sh/v3 v3.14.1
)
require (
@@ -31,7 +32,6 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
+8 -7
View File
@@ -6,7 +6,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -16,12 +15,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0=
github.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -58,7 +59,6 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -76,9 +76,8 @@ github.com/projectdiscovery/utils v0.7.3 h1:kX+77AA58yK6EZgkTRJEnK9V/7AZYzlXdcu/
github.com/projectdiscovery/utils v0.7.3/go.mod h1:uDdQ3/VWomai98l+a3Ye/srDXdJ4xUIar/mSXlQ9gBM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
@@ -157,3 +156,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
+45
View File
@@ -0,0 +1,45 @@
package guard
import (
"context"
"strings"
"testing"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
)
// An argv allow rule is an allow like any other: a higher-priority deny rule
// still wins, and the target gate still runs after it.
func TestArgvAllowKeepsDenyAndTargetGate(t *testing.T) {
ic := intercept.New(nil)
ic.SetRulesForTest([]db.InterceptRule{
{ID: 1, Name: "no force push", Enabled: true, Priority: 50, MatchTarget: "tool_input", MatchType: "string",
Pattern: "--force", Action: "deny", Message: "force push is forbidden"},
{ID: 2, Name: "argv allow curl", Enabled: true, Priority: -1000, MatchTarget: "tool_input", MatchType: "argv",
Pattern: `["curl"]`, Action: "allow"},
{ID: 3, Name: "argv allow git push", Enabled: true, Priority: -1000, MatchTarget: "tool_input", MatchType: "argv",
Pattern: `["git","push"]`, Action: "allow"},
})
g := NewWithInterceptor(ic)
gate := &fakeGate{t: t, check: unpinnedOnce, request: func(int, []intercept.TargetRequest, *intercept.Decision) intercept.TargetResult {
return intercept.TargetResult{Code: intercept.TargetDenied, Host: "app.example.com", Port: 443, Reason: "the request was denied"}
}}
g.gate = gate
ctx := gateOn(context.Background(), "7", time.Minute)
if block, msg := pre(g, ctx, "Bash", `{"command":"git push --force origin"}`); !block || !strings.Contains(msg, "force push is forbidden") {
t.Fatalf("deny over argv allow: block=%v msg=%q", block, msg)
}
if gate.checks != 0 {
t.Fatalf("the gate was reached on a deny: %d checks", gate.checks)
}
block, msg := pre(g, ctx, "Bash", `{"command":"curl https://app.example.com/"}`)
if !block || !strings.Contains(msg, "TARGET_DENIED") {
t.Fatalf("argv allow bypassed the target gate: block=%v msg=%q", block, msg)
}
if gate.checks != 1 || len(gate.asked) != 1 || gate.asks[0] != nil {
t.Fatalf("checks=%d requests=%d asks=%v, want one check and one request without an ask", gate.checks, len(gate.asked), gate.asks)
}
}
+103
View File
@@ -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
}
+95
View File
@@ -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)
}
}
+244
View File
@@ -0,0 +1,244 @@
package intercept
import (
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"unicode"
"mvdan.cc/sh/v3/syntax"
)
// argvArity is the code-owned arity table (a port of cyberstrike's BashArity):
// how many leading tokens of a command form its human prefix, the program plus
// its subcommands. The longest entry that matches the start of the argv wins;
// a program that is not listed takes 1. It derives a prefix and nothing else:
// the security checks are in ParseSimpleCommand and ArgvPrefix.
var argvArity = map[string]int{
"cat": 1, "cd": 1, "ls": 1, "rm": 1, "grep": 1, "head": 1, "tail": 1,
"echo": 1, "pwd": 1, "find": 1, "curl": 1, "wget": 1, "nmap": 1,
"git": 2, "npm": 2, "docker": 2, "kubectl": 2, "terraform": 2,
"npm run": 3, "docker compose": 3, "kubectl rollout": 3, "git config": 3,
"gcloud": 3, "gh": 3, "aws": 3,
}
// argvPrefixLen returns the number of leading tokens that form the prefix.
func argvPrefixLen(tokens []string) int {
for n := len(tokens); n > 0; n-- {
if a, ok := argvArity[strings.Join(tokens[:n], " ")]; ok {
return min(a, len(tokens))
}
}
return min(1, len(tokens))
}
// ArgvPrefixOf returns the prefix of an argv: the program and its subcommands.
func ArgvPrefixOf(tokens []string) []string { return tokens[:argvPrefixLen(tokens)] }
// argvWord is one word of a parsed command. Literal reports that the word is
// fixed text: no expansion of any kind (no glob, tilde, brace, escape or
// parameter), so Value is exactly what the program receives.
type argvWord struct {
Value string
Literal bool
}
var (
errNotSimple = errors.New("not a single simple command")
errBackground = errors.New("the command runs in the background")
errRedirect = errors.New("the command has a redirection")
errAssign = errors.New("the command has a prefix assignment")
errWrapper = errors.New("the program is a wrapper that runs its arguments as a command")
)
// argvWrappers are programs that run their arguments as another command: the
// prefix of "command rm -rf /" would be ["command"], so no argv rule matches them.
var argvWrappers = map[string]bool{"command": true, "builtin": true, "env": true, "exec": true, "eval": true}
// ParseSimpleCommand parses a shell command line and accepts exactly one
// simple command: no assignment, redirection, pipeline, list (&&, ||, ;),
// subshell, background job or compound command, no wrapper program (command,
// builtin, env, exec, eval), and no command substitution, process
// substitution, parameter or arithmetic expansion or extended glob anywhere in
// the line. Arguments may hold plain globs and a tilde (they run nothing);
// such a word is returned with Literal false. The grammar is Bash, the shell
// the Bash tool runs the command with (norma tool/task.go shellCmd: bash -c).
func ParseSimpleCommand(command string) ([]argvWord, error) {
f, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(command), "")
if err != nil {
return nil, fmt.Errorf("the command does not parse: %w", err)
}
if len(f.Stmts) != 1 {
return nil, errNotSimple
}
st := f.Stmts[0]
switch {
case st.Background || st.Coprocess || st.Disown:
return nil, errBackground
case len(st.Redirs) > 0:
return nil, errRedirect
}
call, ok := st.Cmd.(*syntax.CallExpr)
if !ok || st.Negated {
return nil, errNotSimple
}
if len(call.Assigns) > 0 {
return nil, errAssign
}
if len(call.Args) == 0 {
return nil, errNotSimple
}
var bad error
syntax.Walk(f, func(n syntax.Node) bool {
switch n.(type) {
case *syntax.CmdSubst, *syntax.ProcSubst, *syntax.ParamExp, *syntax.ArithmExp, *syntax.ExtGlob, *syntax.BraceExp:
bad = errors.New("the command contains an expansion or a substitution")
}
return bad == nil
})
if bad != nil {
return nil, bad
}
words := make([]argvWord, 0, len(call.Args))
for _, w := range call.Args {
word, ok := argvWordOf(w)
if !ok {
return nil, errors.New("the command contains an expansion or a substitution")
}
words = append(words, word)
}
if argvWrappers[path.Base(words[0].Value)] {
return nil, errWrapper
}
return words, nil
}
// argvWordOf reads one word. ok is false for a part that is not text.
func argvWordOf(w *syntax.Word) (argvWord, bool) {
var sb strings.Builder
literal := true
for _, part := range w.Parts {
switch p := part.(type) {
case *syntax.Lit:
// Unquoted text: a glob, a tilde, a brace or an escape makes the word
// differ from its text, so it is not literal.
if strings.ContainsAny(p.Value, "*?[]{}~\\") {
literal = false
}
sb.WriteString(p.Value)
case *syntax.SglQuoted:
if p.Dollar {
literal = false
}
sb.WriteString(p.Value)
case *syntax.DblQuoted:
if p.Dollar {
literal = false
}
for _, q := range p.Parts {
lit, ok := q.(*syntax.Lit)
if !ok {
return argvWord{}, false
}
if strings.Contains(lit.Value, "\\") {
literal = false
}
sb.WriteString(lit.Value)
}
default:
return argvWord{}, false
}
}
return argvWord{Value: sb.String(), Literal: literal}, true
}
// ArgvPrefix parses a command and derives its prefix with the arity table. It
// refuses (returns an error) when the command is not one simple command, when
// a prefix token is not literal, and when a prefix token is a flag: a flag
// before a subcommand ("git -C /repo push") yields no prefix at all, rather
// than an invented one.
func ArgvPrefix(command string) ([]string, error) {
words, err := ParseSimpleCommand(command)
if err != nil {
return nil, err
}
tokens := make([]string, len(words))
for i, w := range words {
tokens[i] = w.Value
if !w.Literal {
tokens[i] = "\x00" // never a key of the arity table
}
}
n := argvPrefixLen(tokens)
for _, w := range words[:n] {
if !w.Literal {
return nil, errors.New("a token of the prefix is not literal text")
}
if err := checkArgvToken(w.Value); err != nil {
return nil, err
}
}
return tokens[:n], nil
}
// argvMaxTokens bounds the length of an argv pattern.
const argvMaxTokens = 8
// checkArgvToken applies the per-token pattern rules.
func checkArgvToken(tok string) error {
if tok == "" {
return errors.New("a token is empty")
}
if strings.HasPrefix(tok, "-") {
return fmt.Errorf("token %q is a flag; a prefix holds the program and its subcommands only", tok)
}
if strings.ContainsAny(tok, ";&|><`$()") || strings.IndexFunc(tok, unicode.IsSpace) >= 0 {
return fmt.Errorf("token %q contains a shell metacharacter or a space", tok)
}
return nil
}
// ParseArgvPattern validates the pattern of an argv rule: a JSON array of 1 to
// 8 tokens, none empty, none starting with '-', none holding a shell
// metacharacter (; & | > < ` $ ( )) or whitespace.
func ParseArgvPattern(pattern string) ([]string, error) {
var tokens []string
if err := json.Unmarshal([]byte(pattern), &tokens); err != nil {
return nil, fmt.Errorf(`an argv pattern is a JSON array of strings, for example ["git","push"]: %w`, err)
}
if len(tokens) < 1 || len(tokens) > argvMaxTokens {
return nil, fmt.Errorf("an argv pattern has 1 to %d tokens, got %d", argvMaxTokens, len(tokens))
}
for _, tok := range tokens {
if err := checkArgvToken(tok); err != nil {
return nil, err
}
}
return tokens, nil
}
// argvRuleMatches reports whether a Bash call's command derives exactly the
// prefix tokens. Flags after the prefix are not constrained.
func argvRuleMatches(tokens []string, toolName string, input []byte) bool {
if toolName != "Bash" || len(tokens) == 0 {
return false
}
var in struct {
Command string `json:"command"`
}
if json.Unmarshal(input, &in) != nil {
return false
}
prefix, err := ArgvPrefix(in.Command)
if err != nil || len(prefix) != len(tokens) {
return false
}
for i := range prefix {
if prefix[i] != tokens[i] {
return false
}
}
return true
}
+236
View File
@@ -0,0 +1,236 @@
package intercept
import (
"encoding/json"
"errors"
"slices"
"testing"
"github.com/Autumn-27/artex/db"
)
func TestArgvArityTable(t *testing.T) {
for _, c := range []struct {
argv, want []string
}{
{[]string{"git", "push", "origin", "main"}, []string{"git", "push"}},
{[]string{"git", "config", "user.name", "x"}, []string{"git", "config", "user.name"}},
{[]string{"npm", "install", "left-pad"}, []string{"npm", "install"}},
{[]string{"npm", "run", "build", "--watch"}, []string{"npm", "run", "build"}},
{[]string{"docker", "compose", "up", "-d"}, []string{"docker", "compose", "up"}},
{[]string{"docker", "ps"}, []string{"docker", "ps"}},
{[]string{"kubectl", "rollout", "status", "x"}, []string{"kubectl", "rollout", "status"}},
{[]string{"gh", "pr", "list", "--json"}, []string{"gh", "pr", "list"}},
{[]string{"nmap", "-p", "80", "x"}, []string{"nmap"}}, // fallback to tokens[0]
{[]string{"unknowntool", "a", "b"}, []string{"unknowntool"}}, // fallback to tokens[0]
{[]string{"cat", "/etc/hosts"}, []string{"cat"}},
{[]string{"git"}, []string{"git"}}, // arity longer than the argv
{nil, []string{}},
} {
if got := ArgvPrefixOf(c.argv); !slices.Equal(got, c.want) {
t.Errorf("prefix(%q) = %q, want %q", c.argv, got, c.want)
}
}
}
func TestArgvPrefixParsing(t *testing.T) {
ok := []struct {
cmd string
want []string
}{
{"nmap -p 80 10.0.0.1", []string{"nmap"}},
{"git push --force origin main", []string{"git", "push"}},
{" git push ", []string{"git", "push"}},
{`'git' "push" origin`, []string{"git", "push"}}, // quoting is removed
{`git 'pu'"sh"`, []string{"git", "push"}},
{`nmap "a b" 'c;d'`, []string{"nmap"}}, // metacharacters quoted in arguments
{`nmap *.txt ~/scan`, []string{"nmap"}}, // glob and tilde after the prefix
{`ls -la # $(id) in a comment`, []string{"ls"}}, // a comment runs nothing
{"npm run build", []string{"npm", "run", "build"}},
}
for _, c := range ok {
got, err := ArgvPrefix(c.cmd)
if err != nil || !slices.Equal(got, c.want) {
t.Errorf("ArgvPrefix(%q) = %q, %v; want %q", c.cmd, got, err, c.want)
}
}
refused := []string{
"",
"nmap x; rm -rf /", // list
"nmap x && id", // and
"nmap x || id", // or
"nmap x | tee out", // pipeline
"(nmap x)", // subshell
"{ nmap x; }", // block
"! nmap x", // negation
"nmap $(id)", // command substitution in an argument
"nmap `id`", // backquote substitution
`nmap "$(id)"`, // substitution inside double quotes
"nmap x$(id)y", // substitution inside a word
"nmap <(id)", // process substitution
"nmap $HOME", // parameter expansion
`nmap "${x}"`, // parameter expansion in quotes
"nmap $((1+2))", // arithmetic expansion
"nmap @(a|b)", // extended glob
"$X push", // expanded program
"git $SUB", // expanded subcommand
"git *", // glob in the prefix
"git ~", // tilde in the prefix
`g\it push`, // escape in the prefix
"git -C /repo push", // flag before the subcommand
"git --no-pager log", // flag before the subcommand
"if true; then nmap x; fi", // compound
"for i in 1; do nmap; done", // loop
"nmap x\nid", // two statements
"time nmap x", // time clause
"export X=1", // declaration
"'' push", // empty program
"nmap 'unterminated", // parse error
}
for _, cmd := range refused {
if got, err := ArgvPrefix(cmd); err == nil {
t.Errorf("ArgvPrefix(%q) = %q, want a refusal", cmd, got)
}
}
}
// refusedWith checks that each command is refused by the parser with want, and
// that an argv rule for its program matches nothing.
func refusedWith(t *testing.T, want error, cmds ...string) {
t.Helper()
for _, cmd := range cmds {
if _, err := ParseSimpleCommand(cmd); !errors.Is(err, want) {
t.Errorf("ParseSimpleCommand(%q) error = %v, want %v", cmd, err, want)
}
input, _ := json.Marshal(map[string]string{"command": cmd})
for _, p := range [][]string{{"nmap"}, {"command"}, {"builtin"}, {"env"}} {
if argvRuleMatches(p, "Bash", input) {
t.Errorf("%q matched the argv rule %q", cmd, p)
}
}
}
}
func TestArgvRejectsBackground(t *testing.T) {
refusedWith(t, errBackground, "nmap x &", "nmap -p 80 x&")
}
func TestArgvRejectsRedirections(t *testing.T) {
refusedWith(t, errRedirect, "nmap x > out", "nmap x >> out", "nmap x 2>&1", "nmap x < in",
"nmap x <<EOF\nhi\nEOF", "nmap x <<< hi", "nmap x &> out", "> out nmap x")
}
func TestArgvRejectsPrefixAssignments(t *testing.T) {
refusedWith(t, errAssign, "FOO=bar nmap x", "LD_PRELOAD=/tmp/x.so nmap", "A=1 B=2 nmap x", "PATH=. nmap")
}
func TestArgvRejectsWrappers(t *testing.T) {
refusedWith(t, errWrapper, "command nmap x", "command -p rm -rf /", "builtin echo x", "env nmap x",
"env FOO=1 nmap", "/usr/bin/env nmap", "exec nmap x", "eval nmap x", `"command" rm x`)
}
// A command that does not parse is no match, never an error: Match reports
// no rule and the normal flow (judge, approval) continues.
func TestArgvParseFailureIsNoMatch(t *testing.T) {
ic := New(nil)
ic.SetRulesForTest([]db.InterceptRule{{ID: 1, Name: "argv allow nmap", Enabled: true, MatchTarget: "tool_input",
MatchType: "argv", Pattern: `["nmap"]`, Action: "allow"}})
for _, input := range []string{`{"command":"nmap 'unterminated"}`, `{"command":"nmap $("}`, `{"command":""}`, `{}`, `not json`} {
if dec, ok := ic.Match("Bash", []byte(input)); ok {
t.Errorf("%s matched: %+v", input, dec)
}
}
}
func TestArgvRuleMatching(t *testing.T) {
bash := func(cmd string) []byte {
b, _ := json.Marshal(map[string]string{"command": cmd})
return b
}
for _, c := range []struct {
pattern []string
tool string
cmd string
want bool
}{
{[]string{"nmap"}, "Bash", "nmap -p 80 x", true},
{[]string{"nmap"}, "Bash", "nmapx -p 80 x", false},
{[]string{"nmap"}, "Bash", "nmap x; rm -rf /", false},
{[]string{"nmap"}, "Bash", "nmap $(rm -rf /)", false},
{[]string{"nmap"}, "Bash", "nmap x`id`", false},
{[]string{"nmap"}, "Bash", "/usr/bin/nmap x", false},
{[]string{"nmap"}, "shell_open", "nmap x", false}, // Bash only
{[]string{"git", "push"}, "Bash", "git push --force", true},
{[]string{"git", "push"}, "Bash", "git -C /repo push", false},
{[]string{"git", "push"}, "Bash", "git pushx", false},
{[]string{"git"}, "Bash", "git push", false}, // strict equality with the derived prefix
{[]string{"git", "push", "origin"}, "Bash", "git push origin", false},
} {
if got := argvRuleMatches(c.pattern, c.tool, bash(c.cmd)); got != c.want {
t.Errorf("%q on %s %q = %v, want %v", c.pattern, c.tool, c.cmd, got, c.want)
}
}
if argvRuleMatches([]string{"nmap"}, "Bash", []byte(`not json`)) {
t.Error("unreadable input matched")
}
}
func TestParseArgvPattern(t *testing.T) {
if got, err := ParseArgvPattern(`["git","push"]`); err != nil || !slices.Equal(got, []string{"git", "push"}) {
t.Fatalf("valid pattern: %q %v", got, err)
}
for name, p := range map[string]string{
"not json": "git push",
"not strings": `[1,2]`,
"empty array": `[]`,
"empty token": `["git",""]`,
"flag token": `["git","--force"]`,
"dash token": `["-rf"]`,
"semicolon": `["nmap;id"]`,
"ampersand": `["a&b"]`,
"pipe": `["a|b"]`,
"redirect": `["a>b"]`,
"input": `["a<b"]`,
"backquote": "[\"a`b\"]",
"dollar": `["$HOME"]`,
"parenthesis": `["a(b)"]`,
"space": `["git push"]`,
"newline": `["a\nb"]`,
"tab": `["a\tb"]`,
"nine tokens": `["a","b","c","d","e","f","g","h","i"]`,
"empty string": ``,
} {
if _, err := ParseArgvPattern(p); err == nil {
t.Errorf("%s: %q accepted", name, p)
}
}
if _, err := ParseArgvPattern(`["a","b","c","d","e","f","g","h"]`); err != nil {
t.Errorf("eight tokens refused: %v", err)
}
}
// An argv allow rule takes its place in the priority order: a deny rule with a
// higher priority still wins, and an argv rule with a bad pattern is not loaded.
func TestArgvAllowBelowDeny(t *testing.T) {
ic := New(nil)
ic.SetRulesForTest([]db.InterceptRule{
{ID: 1, Name: "no force push", Enabled: true, Priority: 50, MatchTarget: "tool_input", MatchType: "regex",
Pattern: `--force`, Action: "deny"},
{ID: 2, Name: "argv allow git push", Enabled: true, Priority: -1000, MatchTarget: "tool_input", MatchType: "argv",
Pattern: `["git","push"]`, Action: "allow"},
})
dec, ok := ic.Match("Bash", []byte(`{"command":"git push --force"}`))
if !ok || dec.Action != "deny" || dec.RuleID != 1 {
t.Fatalf("force push: %+v %v, want the deny rule", dec, ok)
}
dec, ok = ic.Match("Bash", []byte(`{"command":"git push origin"}`))
if !ok || dec.Action != "allow" || dec.RuleID != 2 {
t.Fatalf("push: %+v %v, want the argv allow", dec, ok)
}
if _, ok := ic.Match("Bash", []byte(`{"command":"git push; id"}`)); ok {
t.Fatal("a compound command matched the argv rule")
}
if _, ok := compileRule(db.InterceptRule{MatchType: "argv", Pattern: `["-x"]`}); ok {
t.Fatal("an argv rule with a flag token compiled")
}
}
+40 -12
View File
@@ -110,10 +110,33 @@ func TargetScopeFromContext(ctx context.Context) (s TargetScope, ok bool) {
return s, ok
}
// compiledRule is an InterceptRule with the regex pre-compiled (nil for string rules).
// compiledRule is an InterceptRule with the regex pre-compiled (nil for string rules)
// and the argv tokens pre-parsed (nil for non-argv rules).
type compiledRule struct {
db.InterceptRule
re *regexp.Regexp
re *regexp.Regexp
argv []string
}
// compileRule prepares a rule for matching. ok is false for a rule whose
// pattern does not compile; such a rule is skipped rather than crashing.
func compileRule(r db.InterceptRule) (compiledRule, bool) {
cr := compiledRule{InterceptRule: r}
switch r.MatchType {
case "regex":
re, err := regexp.Compile(r.Pattern)
if err != nil {
return cr, false
}
cr.re = re
case "argv":
tokens, err := ParseArgvPattern(r.Pattern)
if err != nil {
return cr, false
}
cr.argv = tokens
}
return cr, true
}
// pendingManager tracks in-flight approval requests via per-waiter channels. A
@@ -260,13 +283,9 @@ func (i *Interceptor) loadLocked() error {
if !r.Enabled {
continue
}
cr := compiledRule{InterceptRule: r}
if r.MatchType == "regex" {
re, err := regexp.Compile(r.Pattern)
if err != nil {
continue // skip rules with bad regex rather than crashing
}
cr.re = re
cr, ok := compileRule(r)
if !ok {
continue // skip rules with a bad pattern rather than crashing
}
out = append(out, cr)
}
@@ -716,9 +735,9 @@ func (i *Interceptor) SetRulesForTest(rules []db.InterceptRule) {
if !r.Enabled {
continue
}
cr := compiledRule{InterceptRule: r}
if r.MatchType == "regex" {
cr.re = regexp.MustCompile(r.Pattern)
cr, ok := compileRule(r)
if !ok {
panic("SetRulesForTest: bad pattern " + r.Pattern)
}
out = append(out, cr)
}
@@ -732,6 +751,15 @@ 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
switch r.MatchTarget {
case "tool_name":
+88 -2
View File
@@ -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.
@@ -663,8 +725,21 @@ func validateInterceptRuleReq(req interceptRuleReq) error {
}
switch req.MatchType {
case "string", "regex":
case "argv":
if req.MatchTarget != "tool_input" {
return fmt.Errorf("an argv rule matches tool_input (the Bash command)")
}
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 or regex")
return fmt.Errorf("match_type must be string, regex or argv")
}
if req.Pattern == "" {
return fmt.Errorf("pattern cannot be empty")
@@ -701,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.
+147
View File
@@ -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)
}
}
+54
View File
@@ -0,0 +1,54 @@
package server
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"}
if err := validateInterceptRuleReq(base); err != nil {
t.Fatalf("valid argv rule refused: %v", err)
}
for name, mut := range map[string]func(*interceptRuleReq){
"tool_name target": func(r *interceptRuleReq) { r.MatchTarget = "tool_name" },
"empty pattern": func(r *interceptRuleReq) { r.Pattern = "" },
"empty token": func(r *interceptRuleReq) { r.Pattern = `["git",""]` },
"flag token": func(r *interceptRuleReq) { r.Pattern = `["git","-C"]` },
"metacharacter": func(r *interceptRuleReq) { r.Pattern = `["nmap;id"]` },
"nine tokens": func(r *interceptRuleReq) { r.Pattern = `["a","b","c","d","e","f","g","h","i"]` },
"not an array": func(r *interceptRuleReq) { r.Pattern = `git push` },
} {
req := base
mut(&req)
if err := validateInterceptRuleReq(req); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
// 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)
}
}
}
+1
View File
@@ -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)
+28 -5
View File
@@ -149,7 +149,7 @@ type RuleForm = {
enabled: boolean;
priority: number;
match_target: "tool_name" | "tool_input";
match_type: "string" | "regex";
match_type: "string" | "regex" | "argv";
pattern: string;
action: InterceptAction;
message: string;
@@ -172,6 +172,17 @@ const defaultForm = (): RuleForm => ({
timeout_action: "deny",
});
// An argv pattern (JSON array of tokens) shown as a prefix: ["nmap"] -> "nmap *".
function argvLabel(pattern: string): string {
try {
const tokens: unknown = JSON.parse(pattern);
if (Array.isArray(tokens)) return `${tokens.join(" ")} *`;
} catch {
// not a JSON array: show the raw pattern
}
return pattern;
}
// ---- small components ----
function ActionBadge({ action }: { action: InterceptAction }) {
@@ -720,12 +731,12 @@ export default function InterceptPage() {
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_type === "regex" ? "Regex" : "String"}
{rule.match_type === "regex" ? "Regex" : rule.match_type === "argv" ? "Argv prefix" : "String"}
</span>
</TableCell>
<TableCell className="max-w-[220px]">
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
{rule.pattern}
{rule.match_type === "argv" ? argvLabel(rule.pattern) : rule.pattern}
</code>
</TableCell>
<TableCell>
@@ -814,19 +825,24 @@ export default function InterceptPage() {
<Field label="Match type">
<Select
value={form.match_type}
onValueChange={(v) => set({ match_type: v as RuleForm["match_type"] })}
onValueChange={(v) => {
const match_type = v as RuleForm["match_type"];
// An argv rule always matches the Bash command in tool_input.
set(match_type === "argv" ? { match_type, match_target: "tool_input" } : { match_type });
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="string">String contains</SelectItem>
<SelectItem value="regex">Regular expression</SelectItem>
<SelectItem value="argv">Argv prefix (Bash only)</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Pattern">
<Input
placeholder={form.match_type === "regex" ? "^Bash$" : "rm -rf"}
placeholder={form.match_type === "regex" ? "^Bash$" : form.match_type === "argv" ? '["git","push"]' : "rm -rf"}
value={form.pattern}
onChange={(e) => set({ pattern: e.target.value })}
className={regexErr ? "border-destructive focus-visible:ring-destructive" : ""}
@@ -837,6 +853,13 @@ export default function InterceptPage() {
{regexWarn && (
<p className="text-xs text-amber-600 mt-1">Contains Go RE2 extended syntax (e.g. <code className="font-mono">(?i)</code>); the browser cannot preview it, and the server validates on submission</p>
)}
{form.match_type === "argv" && (
<p className="text-xs text-amber-600 mt-1">
JSON array of 1 to 8 tokens: the program and its subcommands, no flags. Matches a single simple Bash command
whose prefix is exactly <code className="font-mono">{argvLabel(form.pattern)}</code>. Flags are not constrained:
<code className="font-mono"> [&quot;git&quot;,&quot;push&quot;]</code> also covers <code className="font-mono">git push --force</code>.
</p>
)}
</Field>
<Separator />
+35
View File
@@ -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 (
<div className="flex flex-col gap-1">
<Button size="sm" variant="outline" className="self-start" disabled={busy} onClick={() => void create()}>
{prefix ? `Toujours autoriser "${prefix} *"` : "Toujours autoriser"}
</Button>
<p className="text-muted-foreground text-xs">
The prefix does not constrain flags, and it does not cover what the program does next (git hooks, npm
scripts, docker images).
</p>
</div>
);
}
export function ApprovalDetail({
row,
busy,
@@ -418,6 +450,9 @@ export function ApprovalDetail({
) : null}
</dl>
{!readOnly ? <DecisionActions row={current} busy={busy} decide={decide} /> : null}
{!readOnly && current.status === "pending" && row.tool_name === "Bash" ? (
<AlwaysAllowAction id={row.id} prefix={detail?.always_allow_prefix} />
) : null}
</div>
</div>
{error ? (
+3
View File
@@ -1232,6 +1232,9 @@ export const api = {
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
interceptDecide: (id: number, decision: "allowed" | "denied") =>
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
// No body: the server derives the rule from the stored request, which stays pending.
interceptAlwaysAllow: (id: number) =>
post<{ rule: InterceptRule; created: boolean }>(`/intercept/pending/${id}/always-allow`),
interceptExecution: (id: number, conversationId?: number) =>
get<import("@/lib/types").InterceptExecution>(
`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`,
+3 -1
View File
@@ -1278,7 +1278,8 @@ export interface Stats {
// ---- Intercept Rules ----
export type InterceptAction = "allow" | "deny" | "ask";
export type InterceptMatchTarget = "tool_name" | "tool_input";
export type InterceptMatchType = "string" | "regex";
// argv: pattern is a JSON array of tokens, the prefix of a Bash command (program and subcommands; flags are not constrained).
export type InterceptMatchType = "string" | "regex" | "argv";
export interface InterceptRule {
id: number;
@@ -1559,6 +1560,7 @@ export interface InterceptAudit {
}
export interface InterceptDetail extends InterceptApprovalRow {
audit: InterceptAudit | null;
always_allow_prefix?: string; // pending Bash request: the argv prefix "always allow" would allow
}
export type TrafficEvidenceRole = "baseline" | "proof" | "verification" | "supporting";