package main import ( "fmt" "os" "path/filepath" "strings" "testing" "github.com/johannSo/codey/internal/approval" "github.com/johannSo/codey/internal/ccsettings" "github.com/johannSo/codey/internal/config" ) // ccFixture is a home and a project under a temp directory — never the real // ~/.claude* — with a project .mcp.json, a shared allow rule, and a server of // the user's own. 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, Own: map[string]config.MCPServer{"codey-own": {Command: "own"}}, Store: f.store, Lookup: func(string) (string, bool) { return "", false }, 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 user's // own servers still start, 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 _, ok := got.MCP["repo-tool"]; ok { t.Error("an untrusted project server would start") } for _, name := range []string{"mine", "codey-own"} { if _, ok := got.MCP[name]; !ok { t.Errorf("%s should start: it is the user's own", name) } } 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("repo-tool") { t.Errorf("notices %q should name the server and %s", f.notices, trustFlag) } } func TestTrustFlag(t *testing.T) { f := newCCFixture(t) o := f.opts(no()) o.Trust = true got := loadClaudeCode(o) if _, ok := got.MCP["repo-tool"]; !ok || f.asked != 0 { t.Errorf("-trust-project should start the project's servers without asking (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(mustMCP(t, f), 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 _, ok := got.MCP["repo-tool"]; !ok { t.Error("a trusted project server should start") } 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 _, ok := got.MCP["repo-tool"]; !ok { t.Error("a recorded trust should hold for a headless run") } // A changed .mcp.json is a new question. f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"curl","args":["evil.example"]}}}`) got = loadClaudeCode(f.opts(nil)) if _, ok := got.MCP["repo-tool"]; ok { t.Error("a changed server started on the old trust") } loadClaudeCode(f.opts(yes())) if f.asked != 2 { t.Errorf("a changed .mcp.json should ask again (asked %d)", f.asked) } // So is a changed allow list. 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 _, ok := got.MCP["repo-tool"]; ok { t.Error("a declined project server would start") } 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. func TestNoProjectFilesNoQuestion(t *testing.T) { f := newCCFixture(t) os.Remove(filepath.Join(f.root, ".mcp.json")) 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) {}, Lookup: func(string) (string, bool) { return "", false }} 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(nil, []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{"start these MCP servers, from .mcp.json:", " db: db-mcp"}) 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(), "db: db-mcp") || !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 mustMCP(t *testing.T, f *ccFixture) []ccsettings.MCPFile { files, _ := ccsettings.LoadMCP(f.home, f.root) return files } func mustPerms(t *testing.T, f *ccFixture) []ccsettings.Permissions { p, _ := ccsettings.LoadPermissions(f.home, f.root) return p }