package main // What a Claude Code user's existing configuration contributes to a run: the // MCP servers it knows about, the permission rules in its settings files, and // its CLAUDE.md files. The reading and the rules about what is honoured live in // internal/ccsettings; this file is the wiring — when to ask about trust, how, // and what to say when the answer is no. import ( "fmt" "io" "os" "sort" "strings" "sync" "github.com/johannSo/codey/internal/approval" "github.com/johannSo/codey/internal/ccsettings" "github.com/johannSo/codey/internal/config" ) // trustFlag is spelled once, because three messages name it. const trustFlag = "-trust-project" // ccOptions is what loading Claude Code's configuration needs from main. type ccOptions struct { Home, Root string // Own is Codey's own mcp_servers, which win any clash. Own map[string]config.MCPServer // Trust is -trust-project: use the project's servers and allow rules this // run, whatever was decided before, without asking. Trust bool // NoMCP is -no-mcp. The servers are still part of the trust question — // the question is about the repository, not about this run — but the // prompt says they will not start. NoMCP bool // Ask puts the trust question to a person and reports the answer, and // whether there was one. Nil where nobody can answer: -p, agent stdio, // a piped stdin. Ask func(root string, lines []string) (trusted, answered bool) Store ccsettings.TrustStore // Notice reports something to the user, wherever this run's notices go. Notice func(format string, args ...any) // Lookup is the environment ${VAR} expands from. Lookup func(string) (string, bool) } // ccSetup is what came of it. type ccSetup struct { // MCP is every server to start, Codey's own included. MCP map[string]config.MCPServer // Rules is nil when no settings file had any rules, so the policy is left // exactly as it would be on a machine without Claude Code. Rules approval.RuleSet // Trusted is the trust decision for the root, for reuse by rulesFor. Trusted bool } // loadClaudeCode reads Claude Code's files for the startup root, settles // whether the project's own files are trusted — asking, if there is someone // to ask and nothing has been decided about these exact definitions — and // returns what this run should use. func loadClaudeCode(o ccOptions) ccSetup { if o.Lookup == nil { o.Lookup = os.LookupEnv } mcpFiles, errs := ccsettings.LoadMCP(o.Home, o.Root) for _, err := range errs { o.Notice("%v", err) } perms, errs := ccsettings.LoadPermissions(o.Home, o.Root) for _, err := range errs { o.Notice("%v — its deny rules are not in force", err) } grant := ccsettings.NewGrant(mcpFiles, perms) trusted := settleTrust(o, grant) out := ccSetup{Trusted: trusted} merged, notes := ccsettings.MergeMCP(o.Own, mcpFiles, trusted, o.Lookup) out.MCP = merged if !o.NoMCP { // A clash or a missing variable is only news if the servers were // going to start. for _, n := range notes { o.Notice("%s", n) } } rules, notes := ccsettings.NewRuleset(o.Home, o.Root, perms, trusted) for _, n := range notes { o.Notice("%s", n) } if !rules.Empty() { out.Rules = rules } return out } // settleTrust decides whether what the project's committed files grant is // used this run. The flag wins; then a decision already recorded for these // exact definitions; then the person at the terminal, if there is one. With // nobody to ask, the answer is no, and the notice says how to say yes. func settleTrust(o ccOptions, grant ccsettings.Grant) bool { if grant.Empty() { return false } if o.Trust { return true } fp := grant.Fingerprint() decided, trusted, err := o.Store.Lookup(o.Root, fp) if err != nil { o.Notice("%v — treating this project as not trusted", err) return false } if decided { if !trusted { 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) } return trusted } if o.Ask == nil { 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) return false } lines := grant.Describe() if o.NoMCP && len(grant.Servers) > 0 { lines = append(lines, "(the servers will not start this run: -no-mcp)") } trusted, answered := o.Ask(o.Root, lines) if !answered { // Interrupted, or stdin closed: no for now, and the question stays // open for the next start. return false } if err := o.Store.Record(o.Root, fp, trusted); err != nil { o.Notice("%v — the answer holds for this run only", err) } if !trusted { o.Notice("%s — you can change your mind with %s", untrustedWhat(grant), trustFlag) } return trusted } // untrustedWhat names what is being left out, for a notice. func untrustedWhat(g ccsettings.Grant) string { var parts []string if len(g.Servers) > 0 { names := make([]string, 0, len(g.Servers)) for n := range g.Servers { names = append(names, n) } sort.Strings(names) parts = append(parts, fmt.Sprintf("the MCP servers in %s (%s) were not started", ccsettings.ProjectMCP, strings.Join(names, ", "))) } if len(g.Allow) > 0 { parts = append(parts, fmt.Sprintf("the allow rules in %s are ignored", strings.Join(g.AllowFrom, " and "))) } return strings.Join(parts, ", and ") } // askTrustOnTerminal is the trust question, asked on the terminal before the // TUI takes it over — the same moment Claude Code asks its own. It goes to // stderr and reads stdin a byte at a time, so nothing typed after the answer // is swallowed into a buffer the TUI never sees. func askTrustOnTerminal(in io.Reader, out io.Writer) func(string, []string) (bool, bool) { return func(root string, lines []string) (bool, bool) { fmt.Fprintf(out, "\nClaude Code's configuration for %s would:\n\n", root) for _, l := range lines { fmt.Fprintln(out, " "+l) } fmt.Fprint(out, "\nThese files come from the repository, not from you: trusting them lets it\n"+ "choose commands this machine runs. Your answer is remembered until they change.\n"+ "Trust them for this checkout? [y/N] ") line, err := readLine(in) if err != nil { fmt.Fprintln(out) return false, false } switch strings.ToLower(strings.TrimSpace(line)) { case "y", "yes": return true, true default: return false, true } } } // readLine reads up to a newline without reading past it. func readLine(r io.Reader) (string, error) { var b strings.Builder buf := make([]byte, 1) for { n, err := r.Read(buf) if n == 1 { if buf[0] == '\n' { return b.String(), nil } b.WriteByte(buf[0]) } if err != nil { if err == io.EOF && b.Len() > 0 { return b.String(), nil } return "", err } } } // rulesFor builds the rule set for a root other than the one Codey started // in — an ACP client hands each thread a directory of its own. Nobody can be // asked from there, so the project's allow rules count only if trust was // already recorded for them, or -trust-project was given. Each root is read // once; a model switch rebuilds the agent, and the same notices twice would // be noise. func rulesFor(o ccOptions, startRoot string, start ccSetup) func(root string) approval.RuleSet { var mu sync.Mutex cache := map[string]approval.RuleSet{startRoot: start.Rules} return func(root string) approval.RuleSet { mu.Lock() defer mu.Unlock() if rs, ok := cache[root]; ok { return rs } ro := o ro.Root, ro.Ask = root, nil mcpFiles, _ := ccsettings.LoadMCP(o.Home, root) perms, errs := ccsettings.LoadPermissions(o.Home, root) for _, err := range errs { o.Notice("%v — its deny rules are not in force", err) } trusted := settleTrust(ro, ccsettings.NewGrant(mcpFiles, perms)) rules, notes := ccsettings.NewRuleset(o.Home, root, perms, trusted) for _, n := range notes { o.Notice("%s", n) } var rs approval.RuleSet if !rules.Empty() { rs = rules } cache[root] = rs return rs } } // instructionsFor reads the CLAUDE.md files for a root, formatted for the // system prompt, reporting what could not be read. func instructionsFor(home string, notice func(string, ...any)) func(root string) string { var mu sync.Mutex cache := map[string]string{} return func(root string) string { mu.Lock() defer mu.Unlock() if s, ok := cache[root]; ok { return s } files, errs := ccsettings.LoadInstructions(home, root) for _, err := range errs { notice("%v", err) } s := ccsettings.FormatInstructions(files) cache[root] = s return s } }