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 allow rules its shared settings would honour. type Grant struct { // 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(perms []Permissions) Grant { var g Grant 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.Allow) == 0 } // Fingerprint identifies exactly these rules. A trust decision is recorded // against it, so a settings file that changes — one more allow rule — is a // new question rather than one answered last month about something else. func (g Grant) Fingerprint() string { allow := slices.Clone(g.Allow) slices.Sort(allow) body, _ := json.Marshal(struct { Allow []string `json:"allow"` }{allow}) sum := sha256.Sum256(body) return "sha256:" + hex.EncodeToString(sum[:]) } // Describe is the grant as lines for a trust prompt: each rule, since that is // the thing being agreed to. func (g Grant) Describe() []string { if len(g.Allow) == 0 { return nil } lines := []string{"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 // rules. The fingerprint is no protection there, because whoever wrote the // settings file 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 }