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
}
}