// 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]) + " …" }