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.
165 lines
4.1 KiB
Go
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
|
|
}
|