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
}