package main import ( "fmt" "os" "path/filepath" "strings" "testing" "github.com/johannSo/codey/internal/approval" "github.com/johannSo/codey/internal/ccsettings" ) // ccFixture is a home and a project under a temp directory — never the real // ~/.claude* — with a shared allow rule, and MCP servers in both of Claude // Code's places for them, which nothing here should read. type ccFixture struct { home, root string store ccsettings.TrustStore notices []string asked int } func newCCFixture(t *testing.T) *ccFixture { t.Helper() base := t.TempDir() f := &ccFixture{ home: filepath.Join(base, "home"), root: filepath.Join(base, "home", "proj"), store: ccsettings.TrustStore{Path: filepath.Join(base, "config", "codey", ccsettings.TrustFilename)}, } f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"repo-mcp","args":["--root","."]}}}`) f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)"],"deny":["Bash(rm:*)"]}}`) f.write(t, f.home, ".claude.json", `{"mcpServers":{"mine":{"command":"my-mcp"}}}`) return f } func (f *ccFixture) write(t *testing.T, dir, rel, body string) { t.Helper() path := filepath.Join(dir, rel) os.MkdirAll(filepath.Dir(path), 0o755) if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } // opts builds the options; answer nil means nobody can be asked. func (f *ccFixture) opts(answer *bool) ccOptions { o := ccOptions{ Home: f.home, Root: f.root, Store: f.store, Notice: func(format string, args ...any) { f.notices = append(f.notices, fmt.Sprintf(format, args...)) }, } if answer != nil { o.Ask = func(string, []string) (bool, bool) { f.asked++; return *answer, true } } return o } func (f *ccFixture) said(s string) bool { for _, n := range f.notices { if strings.Contains(n, s) { return true } } return false } func allowed(rs approval.RuleSet, cmd string) approval.Verdict { if rs == nil { return approval.NoVerdict } return rs.Check("bash", []byte(fmt.Sprintf(`{"command":%q}`, cmd))).Verdict } func yes() *bool { v := true; return &v } func no() *bool { v := false; return &v } // Headless — -p, agent stdio — nothing from the repository is used, the // repository's deny rules still hold, and the notice names the flag that says // otherwise. func TestUntrustedHeadless(t *testing.T) { f := newCCFixture(t) got := loadClaudeCode(f.opts(nil)) if v := allowed(got.Rules, "make build"); v != approval.NoVerdict { t.Errorf("an untrusted project allow rule was honoured: %v", v) } if v := allowed(got.Rules, "rm -rf x"); v != approval.VerdictDeny { t.Errorf("the project's deny rule should hold untrusted: %v", v) } if !f.said(trustFlag) || !f.said(".claude/settings.json") { t.Errorf("notices %q should name the file and %s", f.notices, trustFlag) } if f.said("repo-tool") || f.said("mcp") { t.Errorf("notices %q mention Claude Code's MCP servers, which are not read", f.notices) } } func TestTrustFlag(t *testing.T) { f := newCCFixture(t) o := f.opts(no()) o.Trust = true got := loadClaudeCode(o) if f.asked != 0 { t.Errorf("-trust-project should not ask (asked %d)", f.asked) } if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow { t.Errorf("-trust-project should honour the project's allow rules: %v", v) } // The flag is for one run: nothing is recorded. if decided, _, _ := f.store.Lookup(f.root, ccsettings.NewGrant(mustPerms(t, f)).Fingerprint()); decided { t.Error("the flag should not record a decision") } } func TestTrustIsAskedOnceAndRemembered(t *testing.T) { f := newCCFixture(t) got := loadClaudeCode(f.opts(yes())) if f.asked != 1 { t.Fatalf("asked %d times, want 1", f.asked) } if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow { t.Errorf("a trusted project allow rule should be honoured: %v", v) } // The next start, interactive or not, does not ask again. got = loadClaudeCode(f.opts(yes())) if f.asked != 1 { t.Errorf("asked again for the same definitions") } got = loadClaudeCode(f.opts(nil)) if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow { t.Errorf("a recorded trust should hold for a headless run: %v", v) } // A .mcp.json is not part of what is trusted: changing it asks nothing. f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"curl","args":["evil.example"]}}}`) loadClaudeCode(f.opts(yes())) if f.asked != 1 { t.Errorf("a changed .mcp.json asked again (asked %d)", f.asked) } // A changed allow list is a new question. f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)","Bash"]}}`) got = loadClaudeCode(f.opts(nil)) if v := allowed(got.Rules, "make build"); v != approval.NoVerdict { t.Errorf("a changed allow list kept the old trust: %v", v) } } func TestDeclinedTrustIsRemembered(t *testing.T) { f := newCCFixture(t) got := loadClaudeCode(f.opts(no())) if v := allowed(got.Rules, "make build"); v != approval.NoVerdict { t.Errorf("a declined project allow rule was honoured: %v", v) } f.notices = nil loadClaudeCode(f.opts(yes())) if f.asked != 1 { t.Errorf("a refusal should not be asked again (asked %d)", f.asked) } if !f.said("declined") || !f.said(trustFlag) { t.Errorf("notices %q should say it was declined and name %s", f.notices, trustFlag) } } // Nothing to trust is nothing to ask — and a .mcp.json is nothing to trust, // since its servers are never started. func TestNoProjectFilesNoQuestion(t *testing.T) { f := newCCFixture(t) f.write(t, f.root, ".claude/settings.json", `{"permissions":{"deny":["Bash(rm:*)"]}}`) got := loadClaudeCode(f.opts(yes())) if f.asked != 0 { t.Error("asked about a project that grants nothing") } if v := allowed(got.Rules, "rm x"); v != approval.VerdictDeny { t.Errorf("deny = %v", v) } } // A machine without Claude Code leaves the policy exactly as it was. func TestNoClaudeCodeNoRules(t *testing.T) { base := t.TempDir() o := ccOptions{Home: filepath.Join(base, "h"), Root: filepath.Join(base, "r"), Notice: func(string, ...any) {}} got := loadClaudeCode(o) if got.Rules != nil { t.Errorf("rules = %#v, want nil", got.Rules) } } // A root other than the startup one — an ACP thread in a worktree — is never // asked about, and uses only what was already trusted. func TestRulesForAnotherRoot(t *testing.T) { f := newCCFixture(t) start := loadClaudeCode(f.opts(nil)) other := filepath.Join(f.home, "other") f.write(t, other, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)"]}}`) o := f.opts(yes()) lookup := rulesFor(o, f.root, start) if v := allowed(lookup(other), "make"); v != approval.NoVerdict || f.asked != 0 { t.Errorf("verdict %v, asked %d: another root must not be asked about", v, f.asked) } g := ccsettings.NewGrant([]ccsettings.Permissions{{Scope: ccsettings.ScopeProject, Allow: []string{"Bash(make:*)"}}}) f.store.Record(other, g.Fingerprint(), true) lookup = rulesFor(o, f.root, start) if v := allowed(lookup(other), "make"); v != approval.VerdictAllow { t.Errorf("a recorded trust for the other root should count: %v", v) } } func TestAskTrustOnTerminal(t *testing.T) { tests := []struct { in string trusted, answered bool }{ {"y\n", true, true}, {"YES\n", true, true}, {"n\n", false, true}, {"\n", false, true}, {"", false, false}, } for _, tc := range tests { var out strings.Builder ask := askTrustOnTerminal(strings.NewReader(tc.in), &out) trusted, answered := ask("/src/proj", []string{"run these without asking, from .claude/settings.json:", " Bash(make:*)"}) if trusted != tc.trusted || answered != tc.answered { t.Errorf("%q: got %v %v, want %v %v", tc.in, trusted, answered, tc.trusted, tc.answered) } if !strings.Contains(out.String(), "Bash(make:*)") || !strings.Contains(out.String(), "[y/N]") { t.Errorf("prompt did not show the definitions:\n%s", out.String()) } } } // The answer is read without reading past it: whatever is typed next belongs // to the TUI. func TestReadLineStopsAtTheNewline(t *testing.T) { r := strings.NewReader("y\nnext") line, err := readLine(r) if line != "y" || err != nil { t.Fatalf("%q %v", line, err) } rest := make([]byte, 8) n, _ := r.Read(rest) if string(rest[:n]) != "next" { t.Errorf("rest = %q", rest[:n]) } } func mustPerms(t *testing.T, f *ccFixture) []ccsettings.Permissions { p, _ := ccsettings.LoadPermissions(f.home, f.root) return p }