first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+305
View File
@@ -0,0 +1,305 @@
// This file holds the interactive pieces of project trust (US-018, #134). The
// trust store itself lives alongside in manager.go; here are the REPL-facing
// parts:
//
// - the first-run trust dialog (EnsureTrustPrompt), shown when the cwd has no
// saved decision;
// - the /trust command (RegisterCommand), which saves or reports the
// current project's decision;
// - the BeforeToolCall hook (BeforeToolCall) that asks before side-effect
// tools (bash/write/edit) run in an untrusted directory.
//
// All prompts share the REPL's single *bufio.Reader so input typed ahead is
// never split between the main loop and a confirmation. Headless mode is
// unaffected: trust is a REPL safety feature and headless is an explicit,
// non-interactive invocation.
package trust
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/runtime"
)
// SideEffectTools are the built-in tools with filesystem or process side
// effects that trust gates. Read-only tools (read/grep/find), in-memory tools
// (todo), and network-read tools (webfetch) are never gated.
var SideEffectTools = map[string]bool{
"bash": true,
"write": true,
"edit": true,
}
// EstablishTrust decides how the launch directory's trust is established before
// the REPL runs. When approve is true (mirrors pi's --approve/-a), it grants
// session trust up front so the first-launch prompt is skipped and side-effect
// tools run without per-call confirmation. Otherwise it defers to
// EnsureTrustPrompt, which asks only on the first launch in an undecided
// directory. mgr==nil disables trust entirely, so approve is a no-op.
func EstablishTrust(out io.Writer, in *bufio.Reader, mgr *Manager, cwd string, approve bool) {
if approve {
if mgr != nil {
mgr.SetSessionTrust(cwd)
}
return
}
EnsureTrustPrompt(out, in, mgr, cwd)
}
// EnsureTrustPrompt runs the first-run trust dialog when cwd has no saved
// decision (NearestTrustDecision reports Found=false). When a decision already
// exists (trusted/untrusted/null) it is a no-op: the user already answered, so
// pigo does not re-ask on every launch. mgr==nil disables trust entirely.
func EnsureTrustPrompt(out io.Writer, in *bufio.Reader, mgr *Manager, cwd string) {
if mgr == nil {
return
}
if res := mgr.NearestTrustDecision(cwd); res.Found {
return
}
fmt.Fprintf(out, "\nFirst time in this directory: %s\n", cwd)
fmt.Fprintln(out, "pigo runs side-effect tools (bash, write, edit) here. Choose a trust level:")
fmt.Fprintln(out, " 1) Trust - remember as trusted (tools run without asking)")
fmt.Fprintln(out, " 2) Just once - trust only for this session (default)")
fmt.Fprintln(out, " 3) Reject - do not trust (tools ask each time)")
// Default to "just once" (2): it keeps the REPL usable without persisting
// a trust grant the user did not explicitly confirm.
choice := readMenuChoice(out, in, "Enter choice [1-3]: ", 3, 2)
switch choice {
case 1:
target := chooseScope(out, in, cwd)
if err := mgr.SetDecision(target, Trusted); err != nil {
fmt.Fprintf(out, "pigo: could not save trust decision: %v\n", err)
return
}
fmt.Fprintf(out, "Trusted %s (saved to %s).\n", cwd, target)
case 3:
target := chooseScope(out, in, cwd)
if err := mgr.SetDecision(target, Untrusted); err != nil {
fmt.Fprintf(out, "pigo: could not save trust decision: %v\n", err)
return
}
fmt.Fprintf(out, "Marked %s untrusted (saved to %s). Side-effect tools will ask.\n", cwd, target)
default: // 2 = just once
mgr.SetSessionTrust(cwd)
fmt.Fprintf(out, "Trusted %s for this session only (not saved).\n", cwd)
}
}
// chooseScope asks whether to save the decision for the current directory or
// its parent, returning the chosen path. On empty/EOF it defaults to the
// current directory.
func chooseScope(out io.Writer, in *bufio.Reader, cwd string) string {
parent := filepath.Dir(filepath.Clean(cwd))
if parent == filepath.Clean(cwd) {
// cwd is the filesystem root: there is no parent, so do not offer one.
return cwd
}
fmt.Fprintln(out, "Save for:")
fmt.Fprintf(out, " 1) This directory (%s)\n", cwd)
fmt.Fprintf(out, " 2) Parent directory (%s)\n", parent)
if readMenuChoice(out, in, "Enter [1-2]: ", 2, 1) == 2 {
return parent
}
return cwd
}
// readMenuChoice prompts and reads a 1..max integer, re-prompting on invalid
// input. An empty line or EOF returns def so the dialog never deadlocks on
// missing input.
func readMenuChoice(out io.Writer, in *bufio.Reader, prompt string, max, def int) int {
for {
fmt.Fprint(out, prompt)
line, err := in.ReadString('\n')
if err != nil && line == "" {
return def
}
s := strings.TrimSpace(line)
if s == "" {
return def
}
if n, parseErr := strconv.Atoi(s); parseErr == nil && n >= 1 && n <= max {
return n
}
fmt.Fprintf(out, " (enter a number 1-%d)\n", max)
if err != nil {
return def
}
}
}
// RegisterCommand installs the /trust action command, which saves or
// reports the current project's trust decision. It is an instance built-in
// (AddBuiltin) because its closure captures the trust manager and cwd - state
// out of reach of an init()-time global registration. mgr==nil is a no-op: the
// command is not installed, so /trust reports unknown when trust is disabled.
func RegisterCommand(reg *runtime.SlashRegistry, mgr *Manager, cwd string) {
if mgr == nil {
return
}
reg.AddBuiltin(runtime.SlashCommand{
Name: "trust",
Description: "view or set this project's trust: /trust [on|off|once|status]",
Action: func(args string) string {
switch strings.TrimSpace(strings.ToLower(args)) {
case "", "on":
if err := mgr.SetDecision(cwd, Trusted); err != nil {
return fmt.Sprintf("pigo: could not save trust: %v", err)
}
return fmt.Sprintf("trusted %s (saved)", cwd)
case "off":
// Clear any active session grant first: IsTrusted checks
// session before the persisted decision, so without this an
// "always" granted earlier in the session would keep the dir
// trusted until restart and the message below would be a lie.
mgr.ClearSessionTrust(cwd)
if err := mgr.SetDecision(cwd, Untrusted); err != nil {
return fmt.Sprintf("pigo: could not save trust: %v", err)
}
return fmt.Sprintf("marked %s untrusted (saved); side-effect tools will ask", cwd)
case "once":
mgr.SetSessionTrust(cwd)
return fmt.Sprintf("trusted %s for this session only (not saved)", cwd)
case "status":
res := mgr.NearestTrustDecision(cwd)
if !res.Found {
return fmt.Sprintf("%s: undecided (no saved decision)", cwd)
}
return fmt.Sprintf("%s: %s (decision saved for %s)", cwd, res.Decision, res.Path)
default:
return "usage: /trust [on|off|once|status] (default: on)"
}
},
})
}
// BeforeToolCall builds the permission hook that gates side-effect tools
// (bash/write/edit) on the cwd's trust decision. In a trusted directory the
// call is allowed (nil). Otherwise the user is prompted; "always" grants
// session trust so subsequent side-effect calls skip the prompt. mgr==nil
// returns nil (trust disabled, no gating).
//
// Concurrency: bash/write/edit are all ToolExecutionSequential, so the batch
// runs serially and this hook fires on the run-loop producer goroutine - never
// concurrently with itself. mu serializes prompts anyway as cheap insurance
// should a side-effect tool ever become parallel; it is nil-safe (callers that
// wire trust without a mutex simply get no serialization). The prompt writes to
// out from the producer goroutine; this is safe because streamRun uses an
// unbuffered event stream (EventBuffer=0, the default), so by the time the hook
// runs the main goroutine has already drained the assistant's streamed text and
// is idle in DrainStream. Do not raise EventBuffer above 0 while trust gating
// is wired without routing the prompt through the main goroutine.
//
// SIGINT caveat: a Ctrl+C that arrives while the prompt is blocked reading
// stdin cancels the run context but does NOT unblock the read, so the
// interrupt takes effect only after the user answers the prompt. This is safe -
// a post-cancel "yes" still aborts: executeToolCall's emit of
// ToolExecutionStartEvent returns ctx.Err() and the tool never runs. A fix that
// unblocks the read on signal would require injecting input on SIGINT, which is
// out of scope for this change.
func BeforeToolCall(mgr *Manager, cwd string, in *bufio.Reader, out io.Writer, mu *sync.Mutex) agentcore.BeforeToolCallFunc {
if mgr == nil {
return nil
}
return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
if !SideEffectTools[call.Name] {
return nil
}
if mu != nil {
mu.Lock()
defer mu.Unlock()
}
// Check under the lock: a prior call's "always" in the same batch may
// have granted session trust, in which case this call skips the prompt.
// (Today batches with side-effect tools run serially, so this is
// belt-and-suspenders.)
if mgr.IsTrusted(cwd) {
return nil
}
allow, always := ConfirmToolCall(out, in, call)
if always {
mgr.SetSessionTrust(cwd)
}
if !allow {
msg := fmt.Sprintf("tool %q blocked: %s is not trusted (use /trust to trust this project)", call.Name, cwd)
return &agentcore.BeforeToolCallDecision{
Block: true,
Content: &agentcore.ContentList{agentcore.NewTextContent(msg)},
}
}
return nil
}
}
// ConfirmToolCall asks whether a side-effect tool call may run in an untrusted
// directory. It returns (allow, always): allow runs the call this once; always
// runs it AND grants session trust so subsequent side-effect calls skip the
// prompt. Denial (no/empty/EOF) returns (false, false).
func ConfirmToolCall(out io.Writer, in *bufio.Reader, call agentcore.AgentToolCall) (allow bool, always bool) {
fmt.Fprintf(out, "\npigo wants to run %q in an untrusted directory.\n", call.Name)
if summary := toolCallSummary(call); summary != "" {
fmt.Fprintf(out, " %s\n", summary)
}
fmt.Fprint(out, "Allow? [y]es / [n]o / [a]lways (trust for this session) [y/N/a]: ")
line, _ := in.ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true, false
case "a", "always":
return true, true
default:
return false, false
}
}
// ToolCallSummary is the exported form of toolCallSummary, used by callers
// outside this package (e.g. the remote-control confirm path) that need the
// same one-line preview a local confirmation prompt shows.
func ToolCallSummary(call agentcore.AgentToolCall) string { return toolCallSummary(call) }
// toolCallSummary renders a one-line preview of what a side-effect tool will
// do, so the user can make an informed allow/deny choice. It best-effort
// extracts the bash command or the write/edit path from the arguments; if the
// arguments do not parse it falls back to a truncated raw view.
func toolCallSummary(call agentcore.AgentToolCall) string {
raw := strings.TrimSpace(string(call.Arguments))
if raw == "" || raw == "{}" {
return ""
}
var args map[string]any
if err := json.Unmarshal(call.Arguments, &args); err != nil {
return truncateForPrompt(raw)
}
switch call.Name {
case "bash":
if cmd, ok := args["command"].(string); ok && cmd != "" {
return "command: " + truncateForPrompt(cmd)
}
case "write", "edit":
if p, ok := args["path"].(string); ok && p != "" {
return "path: " + truncateForPrompt(p)
}
}
return truncateForPrompt(raw)
}
// truncateForPrompt caps a string at maxPromptPreview runes so a confirmation
// prompt stays readable even for large write/edit payloads.
func truncateForPrompt(s string) string {
const maxPromptPreview = 200
r := []rune(s)
if len(r) <= maxPromptPreview {
return s
}
return string(r[:maxPromptPreview]) + " …"
}
+319
View File
@@ -0,0 +1,319 @@
package trust
// Tests for the project-trust interactive glue (US-018, #134): the first-run
// prompt, the tool-call confirmation, the summary preview, and the
// BeforeToolCall gating hook. The trust store itself is covered in
// manager_test.go; here we exercise the interactive pieces.
import (
"bufio"
"bytes"
"context"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/runtime"
)
// newTrustManagerAt builds a Manager backed by path.
func newTrustManagerAt(t *testing.T, path string) *Manager {
t.Helper()
m, err := NewManager(path)
if err != nil {
t.Fatalf("NewManager: %v", err)
}
return m
}
// newTrustManager builds a Manager backed by a fresh temp file and returns the
// manager plus its path (so a test can reload to verify persistence).
func newTrustManager(t *testing.T) (*Manager, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "trust.json")
return newTrustManagerAt(t, path), path
}
// readerOf wraps a string in the *bufio.Reader the trust prompts expect.
func readerOf(s string) *bufio.Reader { return bufio.NewReader(strings.NewReader(s)) }
// TestEnsureTrustPromptTrustedThisDir verifies choice 1 + scope 1 (this
// directory) persists a Trusted decision for cwd.
func TestEnsureTrustPromptTrustedThisDir(t *testing.T) {
mgr, _ := newTrustManager(t)
cwd := t.TempDir()
var out bytes.Buffer
EnsureTrustPrompt(&out, readerOf("1\n1\n"), mgr, cwd)
if got := mgr.NearestTrustDecision(cwd); !got.Found || got.Decision != Trusted || got.Path != cwd {
t.Errorf("after prompt, NearestTrustDecision(%q) = %+v, want Trusted/@cwd", cwd, got)
}
if !strings.Contains(out.String(), "First time in this directory") {
t.Errorf("output missing prompt: %q", out.String())
}
}
// TestEnsureTrustPromptRejectParent verifies choice 3 (reject) + scope 2
// (parent) persists an Untrusted decision for the parent directory.
func TestEnsureTrustPromptRejectParent(t *testing.T) {
mgr, _ := newTrustManager(t)
cwd := t.TempDir()
parent := filepath.Dir(cwd)
EnsureTrustPrompt(&bytes.Buffer{}, readerOf("3\n2\n"), mgr, cwd)
if got := mgr.NearestTrustDecision(cwd); !got.Found || got.Decision != Untrusted || got.Path != parent {
t.Errorf("after reject-parent, NearestTrustDecision(%q) = %+v, want Untrusted/@parent", cwd, got)
}
}
// TestEnsureTrustPromptJustOnce verifies choice 2 (just once) grants session
// trust without persisting: IsTrusted is true now but false after a reload.
func TestEnsureTrustPromptJustOnce(t *testing.T) {
mgr, path := newTrustManager(t)
cwd := t.TempDir()
EnsureTrustPrompt(&bytes.Buffer{}, readerOf("2\n"), mgr, cwd)
if !mgr.IsTrusted(cwd) {
t.Error("IsTrusted(cwd) = false after just-once, want true")
}
// Reload from the same file: session trust must not survive.
m2 := newTrustManagerAt(t, path)
if m2.IsTrusted(cwd) {
t.Error("reload IsTrusted(cwd) = true, want false (session trust must not persist)")
}
}
// TestEnsureTrustPromptSkipsWhenDecided verifies that when a decision already
// exists, EnsureTrustPrompt is a no-op: it writes nothing and reads nothing.
func TestEnsureTrustPromptSkipsWhenDecided(t *testing.T) {
mgr, _ := newTrustManager(t)
cwd := t.TempDir()
if err := mgr.SetDecision(cwd, Trusted); err != nil {
t.Fatalf("SetDecision: %v", err)
}
var out bytes.Buffer
// Empty reader: if the prompt tried to read it would block/EOF; it must not.
EnsureTrustPrompt(&out, readerOf(""), mgr, cwd)
if out.Len() != 0 {
t.Errorf("EnsureTrustPrompt wrote %q when a decision existed, want no output", out.String())
}
}
// TestEstablishTrustApprove verifies --approve grants session trust up front
// without prompting: IsTrusted is true immediately, nothing is written, and the
// (empty) reader is never consumed. Session trust must not persist across a
// reload — --approve is per-run, not a saved decision.
func TestEstablishTrustApprove(t *testing.T) {
mgr, path := newTrustManager(t)
cwd := t.TempDir()
var out bytes.Buffer
EstablishTrust(&out, readerOf(""), mgr, cwd, true)
if !mgr.IsTrusted(cwd) {
t.Error("IsTrusted(cwd) = false after --approve, want true")
}
if out.Len() != 0 {
t.Errorf("EstablishTrust wrote %q with --approve, want no prompt", out.String())
}
m2 := newTrustManagerAt(t, path)
if m2.IsTrusted(cwd) {
t.Error("reload IsTrusted(cwd) = true, want false (--approve must not persist)")
}
}
// TestEstablishTrustWithoutApprove verifies that without --approve, EstablishTrust
// defers to the first-launch prompt (which runs for an undecided directory).
func TestEstablishTrustWithoutApprove(t *testing.T) {
mgr, _ := newTrustManager(t)
cwd := t.TempDir()
var out bytes.Buffer
// "2\n" answers the just-once prompt; if the prompt did not run, this input
// would be left unread and IsTrusted would stay false.
EstablishTrust(&out, readerOf("2\n"), mgr, cwd, false)
if !strings.Contains(out.String(), "First time in this directory") {
t.Errorf("without --approve, expected the trust prompt; got %q", out.String())
}
if !mgr.IsTrusted(cwd) {
t.Error("IsTrusted(cwd) = false after just-once via EstablishTrust, want true")
}
}
// TestConfirmToolCall verifies the y/n/a responses.
func TestConfirmToolCall(t *testing.T) {
call := agentcore.AgentToolCall{Name: "bash", Arguments: []byte(`{"command":"ls"}`)}
cases := []struct {
in string
allow bool
always bool
}{
{"y\n", true, false},
{"yes\n", true, false},
{"a\n", true, true},
{"always\n", true, true},
{"n\n", false, false},
{"\n", false, false},
{"garbage\n", false, false},
}
for _, c := range cases {
var out bytes.Buffer
allow, always := ConfirmToolCall(&out, readerOf(c.in), call)
if allow != c.allow || always != c.always {
t.Errorf("ConfirmToolCall(%q) = (%v,%v), want (%v,%v)", c.in, allow, always, c.allow, c.always)
}
}
}
// TestToolCallSummary verifies the one-line preview for each side-effect tool.
func TestToolCallSummary(t *testing.T) {
cases := []struct {
name string
args string
want string
}{
{"bash", `{"command":"rm -rf /tmp/x"}`, "command: rm -rf /tmp/x"},
{"write", `{"path":"/a/b.txt","content":"..."}`, "path: /a/b.txt"},
{"edit", `{"path":"/a/b.txt","old_string":"x"}`, "path: /a/b.txt"},
{"bash", `{}`, ""},
{"bash", ``, ""},
{"bash", `not-json`, "not-json"},
}
for _, c := range cases {
got := toolCallSummary(agentcore.AgentToolCall{Name: c.name, Arguments: []byte(c.args)})
if got != c.want {
t.Errorf("toolCallSummary(%s,%s) = %q, want %q", c.name, c.args, got, c.want)
}
}
}
// TestToolCallSummaryTruncates verifies a very long command is truncated.
func TestToolCallSummaryTruncates(t *testing.T) {
long := strings.Repeat("x", 500)
got := toolCallSummary(agentcore.AgentToolCall{Name: "bash", Arguments: []byte(`{"command":"` + long + `"}`)})
if !strings.HasSuffix(got, " …") {
t.Errorf("expected truncated output ending with ' …', got %q", got)
}
if len([]rune(got)) > 250 {
t.Errorf("output not truncated: len=%d", len([]rune(got)))
}
}
// TestTrustBeforeToolCallGating verifies the hook: nil manager -> nil hook;
// trusted dir -> allow; untrusted + deny -> block; untrusted + always -> allow
// and grant session trust so the next call is allowed without a prompt.
func TestTrustBeforeToolCallGating(t *testing.T) {
cwd := t.TempDir()
mu := &sync.Mutex{}
call := agentcore.AgentToolCall{Name: "write", Arguments: []byte(`{"path":"/tmp/x"}`)}
// nil manager -> no hook.
if h := BeforeToolCall(nil, cwd, nil, nil, mu); h != nil {
t.Error("nil manager should yield nil hook")
}
// Trusted dir: hook returns nil (allow) without prompting.
mgr, _ := newTrustManager(t)
if err := mgr.SetDecision(cwd, Trusted); err != nil {
t.Fatalf("SetDecision: %v", err)
}
hook := BeforeToolCall(mgr, cwd, readerOf(""), &bytes.Buffer{}, mu)
if dec := hook(context.Background(), call); dec != nil {
t.Errorf("trusted dir: hook returned %+v, want nil", dec)
}
// Untrusted dir, user denies: hook blocks with an error result.
mgr2, _ := newTrustManager(t)
var out bytes.Buffer
hook2 := BeforeToolCall(mgr2, cwd, readerOf("n\n"), &out, mu)
dec := hook2(context.Background(), call)
if dec == nil || !dec.Block {
t.Errorf("untrusted + deny: hook returned %+v, want a Block decision", dec)
}
// Untrusted dir, user says "always": hook allows and grants session trust,
// so a second call is allowed WITHOUT reading any more input.
mgr3, _ := newTrustManager(t)
var out3 bytes.Buffer
// Only one line of input ("a\n"); a second prompt would block on EOF.
hook3 := BeforeToolCall(mgr3, cwd, readerOf("a\n"), &out3, mu)
if dec := hook3(context.Background(), call); dec != nil {
t.Errorf("untrusted + always: hook returned %+v, want nil (allow)", dec)
}
if !mgr3.IsTrusted(cwd) {
t.Error("after 'always', IsTrusted(cwd) = false, want true (session trust granted)")
}
// Second call: no input left, but session trust means no prompt.
if dec := hook3(context.Background(), call); dec != nil {
t.Errorf("second call after 'always': hook returned %+v, want nil (no re-prompt)", dec)
}
}
// TestTrustBeforeToolCallSkipsNonSideEffect verifies read-only tools are never
// gated, even in an untrusted directory.
func TestTrustBeforeToolCallSkipsNonSideEffect(t *testing.T) {
cwd := t.TempDir()
mgr, _ := newTrustManager(t)
mu := &sync.Mutex{}
hook := BeforeToolCall(mgr, cwd, readerOf(""), &bytes.Buffer{}, mu)
for _, name := range []string{"read", "grep", "find", "todo", "webfetch"} {
if dec := hook(context.Background(), agentcore.AgentToolCall{Name: name}); dec != nil {
t.Errorf("non-side-effect tool %q was gated (%+v), want nil", name, dec)
}
}
}
// TestRegisterTrustCommand exercises the /trust action closure: status on an
// undecided dir, on (default) persists Trusted, once grants session trust, off
// revokes session trust AND persists Untrusted (the M2 regression - without
// ClearSessionTrust, an active "always"/once grant would keep IsTrusted true
// until restart), and an unknown arg yields usage.
func TestRegisterTrustCommand(t *testing.T) {
cwd := t.TempDir()
mgr, _ := newTrustManager(t)
reg := runtime.NewSlashRegistry()
RegisterCommand(reg, mgr, cwd)
cmd, ok := reg.Lookup("trust")
if !ok {
t.Fatal("/trust command not registered")
}
if got := cmd.Action("status"); !strings.Contains(got, "undecided") {
t.Errorf("status on undecided dir = %q, want 'undecided'", got)
}
// on (default arg) persists Trusted for cwd.
if got := cmd.Action(""); !strings.Contains(got, "trusted") {
t.Errorf("on = %q, want 'trusted'", got)
}
if res := mgr.NearestTrustDecision(cwd); !res.Found || res.Decision != Trusted {
t.Errorf("after on, nearest = %+v, want Trusted/@cwd", res)
}
// Fresh dir: once grants session trust (in-memory), off must revoke it and
// persist Untrusted so IsTrusted is false immediately.
cwd2 := t.TempDir()
mgr2, _ := newTrustManager(t)
reg2 := runtime.NewSlashRegistry()
RegisterCommand(reg2, mgr2, cwd2)
cmd2, _ := reg2.Lookup("trust")
cmd2.Action("once")
if !mgr2.IsTrusted(cwd2) {
t.Error("after once, IsTrusted = false, want true")
}
if got := cmd2.Action("off"); !strings.Contains(got, "untrusted") {
t.Errorf("off = %q, want 'untrusted'", got)
}
if mgr2.IsTrusted(cwd2) {
t.Error("after off, IsTrusted = true, want false (session grant must be revoked)")
}
if res := mgr2.NearestTrustDecision(cwd2); !res.Found || res.Decision != Untrusted {
t.Errorf("after off, nearest = %+v, want Untrusted/@cwd", res)
}
// Unknown arg yields usage text.
if got := cmd.Action("bogus"); !strings.Contains(got, "usage") {
t.Errorf("unknown arg = %q, want 'usage'", got)
}
}
+303
View File
@@ -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
}
+338
View File
@@ -0,0 +1,338 @@
package trust
// Tests for the trust store (US-018, #134). The store is a JSON map of
// directory path to a nullable boolean (true/false/null); these tests pin the
// tri-state semantics, the nearest-ancestor walk, session-vs-persisted trust,
// and the on-disk round-trip including the null value.
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
)
// newTestManager builds a Manager backed by a temp file, failing the test if
// construction fails. Every test starts from an empty store.
func newTestManager(t *testing.T) *Manager {
t.Helper()
m, err := NewManager(filepath.Join(t.TempDir(), "trust.json"))
if err != nil {
t.Fatalf("NewManager: %v", err)
}
return m
}
// reload reopens the manager at the same path, asserting no error, so a test
// can verify a decision survived a write.
func reload(t *testing.T, m *Manager) *Manager {
t.Helper()
m2, err := NewManager(m.path)
if err != nil {
t.Fatalf("reload NewManager: %v", err)
}
return m2
}
// TestNewManagerMissingFile verifies a missing trust file is not an error: the
// manager starts empty and the nearest lookup reports nothing found.
func TestNewManagerMissingFile(t *testing.T) {
m := newTestManager(t)
if res := m.NearestTrustDecision("/some/dir"); res.Found {
t.Errorf("NearestTrustDecision on empty store: Found=true, want false")
}
}
// TestNewManagerEmptyPath verifies an empty path disables persistence: Set and
// Forget are no-ops on disk and never error, and lookups still work in-memory.
func TestNewManagerEmptyPath(t *testing.T) {
m, err := NewManager("")
if err != nil {
t.Fatalf("NewManager(\"\"): %v", err)
}
if err := m.SetDecision("/a", Trusted); err != nil {
t.Fatalf("SetDecision on empty-path manager: %v", err)
}
if !m.IsTrusted("/a") {
t.Error("IsTrusted(/a) = false after in-memory SetDecision, want true")
}
}
// TestSetDecisionPersists verifies Trusted/Untrusted round-trip through disk:
// after SetDecision + reload, the nearest decision matches what was written.
func TestSetDecisionPersists(t *testing.T) {
m := newTestManager(t)
if err := m.SetDecision("/a", Trusted); err != nil {
t.Fatalf("SetDecision Trusted: %v", err)
}
if err := m.SetDecision("/b", Untrusted); err != nil {
t.Fatalf("SetDecision Untrusted: %v", err)
}
m2 := reload(t, m)
if got := m2.NearestTrustDecision("/a"); !got.Found || got.Decision != Trusted || got.Path != "/a" {
t.Errorf("reload NearestTrustDecision(/a) = %+v, want Found/Trusted//a", got)
}
if got := m2.NearestTrustDecision("/b"); !got.Found || got.Decision != Untrusted || got.Path != "/b" {
t.Errorf("reload NearestTrustDecision(/b) = %+v, want Found/Untrusted//b", got)
}
}
// TestNearestAncestorWalk verifies the lookup walks up from cwd to root and
// returns the nearest ancestor (inclusive) with an entry: a decision saved for
// /a applies to /a/b/c, and the returned Path is the directory it was saved
// for, not the query directory.
func TestNearestAncestorWalk(t *testing.T) {
m := newTestManager(t)
if err := m.SetDecision("/a", Trusted); err != nil {
t.Fatalf("SetDecision: %v", err)
}
got := m.NearestTrustDecision("/a/b/c")
if !got.Found || got.Decision != Trusted || got.Path != "/a" {
t.Errorf("NearestTrustDecision(/a/b/c) = %+v, want Found/Trusted/Path=/a", got)
}
// A more specific entry shadows a broader one: /a/b/untrusted wins over
// /a/trusted for anything under /a/b.
if err := m.SetDecision("/a/b", Untrusted); err != nil {
t.Fatalf("SetDecision /a/b: %v", err)
}
got = m.NearestTrustDecision("/a/b/c")
if !got.Found || got.Decision != Untrusted || got.Path != "/a/b" {
t.Errorf("NearestTrustDecision(/a/b/c) after shadow = %+v, want Found/Untrusted/Path=/a/b", got)
}
// /a/d is under /a but not /a/b, so it still sees /a/trusted.
got = m.NearestTrustDecision("/a/d")
if !got.Found || got.Decision != Trusted || got.Path != "/a" {
t.Errorf("NearestTrustDecision(/a/d) = %+v, want Found/Trusted/Path=/a", got)
}
}
// TestNearestNotFound verifies a query with no entry on the path returns
// Found=false and Undecided.
func TestNearestNotFound(t *testing.T) {
m := newTestManager(t)
if err := m.SetDecision("/a", Trusted); err != nil {
t.Fatalf("SetDecision: %v", err)
}
if got := m.NearestTrustDecision("/completely/unrelated"); got.Found {
t.Errorf("NearestTrustDecision(unrelated) = %+v, want Found=false", got)
}
}
// TestNullEntryRoundTrip verifies the "null" half of the "path -> bool|null"
// schema: SetDecision(Undecided) writes an explicit JSON null, which reloads as
// Found=true with Decision Undecided (recorded but not trusted).
func TestNullEntryRoundTrip(t *testing.T) {
m := newTestManager(t)
if err := m.SetDecision("/a", Undecided); err != nil {
t.Fatalf("SetDecision Undecided: %v", err)
}
// The on-disk value really is null, not omitted.
raw, err := os.ReadFile(m.path)
if err != nil {
t.Fatalf("read trust file: %v", err)
}
var got map[string]*bool
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("parse trust file: %v", err)
}
v, ok := got["/a"]
if !ok {
t.Fatal("entry /a missing from trust file")
}
if v != nil {
t.Errorf("stored value = %v, want nil (JSON null)", *v)
}
// Reload: Found=true (an entry exists), Decision=Undecided (it is null).
m2 := reload(t, m)
res := m2.NearestTrustDecision("/a")
if !res.Found || res.Decision != Undecided {
t.Errorf("reload NearestTrustDecision(/a) = %+v, want Found=true/Undecided", res)
}
}
// TestIsTrusted verifies the gating predicate: true only for Trusted (persisted
// or session), false for Untrusted/Undecided/absent, and that an ancestor
// Trusted decision covers a descendant.
func TestIsTrusted(t *testing.T) {
m := newTestManager(t)
if err := m.SetDecision("/trusted", Trusted); err != nil {
t.Fatalf("SetDecision Trusted: %v", err)
}
if err := m.SetDecision("/untrusted", Untrusted); err != nil {
t.Fatalf("SetDecision Untrusted: %v", err)
}
cases := []struct {
cwd string
want bool
}{
{"/trusted", true}, // exact trusted
{"/trusted/sub/deep", true}, // ancestor trusted
{"/untrusted", false}, // exact untrusted
{"/untrusted/sub", false}, // ancestor untrusted
{"/undecided", false}, // no entry
{"/", false}, // root, no entry
}
for _, c := range cases {
if got := m.IsTrusted(c.cwd); got != c.want {
t.Errorf("IsTrusted(%q) = %v, want %v", c.cwd, got, c.want)
}
}
}
// TestSessionTrustNotPersisted verifies SetSessionTrust grants trust for the
// current process but does not survive a reload (matching the "just once" REPL
// choice): a fresh manager over the same file does not see the grant.
func TestSessionTrustNotPersisted(t *testing.T) {
m := newTestManager(t)
m.SetSessionTrust("/proj")
if !m.IsTrusted("/proj") {
t.Error("IsTrusted(/proj) = false after SetSessionTrust, want true")
}
if !m.IsTrusted("/proj/sub") {
t.Error("IsTrusted(/proj/sub) = false, want true (session trust covers descendants)")
}
m2 := reload(t, m)
if m2.IsTrusted("/proj") {
t.Error("reload IsTrusted(/proj) = true, want false (session trust must not persist)")
}
}
// TestClearSessionTrust verifies ClearSessionTrust revokes a session grant so
// IsTrusted reflects only the persisted decision, including grants on an
// ancestor (walkUp). This is the contract "/trust off" relies on to take effect
// immediately rather than only after a restart.
func TestClearSessionTrust(t *testing.T) {
m := newTestManager(t)
m.SetSessionTrust("/proj")
if !m.IsTrusted("/proj") {
t.Fatal("IsTrusted = false after SetSessionTrust, want true")
}
m.ClearSessionTrust("/proj")
if m.IsTrusted("/proj") {
t.Error("IsTrusted = true after ClearSessionTrust, want false")
}
// Clearing a descendant also revokes an ancestor's session grant, since
// ClearSessionTrust walks up (matching IsTrusted's walkUp check).
m.SetSessionTrust("/a")
m.ClearSessionTrust("/a/b")
if m.IsTrusted("/a/b") {
t.Error("IsTrusted(/a/b) = true after ClearSessionTrust(/a/b), want false (ancestor /a grant revoked)")
}
}
// TestForget verifies Forget removes an entry so the directory becomes
// undecided again, both in-memory and after reload.
func TestForget(t *testing.T) {
m := newTestManager(t)
if err := m.SetDecision("/a", Trusted); err != nil {
t.Fatalf("SetDecision: %v", err)
}
if err := m.Forget("/a"); err != nil {
t.Fatalf("Forget: %v", err)
}
if got := m.NearestTrustDecision("/a"); got.Found {
t.Errorf("NearestTrustDecision after Forget = %+v, want Found=false", got)
}
// Forget is idempotent: forgetting a path with no entry is not an error.
if err := m.Forget("/a"); err != nil {
t.Errorf("Forget missing entry: %v", err)
}
}
// TestDecisionForExactPath verifies the exact-path lookup (no walk) returns the
// stored decision and a Found flag, distinct from the walk-based nearest.
func TestDecisionForExactPath(t *testing.T) {
m := newTestManager(t)
if err := m.SetDecision("/a", Trusted); err != nil {
t.Fatalf("SetDecision: %v", err)
}
if dec, found := m.DecisionFor("/a"); !found || dec != Trusted {
t.Errorf("DecisionFor(/a) = %v,%v, want Trusted,true", dec, found)
}
if dec, found := m.DecisionFor("/a/b"); found {
t.Errorf("DecisionFor(/a/b) = %v,%v, want _,false (no exact entry)", dec, found)
}
}
// TestDefaultPath verifies DefaultPath honors PIGO_HOME and falls back to
// ~/.pigo/trust.json.
func TestDefaultPath(t *testing.T) {
t.Setenv("PIGO_HOME", "/custom/pigo")
if got := DefaultPath(); got != "/custom/pigo/trust.json" {
t.Errorf("DefaultPath with PIGO_HOME = %q, want /custom/pigo/trust.json", got)
}
t.Setenv("PIGO_HOME", "")
home, err := os.UserHomeDir()
if err != nil {
t.Skip("home dir unavailable")
}
want := filepath.Join(home, ".pigo", "trust.json")
if got := DefaultPath(); got != want {
t.Errorf("DefaultPath default = %q, want %q", got, want)
}
}
// TestSaveIsSorted verifies the written file has sorted keys (stable, diffable
// output) and is a valid JSON object.
func TestSaveIsSorted(t *testing.T) {
m := newTestManager(t)
for _, p := range []string{"/zeta", "/alpha", "/mid"} {
if err := m.SetDecision(p, Trusted); err != nil {
t.Fatalf("SetDecision %s: %v", p, err)
}
}
raw, err := os.ReadFile(m.path)
if err != nil {
t.Fatalf("read: %v", err)
}
s := string(raw)
i := strings.Index(s, "/alpha")
j := strings.Index(s, "/mid")
k := strings.Index(s, "/zeta")
if i < 0 || j < 0 || k < 0 {
t.Fatalf("expected /alpha, /mid, /zeta in output; got indices %d/%d/%d", i, j, k)
}
if !(i < j && j < k) {
t.Errorf("keys not sorted in output: alpha@%d mid@%d zeta@%d", i, j, k)
}
var got map[string]*bool
if err := json.Unmarshal(raw, &got); err != nil {
t.Errorf("output is not valid JSON: %v", err)
}
}
// TestMalformedFileIsError verifies a corrupted trust file is a hard error
// rather than being silently overwritten, so the user's data is surfaced.
func TestMalformedFileIsError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "trust.json")
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatalf("write malformed file: %v", err)
}
if _, err := NewManager(path); err == nil {
t.Error("NewManager on malformed file returned nil error, want a parse error")
}
}
// TestConcurrentAccess exercises the mutex under -race: many goroutines reading
// and writing concurrently must not trip the race detector.
func TestConcurrentAccess(t *testing.T) {
m := newTestManager(t)
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
dir := filepath.Join("/p", "d"+strconv.Itoa(i))
_ = m.SetDecision(dir, Trusted)
_ = m.IsTrusted(dir)
_ = m.NearestTrustDecision(dir)
m.SetSessionTrust(dir)
_, _ = m.DecisionFor(dir)
}(i)
}
wg.Wait()
}