Files
2026-08-14 23:41:57 +08:00

306 lines
12 KiB
Go

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