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
}