Vectrum
Sign In
167 lines · 5.0 KB · Go
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
}