Vectrum
Sign In

mcp: the servers Claude Code already knows about

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>
Jjonicommitted 1 parent 2b4f2e7commiteff8c42

Showing 5 changed files with 600 additions and 2 deletions.

+15−0cmd/codey/agentcmd.goView File
@@ -25,6 +25,7 @@ import (
2525
2626 "github.com/johannSo/codey/internal/acp"
2727 "github.com/johannSo/codey/internal/agent"
28+ "github.com/johannSo/codey/internal/approval"
2829 "github.com/johannSo/codey/internal/llm"
2930 "github.com/johannSo/codey/internal/metrics"
3031 "github.com/johannSo/codey/internal/serve"
@@ -204,6 +205,12 @@ type acpDeps struct {
204205 Base agent.Options
205206 // Tools is main's factory: a root in, a tool-set builder out.
206207 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
207214 }
208215
209216 // 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 {
240247 // the parent's limits.
241248 newTools := deps.Tools(req.Root)
242249 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+ }
243258
244259 set := serve.AgentSet{}
245260 if !deps.NoAgents {
+265−0cmd/codey/ccconfig.goaddedView File
@@ -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+ }
+274−0cmd/codey/ccconfig_test.goaddedView File
@@ -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+ }
+41−2cmd/codey/main.goView File
@@ -17,6 +17,7 @@ import (
1717 "github.com/johannSo/codey/internal/agent"
1818 "github.com/johannSo/codey/internal/approval"
1919 "github.com/johannSo/codey/internal/ask"
20+ "github.com/johannSo/codey/internal/ccsettings"
2021 "github.com/johannSo/codey/internal/claudecode"
2122 "github.com/johannSo/codey/internal/config"
2223 "github.com/johannSo/codey/internal/diag"
@@ -93,6 +94,7 @@ func run() error {
9394 noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
9495 noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")
9596 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")
9698
9799 // Read the line before the flag package does. Three invocations belong to
98100 // a program driving Codey rather than to a person — see agentcmd.go — and
@@ -363,9 +365,34 @@ func run() error {
363365 // in and stay there across /worktree, since a server's working directory
364366 // is fixed when it starts. A server that will not start is reported and
365367 // 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+
366391 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)
369396 defer closer.Close()
370397 for _, err := range errs {
371398 notice("%v", err)
@@ -435,6 +462,7 @@ func run() error {
435462 render := &ui.Renderer{W: os.Stdout, Verbose: *verbose}
436463 approver := &ui.Approver{In: in, Out: os.Stdout}
437464 policy := approval.New(approver.Prompt)
465+ policy.Rules = cc.Rules
438466
439467 // Questions go to the line front end until a TUI takes them over in
440468 // tui.Run. The tool belongs to the session's own agent and is registered
@@ -466,6 +494,7 @@ func run() error {
466494 Skills: found,
467495 ServerTools: serverTools,
468496 SystemPrefix: systemPrefix,
497+ Instructions: instructions(root),
469498 }
470499
471500 // An agent run hands the whole thing over to the protocol and never comes
@@ -488,6 +517,9 @@ func run() error {
488517 Log: log,
489518 Base: base,
490519 Tools: toolsIn,
520+
521+ Rules: rulesFor(ccOpts, root, cc),
522+ Instructions: instructions,
491523 })
492524 }
493525
@@ -668,6 +700,7 @@ func run() error {
668700 // to come up in the tab whose agent is asking.
669701 pol := approval.New(nil)
670702 pol.YOLO = acceptAll
703+ pol.Rules = cc.Rules
671704
672705 var tabSup *subagent.Supervisor
673706 if !*noAgents {
@@ -832,6 +865,12 @@ func run() error {
832865 for _, a := range allowed {
833866 fmt.Println(" " + a)
834867 }
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+ }
835874 continue
836875 }
837876 if err := once(ctx, a, render, sessionDir, session, root, client, line); err != nil {
+5−0internal/tui/model.goView File
@@ -1354,6 +1354,11 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
13541354 } else {
13551355 m.tr.addNotice("always allowed:\n " + strings.Join(allowed, "\n "))
13561356 }
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+ }
13571362 return m, m.sync()
13581363
13591364 case line == "/settings":