Vectrum
Sign In
218 lines · 7.0 KB · Go
package main

// What a Claude Code user's existing configuration contributes to a run: the
// permission rules in its settings files, and its CLAUDE.md files. Its MCP
// servers are not among them; Codey starts only the ones in its own config. 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"
	"strings"
	"sync"

	"github.com/johannSo/codey/internal/approval"
	"github.com/johannSo/codey/internal/ccsettings"
)

// 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
	// Trust is -trust-project: use the project's allow rules this run,
	// whatever was decided before, without asking.
	Trust 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)
}

// ccSetup is what came of it.
type ccSetup struct {
	// 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 rules — and returns
// what this run should use.
func loadClaudeCode(o ccOptions) ccSetup {
	perms, errs := ccsettings.LoadPermissions(o.Home, o.Root)
	for _, err := range errs {
		o.Notice("%v — its deny rules are not in force", err)
	}

	trusted := settleTrust(o, ccsettings.NewGrant(perms))

	out := ccSetup{Trusted: trusted}
	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
	}
	trusted, answered := o.Ask(o.Root, grant.Describe())
	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 {
	return fmt.Sprintf("the allow rules in %s are ignored", strings.Join(g.AllowFrom, " 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
		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(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
	}
}