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")
}
}