Vectrum
Sign In
301 lines · 12.3 KB · Go
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")
	}
}