package ccsettings import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "slices" "strings" "time" ) // Grant is everything a project's committed Claude Code files would grant if // trusted: the servers its .mcp.json would start, and the allow rules its // shared settings would honour. One question covers both, because both are // the same question — do you trust what this repository says about itself — // and two prompts at startup for one decision is one too many. type Grant struct { // Servers are the stdio servers .mcp.json defines, unexpanded. Servers map[string]ServerDef // Allow is the allow rules from every project-scope settings file, and // AllowFrom names those files. Allow []string AllowFrom []string } // NewGrant collects what the project-scope files would grant. func NewGrant(mcp []MCPFile, perms []Permissions) Grant { var g Grant if p := Project(mcp); p != nil { g.Servers = p.Servers } for _, p := range perms { if p.Scope == ScopeProject && len(p.Allow) > 0 { g.Allow = append(g.Allow, p.Allow...) g.AllowFrom = append(g.AllowFrom, p.Shown) } } return g } // Empty reports whether there is nothing to ask about. func (g Grant) Empty() bool { return len(g.Servers) == 0 && len(g.Allow) == 0 } // Fingerprint identifies exactly these definitions. A trust decision is // recorded against it, so a .mcp.json that changes — a new server, a // different command for an old one, one more allow rule — is a new question // rather than one answered last month about something else. // // It covers the definitions as written, before ${VAR} is expanded: what the // repository says is what is being trusted, and the user's environment is the // user's own. func (g Grant) Fingerprint() string { allow := slices.Clone(g.Allow) slices.Sort(allow) // encoding/json writes map keys sorted, so equal definitions always // encode to equal bytes. body, _ := json.Marshal(struct { Servers map[string]ServerDef `json:"servers"` Allow []string `json:"allow"` }{g.Servers, allow}) sum := sha256.Sum256(body) return "sha256:" + hex.EncodeToString(sum[:]) } // Describe is the grant as lines for a trust prompt: each server's command // line in full, since that is the thing being agreed to, and each rule. func (g Grant) Describe() []string { var lines []string if len(g.Servers) > 0 { lines = append(lines, "start these MCP servers, from "+ProjectMCP+":") for _, name := range sortedNames(g.Servers) { d := g.Servers[name] line := " " + name + ": " + strings.Join(append([]string{d.Command}, d.Args...), " ") if len(d.Env) > 0 { line += " (env: " + strings.Join(sortedNames(d.Env), ", ") + ")" } lines = append(lines, line) } } if len(g.Allow) > 0 { lines = append(lines, "run these without asking, from "+strings.Join(g.AllowFrom, " and ")+":") for _, r := range g.Allow { lines = append(lines, " "+r) } } return lines } // TrustFilename is the record of trust decisions, beside Codey's config. const TrustFilename = "trust.json" // TrustStore remembers, per working root, whether the user trusted that // project's Claude Code files — and which version of them. // // It lives in the user's config directory, not in the project's .codey/ // directory with the rest of the per-repository state. That is the whole // point of it: anything inside the working tree can arrive with a clone, and a // repository that shipped its own .codey/trust.json would be approving its own // servers. The fingerprint is no protection there, because whoever wrote the // .mcp.json can compute it. Only a file the repository cannot reach can say // what the user decided. type TrustStore struct { Path string } // DefaultTrustStore is the store beside the config file, wherever // XDG_CONFIG_HOME puts that. func DefaultTrustStore(configPath string) TrustStore { return TrustStore{Path: filepath.Join(filepath.Dir(configPath), TrustFilename)} } type trustFile struct { Version int `json:"version"` Projects map[string]trustEntry `json:"projects"` } type trustEntry struct { Fingerprint string `json:"fingerprint"` Trusted bool `json:"trusted"` Decided time.Time `json:"decided"` } // Lookup reports whether a decision has been made about this root with this // fingerprint, and what it was. A decision about a different fingerprint is // no decision: the files have changed since. func (s TrustStore) Lookup(root, fingerprint string) (decided, trusted bool, err error) { f, err := s.read() if err != nil { return false, false, err } e, ok := f.Projects[filepath.Clean(root)] if !ok || e.Fingerprint != fingerprint { return false, false, nil } return true, e.Trusted, nil } // Record saves a decision, replacing whatever was decided about this root // before. A refusal is recorded too, so that saying no is not a question asked // again on every start; a changed file asks again either way. func (s TrustStore) Record(root, fingerprint string, trusted bool) error { f, err := s.read() if err != nil { return err } if f.Projects == nil { f.Projects = map[string]trustEntry{} } f.Version = 1 f.Projects[filepath.Clean(root)] = trustEntry{Fingerprint: fingerprint, Trusted: trusted, Decided: time.Now().UTC()} body, err := json.MarshalIndent(f, "", " ") if err != nil { return err } body = append(body, '\n') if err := os.MkdirAll(filepath.Dir(s.Path), 0o755); err != nil { return fmt.Errorf("trust: %w", err) } // A temporary file renamed over the target, as config.Save does: a write // cut short must not lose every other project's decision. tmp, err := os.CreateTemp(filepath.Dir(s.Path), TrustFilename+".*") if err != nil { return fmt.Errorf("trust: %w", err) } defer os.Remove(tmp.Name()) if _, err := tmp.Write(body); err != nil { tmp.Close() return fmt.Errorf("trust: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("trust: %w", err) } return os.Rename(tmp.Name(), s.Path) } func (s TrustStore) read() (trustFile, error) { var f trustFile raw, err := os.ReadFile(s.Path) if errors.Is(err, fs.ErrNotExist) { return f, nil } if err != nil { return f, fmt.Errorf("trust: %w", err) } if err := json.Unmarshal(raw, &f); err != nil { return f, fmt.Errorf("trust: %s is not valid JSON: %w", s.Path, err) } return f, nil }