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]) + " …"
|
||||
}
|
||||
Reference in New Issue
Block a user