Files
patterm/internal/trust/trust.go
Harry Bayliss 55c6c93086 Sync MCP surface to SPEC §7 process model
Rename list_children/read_output/kill/send_message_to to their SPEC §7
process_id-shaped names; drop report_to_parent (direction inferred by
send_message) and policy_check (replaced by per-project trust gating).
Add the SPEC's missing tools: start_process, restart_process,
close_process, rename_process, select_process, get_process_status,
get_project_status, get_process_raw_output, search_output,
get_process_ports, whoami, help.

Process model now distinguishes agent/terminal/command kinds with
opaque p_<6hex> IDs. Command entries are session-persistent so they
survive PTY exit and can be Restart'd. Status enum gains starting and
stopped. screen_version, port detection, and bracketed-paste send_input
land alongside.

Trust gating (internal/trust) replaces the regex policy: command-preset
spawns return needs_trust on first use; the user confirms in a
status-line modal and the grant persists to
\$XDG_DATA_HOME/patterm/projects/<key>/trust.json.

Tests cover send_message direction inference (parent↔child, sibling
rejection, nil caller paths) and trust grant persistence across reopen.
2026-05-14 14:29:45 +01:00

165 lines
4.1 KiB
Go

// 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/<projectKey>/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
}