304 lines
9.5 KiB
Go
304 lines
9.5 KiB
Go
// Package trust persists per-directory trust decisions so pigo can avoid running
|
|
// side-effect tools (bash/write/edit) in directories the user has not trusted
|
|
// (US-018, #134). Decisions are stored as a JSON map of directory path to a
|
|
// nullable boolean: true = trusted, false = untrusted, null/absent = undecided.
|
|
//
|
|
// The package is intentionally free of any REPL or tool-execution concerns: it
|
|
// only loads, queries, and persists decisions. The interactive prompt and the
|
|
// permission-hook integration live in cmd/pigo.
|
|
package trust
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// Decision is the tri-state trust value for a directory.
|
|
type Decision int
|
|
|
|
const (
|
|
// Undecided means no decision is saved for the directory (or an explicit
|
|
// null entry). The caller should prompt the user and treat side-effect
|
|
// tools as requiring confirmation.
|
|
Undecided Decision = iota
|
|
// Trusted means side-effect tools may run without confirmation.
|
|
Trusted
|
|
// Untrusted means side-effect tools require confirmation.
|
|
Untrusted
|
|
)
|
|
|
|
// String returns a human-readable label for the decision.
|
|
func (d Decision) String() string {
|
|
switch d {
|
|
case Trusted:
|
|
return "trusted"
|
|
case Untrusted:
|
|
return "untrusted"
|
|
default:
|
|
return "undecided"
|
|
}
|
|
}
|
|
|
|
// decisionFromBool maps a saved nullable boolean to a Decision. A nil pointer
|
|
// (JSON null, or an absent entry) is Undecided.
|
|
func decisionFromBool(b *bool) Decision {
|
|
if b == nil {
|
|
return Undecided
|
|
}
|
|
if *b {
|
|
return Trusted
|
|
}
|
|
return Untrusted
|
|
}
|
|
|
|
// boolFromDecision maps a Decision to the nullable boolean persisted to disk.
|
|
// Undecided maps to nil so the JSON value is null, preserving the
|
|
// "path -> bool|null" schema even for an explicitly-recorded undecided entry.
|
|
func boolFromDecision(d Decision) *bool {
|
|
switch d {
|
|
case Trusted:
|
|
v := true
|
|
return &v
|
|
case Untrusted:
|
|
v := false
|
|
return &v
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Result is the outcome of a nearest-decision lookup.
|
|
type Result struct {
|
|
// Decision is the nearest saved decision, or Undecided when none is found.
|
|
Decision Decision
|
|
// Path is the directory whose saved decision applies (the nearest ancestor
|
|
// of cwd with an entry, inclusive of cwd itself). Empty when no entry was
|
|
// found anywhere up the tree.
|
|
Path string
|
|
// Found reports whether any entry (true/false/null) existed for cwd or an
|
|
// ancestor. When false, Decision is Undecided and the caller should prompt
|
|
// the user for a fresh decision.
|
|
Found bool
|
|
}
|
|
|
|
// Manager loads and persists trust decisions to a JSON file (path -> *bool).
|
|
// The zero value is not usable; construct with NewManager. It is safe for
|
|
// concurrent use: every method takes the manager mutex.
|
|
type Manager struct {
|
|
path string
|
|
mu sync.Mutex
|
|
data map[string]*bool
|
|
// session marks directories trusted for the current process only ("just
|
|
// once"). It is never persisted and is consulted by IsTrusted before the
|
|
// on-disk data, so a one-shot grant takes effect immediately.
|
|
session map[string]bool
|
|
}
|
|
|
|
// DefaultPath returns the trust file location: $PIGO_HOME/trust.json, or
|
|
// ~/.pigo/trust.json when PIGO_HOME is unset. It returns "" when the home
|
|
// directory cannot be resolved and no override is set, so a caller can treat
|
|
// trust as disabled rather than guessing a path.
|
|
func DefaultPath() string {
|
|
if dir := os.Getenv("PIGO_HOME"); dir != "" {
|
|
return filepath.Join(dir, "trust.json")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(home, ".pigo", "trust.json")
|
|
}
|
|
|
|
// NewManager loads the trust file at path. A missing file is not an error: the
|
|
// manager starts empty and the file is created lazily on the first SetDecision
|
|
// / Forget. A present-but-malformed file is a hard error so a corrupted trust
|
|
// store is surfaced rather than silently overwritten.
|
|
func NewManager(path string) (*Manager, error) {
|
|
m := &Manager{
|
|
path: path,
|
|
data: make(map[string]*bool),
|
|
session: make(map[string]bool),
|
|
}
|
|
if path == "" {
|
|
return m, nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return m, nil
|
|
}
|
|
return nil, fmt.Errorf("trust: read %s: %w", path, err)
|
|
}
|
|
if len(data) == 0 {
|
|
return m, nil
|
|
}
|
|
if err := json.Unmarshal(data, &m.data); err != nil {
|
|
return nil, fmt.Errorf("trust: parse %s: %w", path, err)
|
|
}
|
|
if m.data == nil {
|
|
m.data = make(map[string]*bool)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// walkUp returns the directory chain from cwd (inclusive) up to the filesystem
|
|
// root, in nearest-first order. Paths are cleaned. An empty cwd yields no
|
|
// entries.
|
|
func walkUp(cwd string) []string {
|
|
cwd = filepath.Clean(cwd)
|
|
if cwd == "" || cwd == "." {
|
|
return nil
|
|
}
|
|
var dirs []string
|
|
cur := cwd
|
|
for {
|
|
dirs = append(dirs, cur)
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
break
|
|
}
|
|
cur = parent
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// nearestLocked computes the nearest saved decision for cwd without taking the
|
|
// mutex, so it can be reused inside already-locked methods.
|
|
func (m *Manager) nearestLocked(cwd string) Result {
|
|
for _, dir := range walkUp(cwd) {
|
|
if v, ok := m.data[dir]; ok {
|
|
return Result{Decision: decisionFromBool(v), Path: dir, Found: true}
|
|
}
|
|
}
|
|
return Result{Decision: Undecided, Found: false}
|
|
}
|
|
|
|
// NearestTrustDecision walks up from cwd (inclusive) to the filesystem root and
|
|
// returns the nearest saved decision. When no entry is found anywhere up the
|
|
// tree it returns {Decision: Undecided, Found: false} so the caller knows to
|
|
// prompt the user.
|
|
func (m *Manager) NearestTrustDecision(cwd string) Result {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.nearestLocked(cwd)
|
|
}
|
|
|
|
// IsTrusted reports whether cwd is trusted for side-effect execution. It
|
|
// returns true when the nearest persisted decision is Trusted, or when cwd (or
|
|
// an ancestor) was granted session trust via SetSessionTrust. Everything else
|
|
// (Untrusted, Undecided, or no entry) returns false, meaning side-effect tools
|
|
// require confirmation.
|
|
func (m *Manager) IsTrusted(cwd string) bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, dir := range walkUp(cwd) {
|
|
if m.session[dir] {
|
|
return true
|
|
}
|
|
}
|
|
return m.nearestLocked(cwd).Decision == Trusted
|
|
}
|
|
|
|
// SetDecision persists a decision for dir. Trusted and Untrusted write true and
|
|
// false respectively; Undecided writes an explicit null entry (recorded but
|
|
// undecided, distinct from a forgotten/absent entry). The directory is created
|
|
// lazily when the trust file is first written.
|
|
func (m *Manager) SetDecision(dir string, dec Decision) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
dir = filepath.Clean(dir)
|
|
m.data[dir] = boolFromDecision(dec)
|
|
return m.saveLocked()
|
|
}
|
|
|
|
// SetSessionTrust grants trust for dir for the current process only. It is not
|
|
// persisted: a future pigo launch re-prompts. Used by the "just once" REPL
|
|
// choice and by the confirmation prompt's "always" response.
|
|
func (m *Manager) SetSessionTrust(dir string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.session[filepath.Clean(dir)] = true
|
|
}
|
|
|
|
// ClearSessionTrust revokes any session trust granted for dir or an ancestor,
|
|
// so a subsequent IsTrusted reflects only the persisted decision. It does not
|
|
// touch the on-disk store. Used by "/trust off" so an active session grant
|
|
// (from a prior "always") does not override a freshly-persisted Untrusted
|
|
// entry - IsTrusted checks session before persisted, so without this clear the
|
|
// "off" command would be ineffective until restart.
|
|
func (m *Manager) ClearSessionTrust(dir string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, d := range walkUp(dir) {
|
|
delete(m.session, d)
|
|
}
|
|
}
|
|
|
|
// Forget removes any saved decision for dir (both true/false and an explicit
|
|
// null entry), so the directory is treated as undecided on the next lookup.
|
|
func (m *Manager) Forget(dir string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
dir = filepath.Clean(dir)
|
|
delete(m.data, dir)
|
|
return m.saveLocked()
|
|
}
|
|
|
|
// DecisionFor returns the raw saved value for a single path (nil when absent or
|
|
// null). It is the exact-path lookup (no walk), used by /trust status to show
|
|
// what is stored for the current directory itself.
|
|
func (m *Manager) DecisionFor(dir string) (Decision, bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
v, ok := m.data[filepath.Clean(dir)]
|
|
if !ok {
|
|
return Undecided, false
|
|
}
|
|
return decisionFromBool(v), true
|
|
}
|
|
|
|
// saveLocked writes the trust map to disk atomically so a crash mid-write
|
|
// cannot leave a truncated store. json.Marshal sorts map keys, so the output is
|
|
// stable and diff-friendly; a nil *bool marshals as JSON null, preserving the
|
|
// "path -> bool|null" schema. The temp file is created with os.CreateTemp
|
|
// (mode 0o600, process-unique name) so two concurrent pigo processes writing
|
|
// the shared store cannot clobber each other's temp file before the rename.
|
|
// The caller must hold m.mu.
|
|
func (m *Manager) saveLocked() error {
|
|
if m.path == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(m.path), 0o700); err != nil {
|
|
return fmt.Errorf("trust: create dir: %w", err)
|
|
}
|
|
b, err := json.Marshal(m.data)
|
|
if err != nil {
|
|
return fmt.Errorf("trust: marshal: %w", err)
|
|
}
|
|
b = append(b, '\n')
|
|
dir := filepath.Dir(m.path)
|
|
f, err := os.CreateTemp(dir, filepath.Base(m.path)+".*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("trust: create temp file: %w", err)
|
|
}
|
|
tmpPath := f.Name()
|
|
cleanup := func() { _ = os.Remove(tmpPath) }
|
|
if _, err := f.Write(b); err != nil {
|
|
f.Close()
|
|
cleanup()
|
|
return fmt.Errorf("trust: write %s: %w", tmpPath, err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("trust: close %s: %w", tmpPath, err)
|
|
}
|
|
if err := os.Rename(tmpPath, m.path); err != nil {
|
|
cleanup()
|
|
return fmt.Errorf("trust: rename %s: %w", m.path, err)
|
|
}
|
|
return nil
|
|
}
|