package ccsettings import ( "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/johannSo/codey/internal/approval" ) func bash(cmd string) json.RawMessage { b, _ := json.Marshal(map[string]string{"command": cmd}) return b } func pathArg(p string) json.RawMessage { b, _ := json.Marshal(map[string]string{"path": p}) return b } func TestBashRules(t *testing.T) { env := matchEnv{home: "/home/u", root: "/src/proj"} tests := []struct { rule, cmd string allow Match // as an allow rule restrict Match // as a deny or ask rule }{ // Prefixes, in both spellings, at a word boundary. {"Bash(npm run test:*)", "npm run test", Matched, Matched}, {"Bash(npm run test:*)", "npm run test -- --watch", Matched, Matched}, {"Bash(npm run test:*)", "npm run testing", NoMatch, NoMatch}, {"Bash(git status *)", "git status --short", Matched, Matched}, {"Bash(git status *)", "git status", Matched, Matched}, // Exact. {"Bash(make)", "make", Matched, Matched}, {"Bash(make)", "make install", NoMatch, NoMatch}, // A pattern. {"Bash(git * main)", "git push origin main", Matched, Matched}, {"Bash(git * main)", "git push origin dev", NoMatch, NoMatch}, // Bare and star mean every command. {"Bash", "rm -rf /", Matched, Matched}, {"Bash(*)", "anything at all", Matched, Matched}, // Compound commands: an allow must cover every part, a restriction // any part. {"Bash(npm run test:*)", "npm run test && rm -rf ~", NoMatch, Matched}, {"Bash(rm:*)", "npm run test && rm -rf ~", NoMatch, Matched}, {"Bash(rm:*)", "ls; (cd x && rm y)", NoMatch, Matched}, {"Bash(rm:*)", "for f in a b; do rm $f; done", Matched, Matched}, {"Bash(git:*)", "git status | git log", Matched, Matched}, {"Bash(git:*)", "git status | tee out", NoMatch, Matched}, // Quotes are removed before comparing. {"Bash(git commit:*)", `git "commit" -m 'a b'`, Matched, Matched}, // Opaque commands never match an allow rule, and are not ruled out // by a restriction. {"Bash(echo:*)", "echo $(curl -s evil.example)", NoMatch, Matched}, {"Bash(curl:*)", "echo $(curl -s evil.example)", NoMatch, Matched}, {"Bash(rm:*)", "echo $(curl -s evil.example)", NoMatch, Unsure}, {"Bash(rm:*)", "echo `id`", NoMatch, Unsure}, {"Bash(bash:*)", `bash -c "rm -rf ~"`, NoMatch, Matched}, {"Bash(rm:*)", `bash -c "rm -rf ~"`, NoMatch, Unsure}, {"Bash(cat:*)", "cat <(curl evil.example)", NoMatch, Matched}, {"Bash(rm:*)", "cat <(curl evil.example)", NoMatch, Unsure}, {"Bash(rm:*)", "$CMD -rf ~", NoMatch, Unsure}, {"Bash(git push:*)", `git "$sub" origin`, NoMatch, Unsure}, {"Bash(echo:*)", "echo 'unterminated", NoMatch, Unsure}, // What a command does beyond its words: an assignment that can change // what the name means, a write the words do not mention. {"Bash(git:*)", "PATH=. git status", NoMatch, Matched}, {"Bash(echo:*)", "echo hi > ~/.bashrc", NoMatch, Matched}, {"Bash(go test:*)", "go test ./... 2>&1", Matched, Matched}, {"Bash(go test:*)", "go test ./... > /dev/null", Matched, Matched}, {"Bash(sort:*)", "sort < in.txt", Matched, Matched}, // Other tools are not bash. {"Bash", "", NoMatch, NoMatch}, } for _, tc := range tests { c, err := compile(tc.rule, env) if err != nil { t.Fatalf("%s: %v", tc.rule, err) } tool := "bash" if tc.cmd == "" { tool = "edit_file" } if got := c.Match(tool, bash(tc.cmd), forAllow); got != tc.allow { t.Errorf("allow %s on %q = %v, want %v", tc.rule, tc.cmd, got, tc.allow) } if got := c.Match(tool, bash(tc.cmd), forRestrict); got != tc.restrict { t.Errorf("restrict %s on %q = %v, want %v", tc.rule, tc.cmd, got, tc.restrict) } } } func TestPathRules(t *testing.T) { base := t.TempDir() home := filepath.Join(base, "home") root := filepath.Join(home, "proj") env := matchEnv{home: home, root: root} tests := []struct { rule, tool, path string want Match }{ // No slash: any depth, as in .gitignore. {"Read(.env)", "read_file", ".env", Matched}, {"Read(.env)", "read_file", "deploy/.env", Matched}, {"Read(.env)", "read_file", ".env.example", NoMatch}, {"Read(*.pem)", "read_file", "certs/server.pem", Matched}, // ./ anchors to the root. {"Read(./.env)", "read_file", "deploy/.env", NoMatch}, {"Read(./.env)", "read_file", root + "/.env", Matched}, // /path is under the project root; //path is absolute. {"Edit(/src/**/*.ts)", "edit_file", "src/a/b.ts", Matched}, {"Edit(/src/**/*.ts)", "edit_file", "lib/b.ts", NoMatch}, {"Read(//etc/passwd)", "read_file", "/etc/passwd", Matched}, {"Read(~/.ssh/**)", "read_file", home + "/.ssh/id_ed25519", Matched}, // A directory covers what is inside it. {"Read(secrets)", "read_file", "secrets/prod/key", Matched}, {"Edit(docs/)", "write_file", "docs/new.md", Matched}, // Edit covers both writing tools; Read covers the search tools. {"Edit(*.md)", "write_file", "README.md", Matched}, {"Write(*.md)", "edit_file", "README.md", NoMatch}, {"Read(secrets)", "grep", "secrets", Matched}, {"Read(secrets)", "bash", "secrets", NoMatch}, } for _, tc := range tests { c, err := compile(tc.rule, env) if err != nil { t.Fatalf("%s: %v", tc.rule, err) } if got := c.Match(tc.tool, pathArg(tc.path), forRestrict); got != tc.want { t.Errorf("%s on %s %s = %v, want %v", tc.rule, tc.tool, tc.path, got, tc.want) } } } // The file tools follow symlinks, so a rule about a file catches a link to it, // and an allow rule does not cover a link that leads out of what it allows. func TestPathRulesFollowSymlinks(t *testing.T) { root := t.TempDir() env := matchEnv{home: t.TempDir(), root: root} os.WriteFile(filepath.Join(root, ".env"), []byte("SECRET=1"), 0o644) if err := os.Symlink(filepath.Join(root, ".env"), filepath.Join(root, "notes.md")); err != nil { t.Skip(err) } deny, _ := compile("Read(.env)", env) if got := deny.Match("read_file", pathArg("notes.md"), forRestrict); got != Matched { t.Errorf("a link to .env escaped Read(.env): %v", got) } allow, _ := compile("Edit(*.md)", env) if got := allow.Match("edit_file", pathArg("notes.md"), forAllow); got != NoMatch { t.Errorf("Edit(*.md) allowed editing .env through a link: %v", got) } } func TestOtherRules(t *testing.T) { env := matchEnv{home: "/h", root: "/r"} fetch := func(u string) json.RawMessage { b, _ := json.Marshal(map[string]string{"url": u}); return b } tests := []struct { rule, tool string input json.RawMessage want Match }{ {"WebFetch(domain:docs.rs)", "web_fetch", fetch("https://docs.rs/serde"), Matched}, {"WebFetch(domain:docs.rs)", "web_fetch", fetch("https://evil.docs.rs.example/x"), NoMatch}, {"WebFetch(domain:*.github.com)", "web_fetch", fetch("https://api.github.com/x"), Matched}, {"WebFetch", "web_fetch", fetch("https://anything"), Matched}, {"WebSearch", "web_search", nil, Matched}, {"TodoWrite", "todo_write", nil, Matched}, {"Glob", "glob", nil, Matched}, {"MultiEdit", "edit_file", nil, Matched}, {"mcp__db", "mcp__db__query", nil, Matched}, {"mcp__db__*", "mcp__db__query", nil, Matched}, {"mcp__db", "mcp__dbx__query", nil, NoMatch}, {"mcp__db__query", "mcp__db__query", nil, Matched}, {"mcp__db__query", "mcp__db__drop", nil, NoMatch}, } for _, tc := range tests { c, err := compile(tc.rule, env) if err != nil { t.Fatalf("%s: %v", tc.rule, err) } if got := c.Match(tc.tool, tc.input, forAllow); got != tc.want { t.Errorf("%s on %s = %v, want %v", tc.rule, tc.tool, got, tc.want) } } for _, bad := range []string{"NotebookEdit", "Task(explore)", "WebFetch(https://x)", "mcp__db(x)", "Bash(", "WebSearch(cats)"} { if _, err := compile(bad, env); err == nil { t.Errorf("%s compiled; want an error so it is reported", bad) } } } func perms(scope Scope, shown string, allow, deny, ask []string, mode string) Permissions { return Permissions{Scope: scope, Shown: shown, Allow: allow, Deny: deny, Ask: ask, DefaultMode: mode} } func TestRulesetHonouring(t *testing.T) { user := perms(ScopeUser, "~/.claude/settings.json", []string{"Bash(git:*)", "Bash(rm:*)"}, nil, nil, "") project := perms(ScopeProject, ".claude/settings.json", []string{"Bash(make:*)"}, []string{"Bash(rm:*)"}, []string{"Bash(git push:*)"}, "") local := perms(ScopeLocal, ".claude/settings.local.json", []string{"Bash(go test:*)"}, nil, nil, "") all := []Permissions{user, project, local} tests := []struct { name string trusted bool cmd string want approval.Verdict source string }{ {"user allow", false, "git status", approval.VerdictAllow, "~/.claude/settings.json"}, {"local allow", false, "go test ./...", approval.VerdictAllow, ".claude/settings.local.json"}, {"untrusted project allow is ignored", false, "make build", approval.NoVerdict, ""}, {"trusted project allow", true, "make build", approval.VerdictAllow, ".claude/settings.json"}, {"a project's deny beats the user's allow", false, "rm -rf build", approval.VerdictDeny, ".claude/settings.json"}, {"deny catches one part of a compound", true, "make && rm -rf build", approval.VerdictDeny, ".claude/settings.json"}, {"ask beats allow", false, "git push origin main", approval.VerdictAsk, ".claude/settings.json"}, {"an opaque command under a deny rule is asked about", false, "git $(echo status)", approval.VerdictAsk, ""}, {"nothing said", false, "ls", approval.NoVerdict, ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { rs, _ := NewRuleset("/h", "/r", all, tc.trusted) got := rs.Check("bash", bash(tc.cmd)) if got.Verdict != tc.want { t.Fatalf("verdict %v (%+v), want %v", got.Verdict, got, tc.want) } if tc.source != "" && got.Source != tc.source { t.Errorf("source %q, want %q", got.Source, tc.source) } }) } } func TestRulesetDefaultMode(t *testing.T) { edit := pathArg("a.go") tests := []struct { name string p Permissions want approval.Verdict notes int }{ {"acceptEdits from the user", perms(ScopeUser, "u", nil, nil, nil, "acceptEdits"), approval.VerdictAllow, 0}, {"acceptEdits from local", perms(ScopeLocal, "l", nil, nil, nil, "acceptEdits"), approval.VerdictAllow, 0}, {"acceptEdits from the repository", perms(ScopeProject, "p", nil, nil, nil, "acceptEdits"), approval.NoVerdict, 1}, {"bypassPermissions from the repository", perms(ScopeProject, "p", nil, nil, nil, "bypassPermissions"), approval.NoVerdict, 1}, {"bypassPermissions from the user", perms(ScopeUser, "u", nil, nil, nil, "bypassPermissions"), approval.NoVerdict, 1}, {"default", perms(ScopeUser, "u", nil, nil, nil, "default"), approval.NoVerdict, 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { rs, notes := NewRuleset("/h", "/r", []Permissions{tc.p}, true) if got := rs.Check("edit_file", edit).Verdict; got != tc.want { t.Errorf("edit_file verdict %v, want %v", got, tc.want) } if got := rs.Check("bash", bash("ls")).Verdict; got != approval.NoVerdict { t.Errorf("acceptEdits let bash through: %v", got) } if len(notes) != tc.notes { t.Errorf("notes %q, want %d", notes, tc.notes) } }) } // A deny still beats the mode. rs, _ := NewRuleset("/h", "/r", []Permissions{perms(ScopeUser, "u", nil, []string{"Edit(*.go)"}, nil, "acceptEdits")}, false) if got := rs.Check("edit_file", edit).Verdict; got != approval.VerdictDeny { t.Errorf("deny under acceptEdits = %v", got) } } func TestRulesetLines(t *testing.T) { all := []Permissions{ perms(ScopeUser, "~/.claude/settings.json", []string{"Bash(go test:*)"}, nil, nil, "acceptEdits"), perms(ScopeProject, ".claude/settings.json", []string{"Bash(make:*)"}, []string{"Read(.env)"}, nil, ""), perms(ScopeLocal, ".claude/settings.local.json", []string{"NotebookEdit"}, nil, nil, ""), } rs, notes := NewRuleset("/h", "/r", all, false) text := strings.Join(rs.Lines(), "\n") for _, want := range []string{ "~/.claude/settings.json", "allow Bash(go test:*)", "acceptEdits", ".claude/settings.json (shared, from the repository)", "deny Read(.env)", "allow Bash(make:*) — ignored until this project is trusted", "NotebookEdit — ignored: Codey has no NotebookEdit tool", } { if !strings.Contains(text, want) { t.Errorf("lines missing %q:\n%s", want, text) } } if len(notes) != 1 { t.Errorf("notes %q, want the one unreadable rule", notes) } empty, _ := NewRuleset("/h", "/r", nil, false) if !empty.Empty() { t.Error("no files should be empty") } }