first commit
This commit is contained in:
@@ -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]) + " …"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user