// Package trust implements SPEC §7's per-project command-preset trust // gating. Command presets are not trusted by default; the first time // an agent attempts to spawn / start / restart a process tied to one, // the MCP tool returns a `needs_trust` error and patterm surfaces a UI // confirmation. The user's acceptance is persisted to disk so the // confirmation isn't repeated every run. // // Trust is keyed by `(project, preset name)` in v1. Freeform-argv // command spawns bypass entirely (the agent had to compose the argv, // so the trust decision is already implicit). package trust import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "sync" ) // Store is one project's trust file. Safe for concurrent use. type Store struct { path string mu sync.RWMutex granted map[string]bool } // Open loads (or creates) the trust file for projectKey. The file is // stored at $XDG_DATA_HOME/patterm/projects//trust.json // (SPEC §3). Missing-file is not an error — it simply means no presets // are trusted yet. func Open(projectKey string) (*Store, error) { if projectKey == "" { return nil, errors.New("trust.Open: empty project key") } base, err := dataDir() if err != nil { return nil, err } dir := filepath.Join(base, "projects", projectKey) if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("trust: mkdir %s: %w", dir, err) } path := filepath.Join(dir, "trust.json") s := &Store{path: path, granted: make(map[string]bool)} if err := s.loadLocked(); err != nil { return nil, err } return s, nil } func dataDir() (string, error) { if h := os.Getenv("XDG_DATA_HOME"); h != "" { return filepath.Join(h, "patterm"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".local", "share", "patterm"), nil } // IsTrusted reports whether preset is granted. func (s *Store) IsTrusted(preset string) bool { if preset == "" { return false } s.mu.RLock() defer s.mu.RUnlock() return s.granted[preset] } // Grant records that preset is trusted and persists the file. func (s *Store) Grant(preset string) error { if preset == "" { return errors.New("trust.Grant: empty preset") } s.mu.Lock() defer s.mu.Unlock() if s.granted[preset] { return nil } s.granted[preset] = true return s.saveLocked() } // Revoke removes a trust grant. Not used by the SPEC v1 flow but // useful for tests and future "untrust this" UI. func (s *Store) Revoke(preset string) error { s.mu.Lock() defer s.mu.Unlock() if !s.granted[preset] { return nil } delete(s.granted, preset) return s.saveLocked() } // List returns the trusted presets in sorted order. For UI debugging. func (s *Store) List() []string { s.mu.RLock() defer s.mu.RUnlock() out := make([]string, 0, len(s.granted)) for k := range s.granted { out = append(out, k) } sort.Strings(out) return out } // Path returns the trust file path. Used by tests / diagnostics. func (s *Store) Path() string { return s.path } type fileShape struct { // Presets is the JSON shape on disk: a list of granted preset names. // Using a list (not a map) keeps the file diff-friendly and ordering // stable across re-saves. Presets []string `json:"presets"` } func (s *Store) loadLocked() error { b, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return fmt.Errorf("trust: read %s: %w", s.path, err) } if len(b) == 0 { return nil } var f fileShape if err := json.Unmarshal(b, &f); err != nil { return fmt.Errorf("trust: parse %s: %w", s.path, err) } for _, p := range f.Presets { s.granted[p] = true } return nil } func (s *Store) saveLocked() error { out := make([]string, 0, len(s.granted)) for k := range s.granted { out = append(out, k) } sort.Strings(out) body, err := json.MarshalIndent(fileShape{Presets: out}, "", " ") if err != nil { return err } body = append(body, '\n') tmp := s.path + ".tmp" if err := os.WriteFile(tmp, body, 0o600); err != nil { return fmt.Errorf("trust: write %s: %w", tmp, err) } if err := os.Rename(tmp, s.path); err != nil { return fmt.Errorf("trust: rename %s: %w", s.path, err) } return nil }