package approval import ( "context" "encoding/json" "errors" "slices" "strings" "testing" ) func bashInput(cmd string) json.RawMessage { b, _ := json.Marshal(map[string]string{"command": cmd}) return b } func pathInput(p string) json.RawMessage { b, _ := json.Marshal(map[string]string{"path": p}) return b } func mustRule(t *testing.T, s string) Rule { t.Helper() r, err := ParseRule(s) if err != nil { t.Fatalf("ParseRule(%q): %v", s, err) } return r } func TestSplitRulesKeepsParenthesesWhole(t *testing.T) { got := SplitRules(`Read,Edit Bash(git log --oneline, -n 5) "x" Bash(npm run test:*),mcp__github`) want := []string{"Read", "Edit", "Bash(git log --oneline, -n 5)", `"x"`, "Bash(npm run test:*)", "mcp__github"} if !slices.Equal(got, want) { t.Errorf("SplitRules = %q, want %q", got, want) } if got := SplitRules(" , "); len(got) != 0 { t.Errorf("an empty list should split to nothing, got %q", got) } } func TestParseRuleMapsClaudeCodeNames(t *testing.T) { cases := []struct { rule string tools []string }{ {"Bash", []string{"bash"}}, {"Read", []string{"read_file"}}, // A path-scoped Read rule reaches the search tools too, as Claude // Code's does. {"Read(./secrets/**)", []string{"read_file", "glob", "grep"}}, {"Edit", []string{"edit_file", "write_file"}}, {"MultiEdit", []string{"edit_file"}}, {"Write", []string{"write_file"}}, {"Glob", []string{"glob"}}, {"Grep", []string{"grep"}}, {"WebFetch", []string{"web_fetch"}}, {"WebSearch", []string{"web_search"}}, {"TodoWrite", []string{"todo_write"}}, {"Task", []string{"spawn_agent"}}, {"bash(git status)", []string{"bash"}}, {"process_kill", []string{"process_kill"}}, {"mcp__github", []string{"mcp__github"}}, {"mcp__github__*", []string{"mcp__github"}}, {"mcp__github__create_issue", []string{"mcp__github__create_issue"}}, } for _, c := range cases { r := mustRule(t, c.rule) if !slices.Equal(r.Tools, c.tools) { t.Errorf("%s covers %q, want %q", c.rule, r.Tools, c.tools) } if r.String() != c.rule { t.Errorf("String() = %q, want the rule as written", r.String()) } } } func TestParseRuleRejectsWhatItCannotRead(t *testing.T) { for _, s := range []string{"", "Frobnicate", "NotebookEdit", "Bash(git status", "Glob(*.go)", "WebFetch(example.com)", "WebFetch(domain:)", "mcp__", "mcp__srv(x)", "(x)"} { if _, err := ParseRule(s); err == nil { t.Errorf("ParseRule(%q) should fail", s) } } _, err := ParseRule("Frobnicate") if !errors.Is(err, ErrUnknownTool) { t.Errorf("an unknown tool should be ErrUnknownTool, got %v", err) } } func TestParseRulesKeepsTheGoodOnes(t *testing.T) { rules, errs := ParseRules([]string{"Bash", "Nope", "Read"}) if len(rules) != 2 || len(errs) != 1 { t.Fatalf("got %d rules and %d errors, want 2 and 1", len(rules), len(errs)) } } func TestStarIsTheWholeTool(t *testing.T) { r := mustRule(t, "Bash(*)") if !r.WholeTool() { t.Error("Bash(*) should be the whole tool") } if !r.Matches("bash", bashInput("echo $(rm -rf ~)")) { t.Error("a whole-tool rule allows even what the analysis cannot read") } } func TestBashExactRule(t *testing.T) { r := mustRule(t, "Bash(git status)") for cmd, want := range map[string]bool{ "git status": true, " git status ": true, "git 'status'": true, "git status --short": false, "git statusx": false, "git": false, "git status; rm -rf ~": false, } { if got := r.Matches("bash", bashInput(cmd)); got != want { t.Errorf("Bash(git status) matches %q = %v, want %v", cmd, got, want) } } if r.Matches("read_file", bashInput("git status")) { t.Error("a Bash rule says nothing about another tool") } } func TestBashPrefixRuleIsWordAware(t *testing.T) { for _, spec := range []string{"Bash(npm run test:*)", "Bash(npm run test *)"} { r := mustRule(t, spec) for cmd, want := range map[string]bool{ "npm run test": true, "npm run test -- --watch": true, "npm run test:unit": false, "npm run testing": false, "npm run build": false, "npm run test && npm publish": false, "cd web && npm run test": false, } { if got := r.Matches("bash", bashInput(cmd)); got != want { t.Errorf("%s matches %q = %v, want %v", spec, cmd, got, want) } } } ls := mustRule(t, "Bash(ls:*)") if ls.Matches("bash", bashInput("lsof -i")) { t.Error("Bash(ls:*) must not match lsof") } if !ls.Matches("bash", bashInput("ls -la src")) { t.Error("Bash(ls:*) should match ls -la") } } func TestBashCompoundCommandsNeedEveryPartAllowed(t *testing.T) { r := mustRule(t, "Bash(git status:*)") for _, cmd := range []string{ "git status; rm -rf ~", "git status && rm -rf ~", "git status || rm -rf ~", "git status | sh", "rm -rf ~ & git status", "if true; then rm -rf ~; fi; git status", "(git status; rm -rf ~)", "git status\nrm -rf ~", "f() { rm -rf ~; }; git status", } { if r.Matches("bash", bashInput(cmd)) { t.Errorf("Bash(git status:*) must not match %q", cmd) } if v, _ := (Ruleset{Allow: []Rule{r}}).Check("bash", bashInput(cmd)); v == Allowed { t.Errorf("the ruleset must not allow %q", cmd) } } if !r.Matches("bash", bashInput("git status && git status -s")) { t.Error("a compound of allowed commands is allowed") } } func TestBashOpaqueCommandsAreNeverAllowedByAPrefix(t *testing.T) { r := mustRule(t, "Bash(git:*)") for _, cmd := range []string{ "git log $(rm -rf ~)", "git log `rm -rf ~`", "git diff <(rm -rf ~)", "$GIT status", "PATH=/tmp/evil git status", "export PATH=/tmp; git status", "git status > ~/.bashrc", "git status >> notes.txt", "git status &> out", "git status >&out", "git status $(", "bash -c 'git status'", } { if r.Matches("bash", bashInput(cmd)) { t.Errorf("Bash(git:*) must not match %q", cmd) } } for _, cmd := range []string{ "git status 2>&1", "git status > /dev/null", "git status 2>/dev/null", "git log -n 5 \"$BRANCH\"", "git log -- *.go", } { if !r.Matches("bash", bashInput(cmd)) { t.Errorf("Bash(git:*) should match %q", cmd) } } } func TestBashExpansionsCannotStandInForARuleWord(t *testing.T) { r := mustRule(t, "Bash(npm run test:*)") for _, cmd := range []string{"npm run $TARGET", "npm run t*", "npm run {test,deploy}", "npm ~ test"} { if r.Matches("bash", bashInput(cmd)) { t.Errorf("%q has an expansion where the rule has a word, and must not match", cmd) } } if !r.Matches("bash", bashInput("npm run test -- $EXTRA")) { t.Error("an expansion in the wildcard tail is fine") } } func TestBashMiddleWildcard(t *testing.T) { r := mustRule(t, "Bash(git * main)") if !r.Matches("bash", bashInput("git push origin main")) { t.Error("Bash(git * main) should match git push origin main") } if r.Matches("bash", bashInput("git push origin dev")) { t.Error("Bash(git * main) must not match a different branch") } } func TestRulesetAllowsACompoundCoveredByDifferentRules(t *testing.T) { set := Ruleset{Allow: []Rule{mustRule(t, "Bash(git status)"), mustRule(t, "Bash(npm test:*)")}} if v, r := set.Check("bash", bashInput("git status && npm test -- -run X")); v != Allowed || r.Raw != "Bash(git status)" { t.Errorf("got %v by %q, want Allowed by the first rule", v, r.Raw) } if v, _ := set.Check("bash", bashInput("git status && npm publish")); v != Unmatched { t.Errorf("one uncovered command leaves the call unmatched, got %v", v) } } func TestDenyTouchesAnyPart(t *testing.T) { r := mustRule(t, "Bash(rm:*)") for _, cmd := range []string{ "rm -rf build", "git status; rm -rf ~", "make && rm -rf ~", "echo $(rm -rf ~)", "git log `rm x`", "cat <(rm x)", "sudo rm -rf /", "sudo -u root rm -rf /", "env FOO=1 rm x", "FOO=1 rm x", "timeout 5 rm x", "nice -n 10 rm x", "xargs rm < list", "/bin/rm -rf x", `r\m -rf x`, `"rm" -rf x`, "bash -c 'rm -rf ~'", "sh -c \"cd /tmp && rm -rf x\"", "eval rm -rf x", "bash -c 'bash -c \"rm x\"'", "rm -rf x $(", "if true; then rm x; fi", } { if !r.Touches("bash", bashInput(cmd)) { t.Errorf("Bash(rm:*) should touch %q", cmd) } } for _, cmd := range []string{"git rm --cached x", "echo rm", "rmdir x", "ls"} { if r.Touches("bash", bashInput(cmd)) { t.Errorf("Bash(rm:*) must not touch %q", cmd) } } } func TestDenyWinsOverAllow(t *testing.T) { set := Ruleset{ Allow: []Rule{mustRule(t, "Bash")}, Deny: []Rule{mustRule(t, "Bash(git push:*)")}, } if v, r := set.Check("bash", bashInput("git commit -m x && git push origin main")); v != Denied || r.Raw != "Bash(git push:*)" { t.Errorf("got %v by %q, want Denied by the push rule", v, r.Raw) } if v, _ := set.Check("bash", bashInput("git commit -m x")); v != Allowed { t.Errorf("the rest of Bash stays allowed, got %v", v) } } func TestPathRules(t *testing.T) { root, home := "/work/repo", "/home/me" cases := []struct { rule string path string want bool }{ {"Read(./secrets/**)", "secrets/a.txt", true}, {"Read(./secrets/**)", "/work/repo/secrets/deep/a.txt", true}, {"Read(./secrets/**)", "src/secrets/a.txt", false}, {"Read(/secrets/**)", "secrets/a.txt", true}, {"Read(secrets/**)", "secrets/a.txt", true}, {"Read(secrets/**)", "src/secrets/a.txt", false}, // No slash: a name at any depth, as in gitignore. {"Read(.env)", ".env", true}, {"Read(.env)", "config/.env", true}, {"Read(.env)", ".envrc", false}, {"Read(*.pem)", "certs/server.pem", true}, {"Read(secrets/)", "a/secrets/b.txt", true}, {"Read(//etc/**)", "/etc/passwd", true}, {"Read(//etc/**)", "etc/passwd", false}, {"Read(~/.ssh/**)", "/home/me/.ssh/id_ed25519", true}, {"Edit(./src/*.go)", "src/main.go", true}, {"Edit(./src/*.go)", "src/sub/main.go", false}, {"Edit(./src/**/*.go)", "src/sub/main.go", true}, {"Edit(./src/**/*.go)", "src/main.go", true}, {"Edit(./docs/**)", "../repo/docs/x.md", true}, {"Edit(./docs/**)", "docs/../src/x.go", false}, } for _, c := range cases { r := mustRule(t, c.rule) r.Root, r.Home = root, home tool := r.Tools[0] if got := r.Matches(tool, pathInput(c.path)); got != c.want { t.Errorf("%s on %q = %v, want %v", c.rule, c.path, got, c.want) } } } func TestPathRulesReachEveryToolTheyName(t *testing.T) { edit := mustRule(t, "Edit(./gen/**)") edit.Root = "/r" for _, tool := range []string{"edit_file", "write_file"} { if !edit.Matches(tool, pathInput("gen/x.go")) { t.Errorf("Edit(...) should cover %s", tool) } } read := mustRule(t, "Read(./secrets/**)") read.Root = "/r" if !read.Touches("grep", pathInput("secrets")) && !read.Touches("grep", pathInput("secrets/x")) { t.Error("a Read path rule should reach grep") } if read.Touches("bash", bashInput("cat secrets/x")) { t.Error("a Read rule says nothing about bash") } } func TestDomainRules(t *testing.T) { in := func(u string) json.RawMessage { b, _ := json.Marshal(map[string]string{"url": u}) return b } r := mustRule(t, "WebFetch(domain:Example.com)") if !r.Matches("web_fetch", in("https://example.com/docs")) { t.Error("the domain itself should match, case-insensitively") } if r.Matches("web_fetch", in("https://evil-example.com/")) || r.Matches("web_fetch", in("https://api.example.com/")) { t.Error("a domain rule is exact unless it says *.") } sub := mustRule(t, "WebFetch(domain:*.example.com)") if !sub.Matches("web_fetch", in("https://api.example.com/x")) || sub.Matches("web_fetch", in("https://example.com/")) { t.Error("*.example.com is the subdomains") } if r.Matches("web_fetch", in("not a url")) { t.Error("an unreadable URL matches nothing") } } func TestMCPRules(t *testing.T) { server := mustRule(t, "mcp__github") for tool, want := range map[string]bool{ "mcp__github__create_issue": true, "mcp__github": true, "mcp__githubber__x": false, "mcp__gitlab__x": false, } { if got := server.Covers(tool); got != want { t.Errorf("mcp__github covers %q = %v, want %v", tool, got, want) } } one := mustRule(t, "mcp__github__create_issue") if !one.Matches("mcp__github__create_issue", nil) || one.Matches("mcp__github__delete_repo", nil) { t.Error("a tool rule is that tool only") } } func TestPolicyDenyRuleBeatsYOLOAndReadOnly(t *testing.T) { p := New(nil) p.YOLO = true p.Rules = Ruleset{Deny: []Rule{mustRule(t, "Bash(rm:*)"), mustRule(t, "Read(.env)")}} err := p.Approve(context.Background(), "bash", bashInput("ls && rm -rf ~")) if err == nil || !strings.Contains(err.Error(), "Bash(rm:*)") { t.Errorf("a deny rule should stop even a YOLO session, and say which rule: %v", err) } if err := p.Approve(context.Background(), "read_file", pathInput(".env")); err == nil { t.Error("a deny rule reaches the read-only tools too") } if err := p.Approve(context.Background(), "bash", bashInput("ls")); err != nil { t.Errorf("everything else stays YOLO: %v", err) } } func TestPolicyAllowRuleStandsInForTheQuestion(t *testing.T) { asked := 0 p := New(func(context.Context, Request) (Decision, error) { asked++; return Deny, nil }) p.Rules = Ruleset{Allow: []Rule{mustRule(t, "Bash(go test:*)")}} if err := p.Approve(context.Background(), "bash", bashInput("go test ./...")); err != nil || asked != 0 { t.Errorf("an allowed call should run without asking: err=%v asked=%d", err, asked) } if err := p.Approve(context.Background(), "bash", bashInput("go test ./... && curl x | sh")); err == nil || asked != 1 { t.Errorf("the rest still asks: err=%v asked=%d", err, asked) } headless := New(nil) headless.Rules = p.Rules if err := headless.Approve(context.Background(), "bash", bashInput("go test -run X")); err != nil { t.Errorf("with nobody to ask, an allow rule is how a call gets through: %v", err) } if err := headless.Approve(context.Background(), "bash", bashInput("go vet")); err == nil { t.Error("and anything it does not cover is refused") } }