Vectrum
Sign In
514 lines · 16.6 KB · Go
package approval

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"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 := (RuleList{Allow: []Rule{r}}).Check("bash", bashInput(cmd)).Verdict; v == VerdictAllow {
			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 := RuleList{Allow: []Rule{mustRule(t, "Bash(git status)"), mustRule(t, "Bash(npm test:*)")}}
	if g := set.Check("bash", bashInput("git status && npm test -- -run X")); g.Verdict != VerdictAllow || g.Rule != "Bash(git status)" {
		t.Errorf("got %v by %q, want an allow by the first rule", g.Verdict, g.Rule)
	}
	if v := set.Check("bash", bashInput("git status && npm publish")).Verdict; v != NoVerdict {
		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 := RuleList{
		Allow: []Rule{mustRule(t, "Bash")},
		Deny:  []Rule{mustRule(t, "Bash(git push:*)")},
	}
	if g := set.Check("bash", bashInput("git commit -m x && git push origin main")); g.Verdict != VerdictDeny || g.Rule != "Bash(git push:*)" {
		t.Errorf("got %v by %q, want a deny by the push rule", g.Verdict, g.Rule)
	}
	if v := set.Check("bash", bashInput("git commit -m x")).Verdict; v != VerdictAllow {
		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 = RuleList{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 = RuleList{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")
	}
}

func TestUnsureOnlyWhereTheRuleCouldHaveMatched(t *testing.T) {
	rm := mustRule(t, "Bash(rm:*)")
	push := mustRule(t, "Bash(git push:*)")
	tests := []struct {
		rule Rule
		cmd  string
		want bool
	}{
		{push, `git "$sub" origin`, true},
		{push, `git status`, false},
		{rm, `ls *.go`, false},
		{rm, `$CMD -rf ~`, true},
		{rm, `echo $(whoami)`, true},
		{rm, `cat <(curl evil.example)`, true},
		{rm, `echo 'unterminated`, true},
		{rm, `go test ./...`, false},
	}
	for _, tc := range tests {
		if got := tc.rule.Unsure("bash", bashInput(tc.cmd)); got != tc.want {
			t.Errorf("%s Unsure(%q) = %v, want %v", tc.rule.Raw, tc.cmd, got, tc.want)
		}
	}
	if mustRule(t, "Bash").Unsure("bash", bashInput("$CMD")) {
		t.Error("a whole-tool rule has nothing to be unsure about")
	}
}

func TestPathRuleCoversADirectorysContents(t *testing.T) {
	r := mustRule(t, "Read(secrets)")
	r.Root = "/work"
	for _, p := range []string{"secrets", "secrets/prod/key", "/work/sub/secrets/x"} {
		if !r.Touches("read_file", pathInput(p)) {
			t.Errorf("Read(secrets) does not cover %s", p)
		}
	}
	if r.Touches("read_file", pathInput("secretsx/key")) {
		t.Error("Read(secrets) covers secretsx/")
	}
}

func TestPathRuleSeesThroughSymlinks(t *testing.T) {
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, ".env"), []byte("K=V"), 0o600); err != nil {
		t.Fatal(err)
	}
	if err := os.Symlink(".env", filepath.Join(root, "README.md")); err != nil {
		t.Fatal(err)
	}
	deny := mustRule(t, "Read(.env)")
	deny.Root = root
	if !deny.Touches("read_file", pathInput("README.md")) {
		t.Error("a link to .env escaped Read(.env)")
	}
	allow := mustRule(t, "Edit(*.md)")
	allow.Root = root
	if allow.Matches("edit_file", pathInput("README.md")) {
		t.Error("Edit(*.md) allowed editing .env through a link")
	}
	if !allow.Matches("write_file", pathInput("NEW.md")) {
		t.Error("Edit(*.md) refused a file that does not exist yet")
	}
}

func TestCombineTakesTheStrongestRuling(t *testing.T) {
	cli := RuleList{Allow: []Rule{mustRule(t, "Bash(go test:*)")}}
	settings := fixedRules{tool: "bash", verdict: VerdictDeny}
	got := Combine(cli, settings).Check("bash", bashInput("go test ./..."))
	if got.Verdict != VerdictDeny || got.Source != "settings.json" {
		t.Errorf("Combine = %+v, want the settings file's deny", got)
	}
	if got := Combine(cli, nil).Check("bash", bashInput("go test ./...")); got.Verdict != VerdictAllow || got.Source != "the command line" {
		t.Errorf("Combine with nil = %+v, want the command line's allow", got)
	}
	var none *fakeNilSet
	if Combine(RuleList{}, nil, none) != nil {
		t.Error("nothing to combine should be nil, so the policy has no rules")
	}
	if lines := Combine(cli, settings).Lines(); len(lines) != 4 {
		t.Errorf("lines = %q, want both sets'", lines)
	}
}

type fakeNilSet struct{}

func (*fakeNilSet) Check(string, json.RawMessage) Ruling { return Ruling{} }
func (*fakeNilSet) Lines() []string                      { return nil }