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.
This commit is contained in:
164
internal/trust/trust.go
Normal file
164
internal/trust/trust.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user