Codey's own servers, then ~/.claude.json's per-project ones, the project's
.mcp.json and ~/.claude.json's global ones, with ${VAR} expanded. A
repository's servers and allow rules are put to a trust prompt on the
terminal before the TUI starts; -p and agent stdio skip them unless the
checkout was trusted before or -trust-project says so. /allowed shows
which rules came from which file, and CLAUDE.md reaches the prompt.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>Showing 5 changed files with 600 additions and 2 deletions.
| @@ -25,6 +25,7 @@ import ( | ||
| 25 | 25 | |
| 26 | 26 | "github.com/johannSo/codey/internal/acp" |
| 27 | 27 | "github.com/johannSo/codey/internal/agent" |
| 28 | + "github.com/johannSo/codey/internal/approval" | |
| 28 | 29 | "github.com/johannSo/codey/internal/llm" |
| 29 | 30 | "github.com/johannSo/codey/internal/metrics" |
| 30 | 31 | "github.com/johannSo/codey/internal/serve" |
| @@ -204,6 +205,12 @@ type acpDeps struct { | ||
| 204 | 205 | Base agent.Options |
| 205 | 206 | // Tools is main's factory: a root in, a tool-set builder out. |
| 206 | 207 | Tools func(root string) func(*agent.State, *metrics.Logger) *tools.Registry |
| 208 | + // Rules and Instructions are Claude Code's permission rules and CLAUDE.md | |
| 209 | + // files for a root. Each session may be rooted somewhere else, so they | |
| 210 | + // are looked up per session rather than taken from startup. Nil for | |
| 211 | + // neither. | |
| 212 | + Rules func(root string) approval.RuleSet | |
| 213 | + Instructions func(root string) string | |
| 207 | 214 | } |
| 208 | 215 | |
| 209 | 216 | // serveACP runs the agent over stdin and stdout until the client hangs up. |
| @@ -240,6 +247,14 @@ func serveACP(ctx context.Context, deps acpDeps) error { | ||
| 240 | 247 | // the parent's limits. |
| 241 | 248 | newTools := deps.Tools(req.Root) |
| 242 | 249 | reg := newTools(req.State, deps.Log) |
| 250 | + if deps.Rules != nil && req.Approve != nil { | |
| 251 | + req.Approve.Rules = deps.Rules(req.Root) | |
| 252 | + } | |
| 253 | + base, subBase := base, subBase | |
| 254 | + if deps.Instructions != nil { | |
| 255 | + base.Instructions = deps.Instructions(req.Root) | |
| 256 | + subBase.Instructions = base.Instructions | |
| 257 | + } | |
| 243 | 258 | |
| 244 | 259 | set := serve.AgentSet{} |
| 245 | 260 | if !deps.NoAgents { |
| @@ -0,0 +1,265 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + // What a Claude Code user's existing configuration contributes to a run: the | |
| 4 | + // MCP servers it knows about, the permission rules in its settings files, and | |
| 5 | + // its CLAUDE.md files. The reading and the rules about what is honoured live in | |
| 6 | + // internal/ccsettings; this file is the wiring — when to ask about trust, how, | |
| 7 | + // and what to say when the answer is no. | |
| 8 | + | |
| 9 | + import ( | |
| 10 | + "fmt" | |
| 11 | + "io" | |
| 12 | + "os" | |
| 13 | + "sort" | |
| 14 | + "strings" | |
| 15 | + "sync" | |
| 16 | + | |
| 17 | + "github.com/johannSo/codey/internal/approval" | |
| 18 | + "github.com/johannSo/codey/internal/ccsettings" | |
| 19 | + "github.com/johannSo/codey/internal/config" | |
| 20 | + ) | |
| 21 | + | |
| 22 | + // trustFlag is spelled once, because three messages name it. | |
| 23 | + const trustFlag = "-trust-project" | |
| 24 | + | |
| 25 | + // ccOptions is what loading Claude Code's configuration needs from main. | |
| 26 | + type ccOptions struct { | |
| 27 | + Home, Root string | |
| 28 | + // Own is Codey's own mcp_servers, which win any clash. | |
| 29 | + Own map[string]config.MCPServer | |
| 30 | + // Trust is -trust-project: use the project's servers and allow rules this | |
| 31 | + // run, whatever was decided before, without asking. | |
| 32 | + Trust bool | |
| 33 | + // NoMCP is -no-mcp. The servers are still part of the trust question — | |
| 34 | + // the question is about the repository, not about this run — but the | |
| 35 | + // prompt says they will not start. | |
| 36 | + NoMCP bool | |
| 37 | + // Ask puts the trust question to a person and reports the answer, and | |
| 38 | + // whether there was one. Nil where nobody can answer: -p, agent stdio, | |
| 39 | + // a piped stdin. | |
| 40 | + Ask func(root string, lines []string) (trusted, answered bool) | |
| 41 | + Store ccsettings.TrustStore | |
| 42 | + // Notice reports something to the user, wherever this run's notices go. | |
| 43 | + Notice func(format string, args ...any) | |
| 44 | + // Lookup is the environment ${VAR} expands from. | |
| 45 | + Lookup func(string) (string, bool) | |
| 46 | + } | |
| 47 | + | |
| 48 | + // ccSetup is what came of it. | |
| 49 | + type ccSetup struct { | |
| 50 | + // MCP is every server to start, Codey's own included. | |
| 51 | + MCP map[string]config.MCPServer | |
| 52 | + // Rules is nil when no settings file had any rules, so the policy is left | |
| 53 | + // exactly as it would be on a machine without Claude Code. | |
| 54 | + Rules approval.RuleSet | |
| 55 | + // Trusted is the trust decision for the root, for reuse by rulesFor. | |
| 56 | + Trusted bool | |
| 57 | + } | |
| 58 | + | |
| 59 | + // loadClaudeCode reads Claude Code's files for the startup root, settles | |
| 60 | + // whether the project's own files are trusted — asking, if there is someone | |
| 61 | + // to ask and nothing has been decided about these exact definitions — and | |
| 62 | + // returns what this run should use. | |
| 63 | + func loadClaudeCode(o ccOptions) ccSetup { | |
| 64 | + if o.Lookup == nil { | |
| 65 | + o.Lookup = os.LookupEnv | |
| 66 | + } | |
| 67 | + mcpFiles, errs := ccsettings.LoadMCP(o.Home, o.Root) | |
| 68 | + for _, err := range errs { | |
| 69 | + o.Notice("%v", err) | |
| 70 | + } | |
| 71 | + perms, errs := ccsettings.LoadPermissions(o.Home, o.Root) | |
| 72 | + for _, err := range errs { | |
| 73 | + o.Notice("%v — its deny rules are not in force", err) | |
| 74 | + } | |
| 75 | + | |
| 76 | + grant := ccsettings.NewGrant(mcpFiles, perms) | |
| 77 | + trusted := settleTrust(o, grant) | |
| 78 | + | |
| 79 | + out := ccSetup{Trusted: trusted} | |
| 80 | + merged, notes := ccsettings.MergeMCP(o.Own, mcpFiles, trusted, o.Lookup) | |
| 81 | + out.MCP = merged | |
| 82 | + if !o.NoMCP { | |
| 83 | + // A clash or a missing variable is only news if the servers were | |
| 84 | + // going to start. | |
| 85 | + for _, n := range notes { | |
| 86 | + o.Notice("%s", n) | |
| 87 | + } | |
| 88 | + } | |
| 89 | + rules, notes := ccsettings.NewRuleset(o.Home, o.Root, perms, trusted) | |
| 90 | + for _, n := range notes { | |
| 91 | + o.Notice("%s", n) | |
| 92 | + } | |
| 93 | + if !rules.Empty() { | |
| 94 | + out.Rules = rules | |
| 95 | + } | |
| 96 | + return out | |
| 97 | + } | |
| 98 | + | |
| 99 | + // settleTrust decides whether what the project's committed files grant is | |
| 100 | + // used this run. The flag wins; then a decision already recorded for these | |
| 101 | + // exact definitions; then the person at the terminal, if there is one. With | |
| 102 | + // nobody to ask, the answer is no, and the notice says how to say yes. | |
| 103 | + func settleTrust(o ccOptions, grant ccsettings.Grant) bool { | |
| 104 | + if grant.Empty() { | |
| 105 | + return false | |
| 106 | + } | |
| 107 | + if o.Trust { | |
| 108 | + return true | |
| 109 | + } | |
| 110 | + fp := grant.Fingerprint() | |
| 111 | + decided, trusted, err := o.Store.Lookup(o.Root, fp) | |
| 112 | + if err != nil { | |
| 113 | + o.Notice("%v — treating this project as not trusted", err) | |
| 114 | + return false | |
| 115 | + } | |
| 116 | + if decided { | |
| 117 | + if !trusted { | |
| 118 | + o.Notice("%s — you declined to trust them for this checkout. %s uses them for one run; a change to them asks again", untrustedWhat(grant), trustFlag) | |
| 119 | + } | |
| 120 | + return trusted | |
| 121 | + } | |
| 122 | + if o.Ask == nil { | |
| 123 | + o.Notice("%s — they come from the repository, and nobody has said this checkout is trusted. Run once interactively to be asked, or pass %s", untrustedWhat(grant), trustFlag) | |
| 124 | + return false | |
| 125 | + } | |
| 126 | + lines := grant.Describe() | |
| 127 | + if o.NoMCP && len(grant.Servers) > 0 { | |
| 128 | + lines = append(lines, "(the servers will not start this run: -no-mcp)") | |
| 129 | + } | |
| 130 | + trusted, answered := o.Ask(o.Root, lines) | |
| 131 | + if !answered { | |
| 132 | + // Interrupted, or stdin closed: no for now, and the question stays | |
| 133 | + // open for the next start. | |
| 134 | + return false | |
| 135 | + } | |
| 136 | + if err := o.Store.Record(o.Root, fp, trusted); err != nil { | |
| 137 | + o.Notice("%v — the answer holds for this run only", err) | |
| 138 | + } | |
| 139 | + if !trusted { | |
| 140 | + o.Notice("%s — you can change your mind with %s", untrustedWhat(grant), trustFlag) | |
| 141 | + } | |
| 142 | + return trusted | |
| 143 | + } | |
| 144 | + | |
| 145 | + // untrustedWhat names what is being left out, for a notice. | |
| 146 | + func untrustedWhat(g ccsettings.Grant) string { | |
| 147 | + var parts []string | |
| 148 | + if len(g.Servers) > 0 { | |
| 149 | + names := make([]string, 0, len(g.Servers)) | |
| 150 | + for n := range g.Servers { | |
| 151 | + names = append(names, n) | |
| 152 | + } | |
| 153 | + sort.Strings(names) | |
| 154 | + parts = append(parts, fmt.Sprintf("the MCP servers in %s (%s) were not started", ccsettings.ProjectMCP, strings.Join(names, ", "))) | |
| 155 | + } | |
| 156 | + if len(g.Allow) > 0 { | |
| 157 | + parts = append(parts, fmt.Sprintf("the allow rules in %s are ignored", strings.Join(g.AllowFrom, " and "))) | |
| 158 | + } | |
| 159 | + return strings.Join(parts, ", and ") | |
| 160 | + } | |
| 161 | + | |
| 162 | + // askTrustOnTerminal is the trust question, asked on the terminal before the | |
| 163 | + // TUI takes it over — the same moment Claude Code asks its own. It goes to | |
| 164 | + // stderr and reads stdin a byte at a time, so nothing typed after the answer | |
| 165 | + // is swallowed into a buffer the TUI never sees. | |
| 166 | + func askTrustOnTerminal(in io.Reader, out io.Writer) func(string, []string) (bool, bool) { | |
| 167 | + return func(root string, lines []string) (bool, bool) { | |
| 168 | + fmt.Fprintf(out, "\nClaude Code's configuration for %s would:\n\n", root) | |
| 169 | + for _, l := range lines { | |
| 170 | + fmt.Fprintln(out, " "+l) | |
| 171 | + } | |
| 172 | + fmt.Fprint(out, "\nThese files come from the repository, not from you: trusting them lets it\n"+ | |
| 173 | + "choose commands this machine runs. Your answer is remembered until they change.\n"+ | |
| 174 | + "Trust them for this checkout? [y/N] ") | |
| 175 | + line, err := readLine(in) | |
| 176 | + if err != nil { | |
| 177 | + fmt.Fprintln(out) | |
| 178 | + return false, false | |
| 179 | + } | |
| 180 | + switch strings.ToLower(strings.TrimSpace(line)) { | |
| 181 | + case "y", "yes": | |
| 182 | + return true, true | |
| 183 | + default: | |
| 184 | + return false, true | |
| 185 | + } | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + // readLine reads up to a newline without reading past it. | |
| 190 | + func readLine(r io.Reader) (string, error) { | |
| 191 | + var b strings.Builder | |
| 192 | + buf := make([]byte, 1) | |
| 193 | + for { | |
| 194 | + n, err := r.Read(buf) | |
| 195 | + if n == 1 { | |
| 196 | + if buf[0] == '\n' { | |
| 197 | + return b.String(), nil | |
| 198 | + } | |
| 199 | + b.WriteByte(buf[0]) | |
| 200 | + } | |
| 201 | + if err != nil { | |
| 202 | + if err == io.EOF && b.Len() > 0 { | |
| 203 | + return b.String(), nil | |
| 204 | + } | |
| 205 | + return "", err | |
| 206 | + } | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + // rulesFor builds the rule set for a root other than the one Codey started | |
| 211 | + // in — an ACP client hands each thread a directory of its own. Nobody can be | |
| 212 | + // asked from there, so the project's allow rules count only if trust was | |
| 213 | + // already recorded for them, or -trust-project was given. Each root is read | |
| 214 | + // once; a model switch rebuilds the agent, and the same notices twice would | |
| 215 | + // be noise. | |
| 216 | + func rulesFor(o ccOptions, startRoot string, start ccSetup) func(root string) approval.RuleSet { | |
| 217 | + var mu sync.Mutex | |
| 218 | + cache := map[string]approval.RuleSet{startRoot: start.Rules} | |
| 219 | + return func(root string) approval.RuleSet { | |
| 220 | + mu.Lock() | |
| 221 | + defer mu.Unlock() | |
| 222 | + if rs, ok := cache[root]; ok { | |
| 223 | + return rs | |
| 224 | + } | |
| 225 | + ro := o | |
| 226 | + ro.Root, ro.Ask = root, nil | |
| 227 | + mcpFiles, _ := ccsettings.LoadMCP(o.Home, root) | |
| 228 | + perms, errs := ccsettings.LoadPermissions(o.Home, root) | |
| 229 | + for _, err := range errs { | |
| 230 | + o.Notice("%v — its deny rules are not in force", err) | |
| 231 | + } | |
| 232 | + trusted := settleTrust(ro, ccsettings.NewGrant(mcpFiles, perms)) | |
| 233 | + rules, notes := ccsettings.NewRuleset(o.Home, root, perms, trusted) | |
| 234 | + for _, n := range notes { | |
| 235 | + o.Notice("%s", n) | |
| 236 | + } | |
| 237 | + var rs approval.RuleSet | |
| 238 | + if !rules.Empty() { | |
| 239 | + rs = rules | |
| 240 | + } | |
| 241 | + cache[root] = rs | |
| 242 | + return rs | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + // instructionsFor reads the CLAUDE.md files for a root, formatted for the | |
| 247 | + // system prompt, reporting what could not be read. | |
| 248 | + func instructionsFor(home string, notice func(string, ...any)) func(root string) string { | |
| 249 | + var mu sync.Mutex | |
| 250 | + cache := map[string]string{} | |
| 251 | + return func(root string) string { | |
| 252 | + mu.Lock() | |
| 253 | + defer mu.Unlock() | |
| 254 | + if s, ok := cache[root]; ok { | |
| 255 | + return s | |
| 256 | + } | |
| 257 | + files, errs := ccsettings.LoadInstructions(home, root) | |
| 258 | + for _, err := range errs { | |
| 259 | + notice("%v", err) | |
| 260 | + } | |
| 261 | + s := ccsettings.FormatInstructions(files) | |
| 262 | + cache[root] = s | |
| 263 | + return s | |
| 264 | + } | |
| 265 | + } | |
| @@ -0,0 +1,274 @@ | ||
| 1 | + package main | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "fmt" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + | |
| 10 | + "github.com/johannSo/codey/internal/approval" | |
| 11 | + "github.com/johannSo/codey/internal/ccsettings" | |
| 12 | + "github.com/johannSo/codey/internal/config" | |
| 13 | + ) | |
| 14 | + | |
| 15 | + // ccFixture is a home and a project under a temp directory — never the real | |
| 16 | + // ~/.claude* — with a project .mcp.json, a shared allow rule, and a server of | |
| 17 | + // the user's own. | |
| 18 | + type ccFixture struct { | |
| 19 | + home, root string | |
| 20 | + store ccsettings.TrustStore | |
| 21 | + notices []string | |
| 22 | + asked int | |
| 23 | + } | |
| 24 | + | |
| 25 | + func newCCFixture(t *testing.T) *ccFixture { | |
| 26 | + t.Helper() | |
| 27 | + base := t.TempDir() | |
| 28 | + f := &ccFixture{ | |
| 29 | + home: filepath.Join(base, "home"), | |
| 30 | + root: filepath.Join(base, "home", "proj"), | |
| 31 | + store: ccsettings.TrustStore{Path: filepath.Join(base, "config", "codey", ccsettings.TrustFilename)}, | |
| 32 | + } | |
| 33 | + f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"repo-mcp","args":["--root","."]}}}`) | |
| 34 | + f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)"],"deny":["Bash(rm:*)"]}}`) | |
| 35 | + f.write(t, f.home, ".claude.json", `{"mcpServers":{"mine":{"command":"my-mcp"}}}`) | |
| 36 | + return f | |
| 37 | + } | |
| 38 | + | |
| 39 | + func (f *ccFixture) write(t *testing.T, dir, rel, body string) { | |
| 40 | + t.Helper() | |
| 41 | + path := filepath.Join(dir, rel) | |
| 42 | + os.MkdirAll(filepath.Dir(path), 0o755) | |
| 43 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 44 | + t.Fatal(err) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + // opts builds the options; answer nil means nobody can be asked. | |
| 49 | + func (f *ccFixture) opts(answer *bool) ccOptions { | |
| 50 | + o := ccOptions{ | |
| 51 | + Home: f.home, | |
| 52 | + Root: f.root, | |
| 53 | + Own: map[string]config.MCPServer{"codey-own": {Command: "own"}}, | |
| 54 | + Store: f.store, | |
| 55 | + Lookup: func(string) (string, bool) { return "", false }, | |
| 56 | + Notice: func(format string, args ...any) { f.notices = append(f.notices, fmt.Sprintf(format, args...)) }, | |
| 57 | + } | |
| 58 | + if answer != nil { | |
| 59 | + o.Ask = func(string, []string) (bool, bool) { f.asked++; return *answer, true } | |
| 60 | + } | |
| 61 | + return o | |
| 62 | + } | |
| 63 | + | |
| 64 | + func (f *ccFixture) said(s string) bool { | |
| 65 | + for _, n := range f.notices { | |
| 66 | + if strings.Contains(n, s) { | |
| 67 | + return true | |
| 68 | + } | |
| 69 | + } | |
| 70 | + return false | |
| 71 | + } | |
| 72 | + | |
| 73 | + func allowed(rs approval.RuleSet, cmd string) approval.Verdict { | |
| 74 | + if rs == nil { | |
| 75 | + return approval.NoVerdict | |
| 76 | + } | |
| 77 | + return rs.Check("bash", []byte(fmt.Sprintf(`{"command":%q}`, cmd))).Verdict | |
| 78 | + } | |
| 79 | + | |
| 80 | + func yes() *bool { v := true; return &v } | |
| 81 | + func no() *bool { v := false; return &v } | |
| 82 | + | |
| 83 | + // Headless — -p, agent stdio — nothing from the repository is used, the user's | |
| 84 | + // own servers still start, the repository's deny rules still hold, and the | |
| 85 | + // notice names the flag that says otherwise. | |
| 86 | + func TestUntrustedHeadless(t *testing.T) { | |
| 87 | + f := newCCFixture(t) | |
| 88 | + got := loadClaudeCode(f.opts(nil)) | |
| 89 | + if _, ok := got.MCP["repo-tool"]; ok { | |
| 90 | + t.Error("an untrusted project server would start") | |
| 91 | + } | |
| 92 | + for _, name := range []string{"mine", "codey-own"} { | |
| 93 | + if _, ok := got.MCP[name]; !ok { | |
| 94 | + t.Errorf("%s should start: it is the user's own", name) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + if v := allowed(got.Rules, "make build"); v != approval.NoVerdict { | |
| 98 | + t.Errorf("an untrusted project allow rule was honoured: %v", v) | |
| 99 | + } | |
| 100 | + if v := allowed(got.Rules, "rm -rf x"); v != approval.VerdictDeny { | |
| 101 | + t.Errorf("the project's deny rule should hold untrusted: %v", v) | |
| 102 | + } | |
| 103 | + if !f.said(trustFlag) || !f.said("repo-tool") { | |
| 104 | + t.Errorf("notices %q should name the server and %s", f.notices, trustFlag) | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + func TestTrustFlag(t *testing.T) { | |
| 109 | + f := newCCFixture(t) | |
| 110 | + o := f.opts(no()) | |
| 111 | + o.Trust = true | |
| 112 | + got := loadClaudeCode(o) | |
| 113 | + if _, ok := got.MCP["repo-tool"]; !ok || f.asked != 0 { | |
| 114 | + t.Errorf("-trust-project should start the project's servers without asking (asked %d)", f.asked) | |
| 115 | + } | |
| 116 | + if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow { | |
| 117 | + t.Errorf("-trust-project should honour the project's allow rules: %v", v) | |
| 118 | + } | |
| 119 | + // The flag is for one run: nothing is recorded. | |
| 120 | + if decided, _, _ := f.store.Lookup(f.root, ccsettings.NewGrant(mustMCP(t, f), mustPerms(t, f)).Fingerprint()); decided { | |
| 121 | + t.Error("the flag should not record a decision") | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + func TestTrustIsAskedOnceAndRemembered(t *testing.T) { | |
| 126 | + f := newCCFixture(t) | |
| 127 | + got := loadClaudeCode(f.opts(yes())) | |
| 128 | + if f.asked != 1 { | |
| 129 | + t.Fatalf("asked %d times, want 1", f.asked) | |
| 130 | + } | |
| 131 | + if _, ok := got.MCP["repo-tool"]; !ok { | |
| 132 | + t.Error("a trusted project server should start") | |
| 133 | + } | |
| 134 | + if v := allowed(got.Rules, "make build"); v != approval.VerdictAllow { | |
| 135 | + t.Errorf("a trusted project allow rule should be honoured: %v", v) | |
| 136 | + } | |
| 137 | + | |
| 138 | + // The next start, interactive or not, does not ask again. | |
| 139 | + got = loadClaudeCode(f.opts(yes())) | |
| 140 | + if f.asked != 1 { | |
| 141 | + t.Errorf("asked again for the same definitions") | |
| 142 | + } | |
| 143 | + got = loadClaudeCode(f.opts(nil)) | |
| 144 | + if _, ok := got.MCP["repo-tool"]; !ok { | |
| 145 | + t.Error("a recorded trust should hold for a headless run") | |
| 146 | + } | |
| 147 | + | |
| 148 | + // A changed .mcp.json is a new question. | |
| 149 | + f.write(t, f.root, ".mcp.json", `{"mcpServers":{"repo-tool":{"command":"curl","args":["evil.example"]}}}`) | |
| 150 | + got = loadClaudeCode(f.opts(nil)) | |
| 151 | + if _, ok := got.MCP["repo-tool"]; ok { | |
| 152 | + t.Error("a changed server started on the old trust") | |
| 153 | + } | |
| 154 | + loadClaudeCode(f.opts(yes())) | |
| 155 | + if f.asked != 2 { | |
| 156 | + t.Errorf("a changed .mcp.json should ask again (asked %d)", f.asked) | |
| 157 | + } | |
| 158 | + | |
| 159 | + // So is a changed allow list. | |
| 160 | + f.write(t, f.root, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)","Bash"]}}`) | |
| 161 | + got = loadClaudeCode(f.opts(nil)) | |
| 162 | + if v := allowed(got.Rules, "make build"); v != approval.NoVerdict { | |
| 163 | + t.Errorf("a changed allow list kept the old trust: %v", v) | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + func TestDeclinedTrustIsRemembered(t *testing.T) { | |
| 168 | + f := newCCFixture(t) | |
| 169 | + got := loadClaudeCode(f.opts(no())) | |
| 170 | + if _, ok := got.MCP["repo-tool"]; ok { | |
| 171 | + t.Error("a declined project server would start") | |
| 172 | + } | |
| 173 | + f.notices = nil | |
| 174 | + loadClaudeCode(f.opts(yes())) | |
| 175 | + if f.asked != 1 { | |
| 176 | + t.Errorf("a refusal should not be asked again (asked %d)", f.asked) | |
| 177 | + } | |
| 178 | + if !f.said("declined") || !f.said(trustFlag) { | |
| 179 | + t.Errorf("notices %q should say it was declined and name %s", f.notices, trustFlag) | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + // Nothing to trust is nothing to ask. | |
| 184 | + func TestNoProjectFilesNoQuestion(t *testing.T) { | |
| 185 | + f := newCCFixture(t) | |
| 186 | + os.Remove(filepath.Join(f.root, ".mcp.json")) | |
| 187 | + f.write(t, f.root, ".claude/settings.json", `{"permissions":{"deny":["Bash(rm:*)"]}}`) | |
| 188 | + got := loadClaudeCode(f.opts(yes())) | |
| 189 | + if f.asked != 0 { | |
| 190 | + t.Error("asked about a project that grants nothing") | |
| 191 | + } | |
| 192 | + if v := allowed(got.Rules, "rm x"); v != approval.VerdictDeny { | |
| 193 | + t.Errorf("deny = %v", v) | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 197 | + // A machine without Claude Code leaves the policy exactly as it was. | |
| 198 | + func TestNoClaudeCodeNoRules(t *testing.T) { | |
| 199 | + base := t.TempDir() | |
| 200 | + o := ccOptions{Home: filepath.Join(base, "h"), Root: filepath.Join(base, "r"), Notice: func(string, ...any) {}, Lookup: func(string) (string, bool) { return "", false }} | |
| 201 | + got := loadClaudeCode(o) | |
| 202 | + if got.Rules != nil { | |
| 203 | + t.Errorf("rules = %#v, want nil", got.Rules) | |
| 204 | + } | |
| 205 | + } | |
| 206 | + | |
| 207 | + // A root other than the startup one — an ACP thread in a worktree — is never | |
| 208 | + // asked about, and uses only what was already trusted. | |
| 209 | + func TestRulesForAnotherRoot(t *testing.T) { | |
| 210 | + f := newCCFixture(t) | |
| 211 | + start := loadClaudeCode(f.opts(nil)) | |
| 212 | + other := filepath.Join(f.home, "other") | |
| 213 | + f.write(t, other, ".claude/settings.json", `{"permissions":{"allow":["Bash(make:*)"]}}`) | |
| 214 | + o := f.opts(yes()) | |
| 215 | + lookup := rulesFor(o, f.root, start) | |
| 216 | + if v := allowed(lookup(other), "make"); v != approval.NoVerdict || f.asked != 0 { | |
| 217 | + t.Errorf("verdict %v, asked %d: another root must not be asked about", v, f.asked) | |
| 218 | + } | |
| 219 | + g := ccsettings.NewGrant(nil, []ccsettings.Permissions{{Scope: ccsettings.ScopeProject, Allow: []string{"Bash(make:*)"}}}) | |
| 220 | + f.store.Record(other, g.Fingerprint(), true) | |
| 221 | + lookup = rulesFor(o, f.root, start) | |
| 222 | + if v := allowed(lookup(other), "make"); v != approval.VerdictAllow { | |
| 223 | + t.Errorf("a recorded trust for the other root should count: %v", v) | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + func TestAskTrustOnTerminal(t *testing.T) { | |
| 228 | + tests := []struct { | |
| 229 | + in string | |
| 230 | + trusted, answered bool | |
| 231 | + }{ | |
| 232 | + {"y\n", true, true}, | |
| 233 | + {"YES\n", true, true}, | |
| 234 | + {"n\n", false, true}, | |
| 235 | + {"\n", false, true}, | |
| 236 | + {"", false, false}, | |
| 237 | + } | |
| 238 | + for _, tc := range tests { | |
| 239 | + var out strings.Builder | |
| 240 | + ask := askTrustOnTerminal(strings.NewReader(tc.in), &out) | |
| 241 | + trusted, answered := ask("/src/proj", []string{"start these MCP servers, from .mcp.json:", " db: db-mcp"}) | |
| 242 | + if trusted != tc.trusted || answered != tc.answered { | |
| 243 | + t.Errorf("%q: got %v %v, want %v %v", tc.in, trusted, answered, tc.trusted, tc.answered) | |
| 244 | + } | |
| 245 | + if !strings.Contains(out.String(), "db: db-mcp") || !strings.Contains(out.String(), "[y/N]") { | |
| 246 | + t.Errorf("prompt did not show the definitions:\n%s", out.String()) | |
| 247 | + } | |
| 248 | + } | |
| 249 | + } | |
| 250 | + | |
| 251 | + // The answer is read without reading past it: whatever is typed next belongs | |
| 252 | + // to the TUI. | |
| 253 | + func TestReadLineStopsAtTheNewline(t *testing.T) { | |
| 254 | + r := strings.NewReader("y\nnext") | |
| 255 | + line, err := readLine(r) | |
| 256 | + if line != "y" || err != nil { | |
| 257 | + t.Fatalf("%q %v", line, err) | |
| 258 | + } | |
| 259 | + rest := make([]byte, 8) | |
| 260 | + n, _ := r.Read(rest) | |
| 261 | + if string(rest[:n]) != "next" { | |
| 262 | + t.Errorf("rest = %q", rest[:n]) | |
| 263 | + } | |
| 264 | + } | |
| 265 | + | |
| 266 | + func mustMCP(t *testing.T, f *ccFixture) []ccsettings.MCPFile { | |
| 267 | + files, _ := ccsettings.LoadMCP(f.home, f.root) | |
| 268 | + return files | |
| 269 | + } | |
| 270 | + | |
| 271 | + func mustPerms(t *testing.T, f *ccFixture) []ccsettings.Permissions { | |
| 272 | + p, _ := ccsettings.LoadPermissions(f.home, f.root) | |
| 273 | + return p | |
| 274 | + } | |
| @@ -17,6 +17,7 @@ import ( | ||
| 17 | 17 | "github.com/johannSo/codey/internal/agent" |
| 18 | 18 | "github.com/johannSo/codey/internal/approval" |
| 19 | 19 | "github.com/johannSo/codey/internal/ask" |
| 20 | + "github.com/johannSo/codey/internal/ccsettings" | |
| 20 | 21 | "github.com/johannSo/codey/internal/claudecode" |
| 21 | 22 | "github.com/johannSo/codey/internal/config" |
| 22 | 23 | "github.com/johannSo/codey/internal/diag" |
| @@ -93,6 +94,7 @@ func run() error { | ||
| 93 | 94 | noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr") |
| 94 | 95 | noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it") |
| 95 | 96 | noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered") |
| 97 | + trustProject := flag.Bool(strings.TrimPrefix(trustFlag, "-"), false, "use this project's .mcp.json servers and the allow rules in its .claude/settings.json for one run, without asking") | |
| 96 | 98 | |
| 97 | 99 | // Read the line before the flag package does. Three invocations belong to |
| 98 | 100 | // a program driving Codey rather than to a person — see agentcmd.go — and |
| @@ -363,9 +365,34 @@ func run() error { | ||
| 363 | 365 | // in and stay there across /worktree, since a server's working directory |
| 364 | 366 | // is fixed when it starts. A server that will not start is reported and |
| 365 | 367 | // left out; the session goes on without its tools. |
| 368 | + // Claude Code's configuration for this root: its MCP servers merged under | |
| 369 | + // Codey's own, and its permission rules. The project's committed files | |
| 370 | + // are used only if this checkout is trusted, which is asked here — on the | |
| 371 | + // terminal, before the TUI owns it — when there is a person to ask and | |
| 372 | + // nothing has been decided about these exact definitions. -p and agent | |
| 373 | + // runs are not asked; see ccconfig.go. | |
| 374 | + ccOpts := ccOptions{ | |
| 375 | + Home: home, | |
| 376 | + Root: root, | |
| 377 | + Own: cfg.MCPServers, | |
| 378 | + Trust: *trustProject, | |
| 379 | + NoMCP: *noMCP, | |
| 380 | + Notice: notice, | |
| 381 | + } | |
| 382 | + if cfgPath, err := config.Path(); err == nil { | |
| 383 | + ccOpts.Store = ccsettings.DefaultTrustStore(cfgPath) | |
| 384 | + } | |
| 385 | + if inv.Kind == invNormal && *prompt == "" && interactive() { | |
| 386 | + ccOpts.Ask = askTrustOnTerminal(os.Stdin, os.Stderr) | |
| 387 | + } | |
| 388 | + cc := loadClaudeCode(ccOpts) | |
| 389 | + instructions := instructionsFor(home, notice) | |
| 390 | + | |
| 366 | 391 | var mcpTools []tools.Tool |
| 367 | - if len(cfg.MCPServers) > 0 && !*noMCP { | |
| 368 | - ts, closer, errs := mcp.Connect(context.Background(), root, cfg) | |
| 392 | + if len(cc.MCP) > 0 && !*noMCP { | |
| 393 | + mcpCfg := cfg | |
| 394 | + mcpCfg.MCPServers = cc.MCP | |
| 395 | + ts, closer, errs := mcp.Connect(context.Background(), root, mcpCfg) | |
| 369 | 396 | defer closer.Close() |
| 370 | 397 | for _, err := range errs { |
| 371 | 398 | notice("%v", err) |
| @@ -435,6 +462,7 @@ func run() error { | ||
| 435 | 462 | render := &ui.Renderer{W: os.Stdout, Verbose: *verbose} |
| 436 | 463 | approver := &ui.Approver{In: in, Out: os.Stdout} |
| 437 | 464 | policy := approval.New(approver.Prompt) |
| 465 | + policy.Rules = cc.Rules | |
| 438 | 466 | |
| 439 | 467 | // Questions go to the line front end until a TUI takes them over in |
| 440 | 468 | // tui.Run. The tool belongs to the session's own agent and is registered |
| @@ -466,6 +494,7 @@ func run() error { | ||
| 466 | 494 | Skills: found, |
| 467 | 495 | ServerTools: serverTools, |
| 468 | 496 | SystemPrefix: systemPrefix, |
| 497 | + Instructions: instructions(root), | |
| 469 | 498 | } |
| 470 | 499 | |
| 471 | 500 | // An agent run hands the whole thing over to the protocol and never comes |
| @@ -488,6 +517,9 @@ func run() error { | ||
| 488 | 517 | Log: log, |
| 489 | 518 | Base: base, |
| 490 | 519 | Tools: toolsIn, |
| 520 | + | |
| 521 | + Rules: rulesFor(ccOpts, root, cc), | |
| 522 | + Instructions: instructions, | |
| 491 | 523 | }) |
| 492 | 524 | } |
| 493 | 525 | |
| @@ -668,6 +700,7 @@ func run() error { | ||
| 668 | 700 | // to come up in the tab whose agent is asking. |
| 669 | 701 | pol := approval.New(nil) |
| 670 | 702 | pol.YOLO = acceptAll |
| 703 | + pol.Rules = cc.Rules | |
| 671 | 704 | |
| 672 | 705 | var tabSup *subagent.Supervisor |
| 673 | 706 | if !*noAgents { |
| @@ -832,6 +865,12 @@ func run() error { | ||
| 832 | 865 | for _, a := range allowed { |
| 833 | 866 | fmt.Println(" " + a) |
| 834 | 867 | } |
| 868 | + if rules := policy.RuleLines(); len(rules) > 0 { | |
| 869 | + fmt.Println("from Claude Code's settings:") | |
| 870 | + for _, r := range rules { | |
| 871 | + fmt.Println(" " + r) | |
| 872 | + } | |
| 873 | + } | |
| 835 | 874 | continue |
| 836 | 875 | } |
| 837 | 876 | if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil { |
| @@ -1354,6 +1354,11 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) { | ||
| 1354 | 1354 | } else { |
| 1355 | 1355 | m.tr.addNotice("always allowed:\n " + strings.Join(allowed, "\n ")) |
| 1356 | 1356 | } |
| 1357 | + // The standing rules, by the file each came from, so a call that | |
| 1358 | + // never prompted can be traced to the line that let it through. | |
| 1359 | + if rules := m.opts.Policy.RuleLines(); len(rules) > 0 { | |
| 1360 | + m.tr.addNotice("from Claude Code's settings:\n " + strings.Join(rules, "\n ")) | |
| 1361 | + } | |
| 1357 | 1362 | return m, m.sync() |
| 1358 | 1363 | |
| 1359 | 1364 | case line == "/settings": |