Compare commits

...
Author SHA1 Message Date
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
13 changed files with 682 additions and 28 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)
}
}
+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")
}
}
+35 -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,10 @@ 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.
return r.MatchTarget == "tool_input" && argvRuleMatches(r.argv, toolName, input)
}
var subject string
switch r.MatchTarget {
case "tool_name":
+8 -1
View File
@@ -663,8 +663,15 @@ 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)")
}
if _, err := intercept.ParseArgvPattern(req.Pattern); err != nil {
return fmt.Errorf("invalid argv pattern: %w", 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")
+25
View File
@@ -0,0 +1,25 @@
package server
import "testing"
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)
}
}
}
+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 />
+2 -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;