first commit
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user