feat(tool-gate): --no-yolo flag, confirm dialog, /yolo runtime toggle
Adds a per-tool-call confirmation gate. Default stays yolo mode
(tools run freely, same as today). Pass --no-yolo to require
explicit user approval before each tool invocation.
Interactive TUI:
A dialog appears before every tool call. Shows the tool name and
a one-line preview of its args (command / path / url / etc.)
with four choices, selectable by arrow keys or numeric shortcut:
1. yes (run this call)
2. yes, always this tool (skip prompts for this tool,
session-scoped)
3. yes, always (skip prompts for every tool,
session-scoped)
4. no (refuse and let the model try
something else)
Esc/ctrl+c refuses the current prompt. Esc during a running turn
both cancels the turn AND drains any pending confirm so the
agent goroutine doesn't deadlock. Multiple pending confirms are
queued and answered one at a time with a count visible in the
header.
Type /yolo to disable the gate for the rest of the session
(equivalent to the "yes, always" choice but without needing a
pending prompt). Any currently-open confirm auto-allows so the
agent keeps moving.
Print / JSON / RPC modes:
No interactive prompt is available, so every tool call is
auto-refused with a reason the model can learn from:
"tool call refused: --no-yolo is active and there is no
interactive prompt in this mode; ask the user what to do
instead". Observed behaviour: the model pivots to asking the
user directly instead of looping on the same tool.
Implementation:
internal/core/confirm.go
- ConfirmDecision, Confirmer interface
- ConfirmGate with session-scoped memory for "always this tool"
and "always everything" decisions, both concurrency-safe
- BuildPreview: turns {"command":"ls"} into "ls", etc.
- Lives in core to avoid a modes -> agent import cycle
internal/core/confirm_test.go
- Tests: nil gate allows, nil-inner refuses with reason, one-
shot allow doesn't remember, remember-tool short-circuits
only same tool, remember-all short-circuits everything,
refusal reasons surface, empty-reason gets a default,
runtime AllowAll works, BuildPreview handles each field
internal/agent/modes/confirm_dialog.go
- Queue-based dialog, HandleKey wiring, CancelAll and
AllowAllPending for the two exit cases
internal/agent/modes/interactive.go
- InteractiveConfig gains NoYolo + ConfirmGate fields
- Interactive implements core.Confirmer via a response channel
- Confirm dialog dispatched FIRST in the key-handler chain so
keys never leak to other dialogs while the agent is blocked
- Esc-while-busy also calls confirmDialog.CancelAll so the
agent unblocks
- /yolo slash command handled in runSlash
internal/agent/cli.go
- Constructs the ConfirmGate when args.NoYolo is set,
BeforeToolExecute calls it first, extensions only see calls
the user already approved
- After iv is built, SetConfirmer(iv) wires the gate's inner
so interactive + gate share the same struct
- wireNoYoloAutoRefuse() for print / json modes
internal/agent/args.go
- --no-yolo flag and help text
internal/agent/modes/slash_suggest.go
- /yolo added to slashCatalog
Verified end-to-end: fresh zot --no-yolo -p "read sample.ts" now
returns "I can't read files in this mode (--no-yolo without an
interactive prompt). How would you like to proceed" instead of
actually reading.- commit
- ac6d556f0a2559342461a2bee5addbf23f75796e
- author
- patriceckhart · · 2026-04-19 17:12 UTC
- parent
- f371687
- signature
- unsigned
- refs
- tag: v0.0.42
8 files · +748 -1
M README.md +2 -0
| @@ -152,6 +152,7 @@ zot --help | |||
| 152 | 152 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | |
| 153 | 153 | | `--with-skills` | Also load user-installed skills. Without this, only the built-in skills shipped in the binary are loaded. | | |
| 154 | 154 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | |
| 155 | + | | `--no-yolo` | Confirm every tool call before it runs. In the TUI, a dialog shows the tool name and a one-line preview of its args with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. In print / json / rpc modes (no interactive prompt) every tool call is auto-refused with a reason the model can learn from. Type `/yolo` in the TUI to disable the gate for the rest of the session. | | |
| 155 | 156 | ||
| 156 | 157 | ## Tools | |
| 157 | 158 | ||
| @@ -196,6 +197,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||
| 196 | 197 | | `/lock` | Confine tools to the current directory. | | |
| 197 | 198 | | `/unlock` | Allow tools to touch paths outside again. | | |
| 198 | 199 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 200 | + | | `/yolo` | Turn off `--no-yolo` confirmation for the rest of this session. | | |
| 199 | 201 | | `/clear` | Clear the chat transcript. | | |
| 200 | 202 | | `/exit` | Exit zot. | | |
| 201 | 203 | ||
| @@ -152,6 +152,7 @@ zot --help | |||||
| 152 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | 152 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | ||
| 153 | | `--with-skills` | Also load user-installed skills. Without this, only the built-in skills shipped in the binary are loaded. | | 153 | | `--with-skills` | Also load user-installed skills. Without this, only the built-in skills shipped in the binary are loaded. | | ||
| 154 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | 154 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | ||
| 155 | + | | `--no-yolo` | Confirm every tool call before it runs. In the TUI, a dialog shows the tool name and a one-line preview of its args with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. In print / json / rpc modes (no interactive prompt) every tool call is auto-refused with a reason the model can learn from. Type `/yolo` in the TUI to disable the gate for the rest of the session. | | |||
| 155 | 156 | ||||
| 156 | ## Tools | 157 | ## Tools | ||
| 157 | 158 | ||||
| @@ -196,6 +197,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||||
| 196 | | `/lock` | Confine tools to the current directory. | | 197 | | `/lock` | Confine tools to the current directory. | | ||
| 197 | | `/unlock` | Allow tools to touch paths outside again. | | 198 | | `/unlock` | Allow tools to touch paths outside again. | | ||
| 198 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 199 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 200 | + | | `/yolo` | Turn off `--no-yolo` confirmation for the rest of this session. | | |||
| 199 | | `/clear` | Clear the chat transcript. | | 201 | | `/clear` | Clear the chat transcript. | | ||
| 200 | | `/exit` | Exit zot. | | 202 | | `/exit` | Exit zot. | | ||
| 201 | 203 | ||||
M internal/agent/args.go +16 -0
| @@ -64,6 +64,16 @@ type Args struct { | |||
| 64 | 64 | // around in the user's home directory. | |
| 65 | 65 | WithSkills bool | |
| 66 | 66 | ||
| 67 | + | // NoYolo turns on per-tool confirmation. Before each tool | |
| 68 | + | // invocation the TUI prompts the user with the tool name + args | |
| 69 | + | // and waits for an explicit yes/no. The user can also pick | |
| 70 | + | // "always for this tool this session" or "always for anything | |
| 71 | + | // this session" to stop being prompted again. Defaults off | |
| 72 | + | // (yolo mode): tools run without asking. In -p / --json / rpc | |
| 73 | + | // modes there's no interactive TUI, so --no-yolo auto-refuses | |
| 74 | + | // every tool call with a reason the model can learn from. | |
| 75 | + | NoYolo bool | |
| 76 | + | ||
| 67 | 77 | ListModels bool | |
| 68 | 78 | Help bool | |
| 69 | 79 | Version bool | |
| @@ -161,6 +171,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 161 | 171 | a.NoSkill = true | |
| 162 | 172 | case "--with-skills", "--with-skill": | |
| 163 | 173 | a.WithSkills = true | |
| 174 | + | case "--no-yolo": | |
| 175 | + | a.NoYolo = true | |
| 164 | 176 | case "--reasoning": | |
| 165 | 177 | v, err := want(&i, arg) | |
| 166 | 178 | if err != nil { | |
| @@ -274,6 +286,10 @@ flags: | |||
| 274 | 286 | .claude/skills/ + .agents/skills/. | |
| 275 | 287 | default: only built-in skills load | |
| 276 | 288 | ||
| 289 | + | --no-yolo ask before running every tool call | |
| 290 | + | (interactive mode only; in -p / --json | |
| 291 | + | / rpc this refuses every tool call) | |
| 292 | + | ||
| 277 | 293 | --max-steps N agent loop iteration cap (default 50) | |
| 278 | 294 | --list-models print known models and exit | |
| 279 | 295 | -h, --help this message | |
| @@ -64,6 +64,16 @@ type Args struct { | |||||
| 64 | // around in the user's home directory. | 64 | // around in the user's home directory. | ||
| 65 | WithSkills bool | 65 | WithSkills bool | ||
| 66 | 66 | ||||
| 67 | + | // NoYolo turns on per-tool confirmation. Before each tool | |||
| 68 | + | // invocation the TUI prompts the user with the tool name + args | |||
| 69 | + | // and waits for an explicit yes/no. The user can also pick | |||
| 70 | + | // "always for this tool this session" or "always for anything | |||
| 71 | + | // this session" to stop being prompted again. Defaults off | |||
| 72 | + | // (yolo mode): tools run without asking. In -p / --json / rpc | |||
| 73 | + | // modes there's no interactive TUI, so --no-yolo auto-refuses | |||
| 74 | + | // every tool call with a reason the model can learn from. | |||
| 75 | + | NoYolo bool | |||
| 76 | + | ||||
| 67 | ListModels bool | 77 | ListModels bool | ||
| 68 | Help bool | 78 | Help bool | ||
| 69 | Version bool | 79 | Version bool | ||
| @@ -161,6 +171,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 161 | a.NoSkill = true | 171 | a.NoSkill = true | ||
| 162 | case "--with-skills", "--with-skill": | 172 | case "--with-skills", "--with-skill": | ||
| 163 | a.WithSkills = true | 173 | a.WithSkills = true | ||
| 174 | + | case "--no-yolo": | |||
| 175 | + | a.NoYolo = true | |||
| 164 | case "--reasoning": | 176 | case "--reasoning": | ||
| 165 | v, err := want(&i, arg) | 177 | v, err := want(&i, arg) | ||
| 166 | if err != nil { | 178 | if err != nil { | ||
| @@ -274,6 +286,10 @@ flags: | |||||
| 274 | .claude/skills/ + .agents/skills/. | 286 | .claude/skills/ + .agents/skills/. | ||
| 275 | default: only built-in skills load | 287 | default: only built-in skills load | ||
| 276 | 288 | ||||
| 289 | + | --no-yolo ask before running every tool call | |||
| 290 | + | (interactive mode only; in -p / --json | |||
| 291 | + | / rpc this refuses every tool call) | |||
| 292 | + | ||||
| 277 | --max-steps N agent loop iteration cap (default 50) | 293 | --max-steps N agent loop iteration cap (default 50) | ||
| 278 | --list-models print known models and exit | 294 | --list-models print known models and exit | ||
| 279 | -h, --help this message | 295 | -h, --help this message | ||
M internal/agent/cli.go +53 -0
| @@ -189,6 +189,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 189 | 189 | return err | |
| 190 | 190 | } | |
| 191 | 191 | ag := r.NewAgent() | |
| 192 | + | wireNoYoloAutoRefuse(ag, args) | |
| 192 | 193 | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 193 | 194 | defer sess.Close() | |
| 194 | 195 | ||
| @@ -207,12 +208,36 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 207 | 208 | return err | |
| 208 | 209 | } | |
| 209 | 210 | ||
| 211 | + | // wireNoYoloAutoRefuse installs a BeforeToolExecute hook that | |
| 212 | + | // refuses every tool call when --no-yolo is active but there's no | |
| 213 | + | // interactive UI to prompt (print / json / rpc modes). The reason | |
| 214 | + | // is written in a way the model can learn from so it proposes a | |
| 215 | + | // different action rather than looping on the same tool. | |
| 216 | + | func wireNoYoloAutoRefuse(ag *core.Agent, args Args) { | |
| 217 | + | if !args.NoYolo || ag == nil { | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | gate := core.NewConfirmGate(nil) // nil inner = auto-refuse | |
| 221 | + | prev := ag.BeforeToolExecute | |
| 222 | + | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 223 | + | ok, reason, _ := gate.Check(call.Name, core.BuildPreview(call.Arguments, 120)) | |
| 224 | + | if !ok { | |
| 225 | + | return false, reason, nil | |
| 226 | + | } | |
| 227 | + | if prev != nil { | |
| 228 | + | return prev(call) | |
| 229 | + | } | |
| 230 | + | return true, "", nil | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 210 | 234 | func runJSONMode(ctx context.Context, args Args, version string) error { | |
| 211 | 235 | r, err := Resolve(args, true) | |
| 212 | 236 | if err != nil { | |
| 213 | 237 | return err | |
| 214 | 238 | } | |
| 215 | 239 | ag := r.NewAgent() | |
| 240 | + | wireNoYoloAutoRefuse(ag, args) | |
| 216 | 241 | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 217 | 242 | defer sess.Close() | |
| 218 | 243 | ||
| @@ -283,6 +308,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 283 | 308 | extToolAdapter := &extToolAdapter{mgr: extMgr} | |
| 284 | 309 | r.MergeExtensionTools(extToolAdapter) | |
| 285 | 310 | ||
| 311 | + | // Confirmation gate: when --no-yolo is on, the agent must ask | |
| 312 | + | // the user before every tool call. In interactive mode the TUI | |
| 313 | + | // provides the Confirmer; in print/json/rpc modes there's no | |
| 314 | + | // way to prompt, so the gate is constructed with a nil inner | |
| 315 | + | // which auto-refuses every call with a helpful reason. | |
| 316 | + | var confirmGate *core.ConfirmGate | |
| 317 | + | if args.NoYolo { | |
| 318 | + | confirmGate = core.NewConfirmGate(nil) // set below for interactive | |
| 319 | + | } | |
| 320 | + | ||
| 286 | 321 | // Capture current args in a closure so BuildAgent can re-resolve | |
| 287 | 322 | // after a successful login (picks up the newly stored credential). | |
| 288 | 323 | wireAgentExt := func(a *core.Agent) *core.Agent { | |
| @@ -290,6 +325,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 290 | 325 | return a | |
| 291 | 326 | } | |
| 292 | 327 | a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |
| 328 | + | // Confirm gate runs FIRST: if the user refused, we don't | |
| 329 | + | // waste extension-intercept time or let guards see the call. | |
| 330 | + | if confirmGate != nil { | |
| 331 | + | ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120)) | |
| 332 | + | if !ok { | |
| 333 | + | return false, reason, nil | |
| 334 | + | } | |
| 335 | + | } | |
| 293 | 336 | r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | |
| 294 | 337 | if r.Block { | |
| 295 | 338 | return false, r.Reason, nil | |
| @@ -492,6 +535,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 492 | 535 | list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills) | |
| 493 | 536 | return skills.VisibleSkills(list) | |
| 494 | 537 | }, | |
| 538 | + | NoYolo: args.NoYolo, | |
| 539 | + | ConfirmGate: confirmGate, | |
| 495 | 540 | PersistModel: func(providerName, model string) { | |
| 496 | 541 | // Update config.json so next launch uses the same pick. | |
| 497 | 542 | cfg, _ := LoadConfig() | |
| @@ -505,6 +550,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 505 | 550 | }, | |
| 506 | 551 | }) | |
| 507 | 552 | ||
| 553 | + | // Bind the interactive TUI as the Confirmer. We deferred this | |
| 554 | + | // until now because the gate is constructed before the TUI | |
| 555 | + | // (the BeforeToolExecute closure captures it). SetConfirmer | |
| 556 | + | // is mutex-guarded on the gate so this is safe. | |
| 557 | + | if confirmGate != nil { | |
| 558 | + | confirmGate.SetConfirmer(iv) | |
| 559 | + | } | |
| 560 | + | ||
| 508 | 561 | runErr := iv.Run(ctx) | |
| 509 | 562 | ||
| 510 | 563 | // Flush final transcript to session (only if we had / ended up with an agent). | |
| @@ -189,6 +189,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||||
| 189 | return err | 189 | return err | ||
| 190 | } | 190 | } | ||
| 191 | ag := r.NewAgent() | 191 | ag := r.NewAgent() | ||
| 192 | + | wireNoYoloAutoRefuse(ag, args) | |||
| 192 | sess, _ := openOrCreateSession(args, r, ag, version) | 193 | sess, _ := openOrCreateSession(args, r, ag, version) | ||
| 193 | defer sess.Close() | 194 | defer sess.Close() | ||
| 194 | 195 | ||||
| @@ -207,12 +208,36 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||||
| 207 | return err | 208 | return err | ||
| 208 | } | 209 | } | ||
| 209 | 210 | ||||
| 211 | + | // wireNoYoloAutoRefuse installs a BeforeToolExecute hook that | |||
| 212 | + | // refuses every tool call when --no-yolo is active but there's no | |||
| 213 | + | // interactive UI to prompt (print / json / rpc modes). The reason | |||
| 214 | + | // is written in a way the model can learn from so it proposes a | |||
| 215 | + | // different action rather than looping on the same tool. | |||
| 216 | + | func wireNoYoloAutoRefuse(ag *core.Agent, args Args) { | |||
| 217 | + | if !args.NoYolo || ag == nil { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | gate := core.NewConfirmGate(nil) // nil inner = auto-refuse | |||
| 221 | + | prev := ag.BeforeToolExecute | |||
| 222 | + | ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 223 | + | ok, reason, _ := gate.Check(call.Name, core.BuildPreview(call.Arguments, 120)) | |||
| 224 | + | if !ok { | |||
| 225 | + | return false, reason, nil | |||
| 226 | + | } | |||
| 227 | + | if prev != nil { | |||
| 228 | + | return prev(call) | |||
| 229 | + | } | |||
| 230 | + | return true, "", nil | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 210 | func runJSONMode(ctx context.Context, args Args, version string) error { | 234 | func runJSONMode(ctx context.Context, args Args, version string) error { | ||
| 211 | r, err := Resolve(args, true) | 235 | r, err := Resolve(args, true) | ||
| 212 | if err != nil { | 236 | if err != nil { | ||
| 213 | return err | 237 | return err | ||
| 214 | } | 238 | } | ||
| 215 | ag := r.NewAgent() | 239 | ag := r.NewAgent() | ||
| 240 | + | wireNoYoloAutoRefuse(ag, args) | |||
| 216 | sess, _ := openOrCreateSession(args, r, ag, version) | 241 | sess, _ := openOrCreateSession(args, r, ag, version) | ||
| 217 | defer sess.Close() | 242 | defer sess.Close() | ||
| 218 | 243 | ||||
| @@ -283,6 +308,16 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 283 | extToolAdapter := &extToolAdapter{mgr: extMgr} | 308 | extToolAdapter := &extToolAdapter{mgr: extMgr} | ||
| 284 | r.MergeExtensionTools(extToolAdapter) | 309 | r.MergeExtensionTools(extToolAdapter) | ||
| 285 | 310 | ||||
| 311 | + | // Confirmation gate: when --no-yolo is on, the agent must ask | |||
| 312 | + | // the user before every tool call. In interactive mode the TUI | |||
| 313 | + | // provides the Confirmer; in print/json/rpc modes there's no | |||
| 314 | + | // way to prompt, so the gate is constructed with a nil inner | |||
| 315 | + | // which auto-refuses every call with a helpful reason. | |||
| 316 | + | var confirmGate *core.ConfirmGate | |||
| 317 | + | if args.NoYolo { | |||
| 318 | + | confirmGate = core.NewConfirmGate(nil) // set below for interactive | |||
| 319 | + | } | |||
| 320 | + | ||||
| 286 | // Capture current args in a closure so BuildAgent can re-resolve | 321 | // Capture current args in a closure so BuildAgent can re-resolve | ||
| 287 | // after a successful login (picks up the newly stored credential). | 322 | // after a successful login (picks up the newly stored credential). | ||
| 288 | wireAgentExt := func(a *core.Agent) *core.Agent { | 323 | wireAgentExt := func(a *core.Agent) *core.Agent { | ||
| @@ -290,6 +325,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 290 | return a | 325 | return a | ||
| 291 | } | 326 | } | ||
| 292 | a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | 327 | a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) { | ||
| 328 | + | // Confirm gate runs FIRST: if the user refused, we don't | |||
| 329 | + | // waste extension-intercept time or let guards see the call. | |||
| 330 | + | if confirmGate != nil { | |||
| 331 | + | ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120)) | |||
| 332 | + | if !ok { | |||
| 333 | + | return false, reason, nil | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 293 | r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | 336 | r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments) | ||
| 294 | if r.Block { | 337 | if r.Block { | ||
| 295 | return false, r.Reason, nil | 338 | return false, r.Reason, nil | ||
| @@ -492,6 +535,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 492 | list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills) | 535 | list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills) | ||
| 493 | return skills.VisibleSkills(list) | 536 | return skills.VisibleSkills(list) | ||
| 494 | }, | 537 | }, | ||
| 538 | + | NoYolo: args.NoYolo, | |||
| 539 | + | ConfirmGate: confirmGate, | |||
| 495 | PersistModel: func(providerName, model string) { | 540 | PersistModel: func(providerName, model string) { | ||
| 496 | // Update config.json so next launch uses the same pick. | 541 | // Update config.json so next launch uses the same pick. | ||
| 497 | cfg, _ := LoadConfig() | 542 | cfg, _ := LoadConfig() | ||
| @@ -505,6 +550,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 505 | }, | 550 | }, | ||
| 506 | }) | 551 | }) | ||
| 507 | 552 | ||||
| 553 | + | // Bind the interactive TUI as the Confirmer. We deferred this | |||
| 554 | + | // until now because the gate is constructed before the TUI | |||
| 555 | + | // (the BeforeToolExecute closure captures it). SetConfirmer | |||
| 556 | + | // is mutex-guarded on the gate so this is safe. | |||
| 557 | + | if confirmGate != nil { | |||
| 558 | + | confirmGate.SetConfirmer(iv) | |||
| 559 | + | } | |||
| 560 | + | ||||
| 508 | runErr := iv.Run(ctx) | 561 | runErr := iv.Run(ctx) | ||
| 509 | 562 | ||||
| 510 | // Flush final transcript to session (only if we had / ended up with an agent). | 563 | // Flush final transcript to session (only if we had / ended up with an agent). | ||
A internal/agent/modes/confirm_dialog.go +233 -0
| @@ -0,0 +1,233 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/internal/core" | |
| 10 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // confirmRequest is one pending confirmation. The agent goroutine | |
| 14 | + | // writes it onto the interactive's queue, the TUI renders the | |
| 15 | + | // dialog, and the user's answer goes back through `resp`. | |
| 16 | + | type confirmRequest struct { | |
| 17 | + | toolName string | |
| 18 | + | preview string | |
| 19 | + | resp chan core.ConfirmDecision | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // confirmDialog is the inline prompt shown before every tool call | |
| 23 | + | // when --no-yolo is on. It presents four options and routes the | |
| 24 | + | // answer back through the pending request's response channel. | |
| 25 | + | type confirmDialog struct { | |
| 26 | + | mu sync.Mutex | |
| 27 | + | pending []*confirmRequest | |
| 28 | + | cursor int | |
| 29 | + | // activeSince timestamps when the current request started | |
| 30 | + | // showing. Used only for debug/future UX (e.g. flashing the | |
| 31 | + | // dialog border after N seconds of no answer). | |
| 32 | + | activeSince time.Time | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func newConfirmDialog() *confirmDialog { return &confirmDialog{} } | |
| 36 | + | ||
| 37 | + | // Enqueue adds a pending request. Multiple requests can queue up | |
| 38 | + | // if the agent produces tool calls in parallel (via extensions); | |
| 39 | + | // the TUI shows them one at a time, in order. | |
| 40 | + | func (d *confirmDialog) Enqueue(req *confirmRequest) { | |
| 41 | + | d.mu.Lock() | |
| 42 | + | defer d.mu.Unlock() | |
| 43 | + | d.pending = append(d.pending, req) | |
| 44 | + | if len(d.pending) == 1 { | |
| 45 | + | d.cursor = 0 | |
| 46 | + | d.activeSince = time.Now() | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Active reports whether the dialog is consuming input (there's at | |
| 51 | + | // least one pending request). | |
| 52 | + | func (d *confirmDialog) Active() bool { | |
| 53 | + | if d == nil { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | d.mu.Lock() | |
| 57 | + | defer d.mu.Unlock() | |
| 58 | + | return len(d.pending) > 0 | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // CancelAll refuses every pending request (used when the user | |
| 62 | + | // cancels the active turn or closes the session while the dialog | |
| 63 | + | // is open). Safe to call when idle. | |
| 64 | + | func (d *confirmDialog) CancelAll(reason string) { | |
| 65 | + | d.mu.Lock() | |
| 66 | + | pending := d.pending | |
| 67 | + | d.pending = nil | |
| 68 | + | d.mu.Unlock() | |
| 69 | + | for _, req := range pending { | |
| 70 | + | select { | |
| 71 | + | case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}: | |
| 72 | + | default: | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // AllowAllPending approves every pending request and drains the | |
| 78 | + | // queue. Used by /yolo so any confirmation dialog already on screen | |
| 79 | + | // resolves immediately as "yes". | |
| 80 | + | func (d *confirmDialog) AllowAllPending() { | |
| 81 | + | d.mu.Lock() | |
| 82 | + | pending := d.pending | |
| 83 | + | d.pending = nil | |
| 84 | + | d.mu.Unlock() | |
| 85 | + | for _, req := range pending { | |
| 86 | + | select { | |
| 87 | + | case req.resp <- core.ConfirmDecision{Allow: true, RememberAll: true}: | |
| 88 | + | default: | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // confirmOptions lists the four responses in vertical order. Keyed | |
| 94 | + | // by index so HandleKey can send the right decision. | |
| 95 | + | var confirmOptions = []struct { | |
| 96 | + | label string | |
| 97 | + | decision core.ConfirmDecision | |
| 98 | + | }{ | |
| 99 | + | { | |
| 100 | + | label: "yes (run this call)", | |
| 101 | + | decision: core.ConfirmDecision{Allow: true}, | |
| 102 | + | }, | |
| 103 | + | { | |
| 104 | + | label: "yes, always this tool (skip prompts for this tool for the rest of the session)", | |
| 105 | + | decision: core.ConfirmDecision{Allow: true, RememberTool: true}, | |
| 106 | + | }, | |
| 107 | + | { | |
| 108 | + | label: "yes, always (skip all prompts for the rest of the session)", | |
| 109 | + | decision: core.ConfirmDecision{Allow: true, RememberAll: true}, | |
| 110 | + | }, | |
| 111 | + | { | |
| 112 | + | label: "no (refuse and let the model try something else)", | |
| 113 | + | decision: core.ConfirmDecision{Allow: false, Reason: "user declined"}, | |
| 114 | + | }, | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // HandleKey advances the selection or resolves the dialog. Returns | |
| 118 | + | // true when a decision was sent back to the agent (the dialog may | |
| 119 | + | // remain Active if there are more queued requests behind it). | |
| 120 | + | func (d *confirmDialog) HandleKey(k tui.Key) bool { | |
| 121 | + | d.mu.Lock() | |
| 122 | + | if len(d.pending) == 0 { | |
| 123 | + | d.mu.Unlock() | |
| 124 | + | return false | |
| 125 | + | } | |
| 126 | + | req := d.pending[0] | |
| 127 | + | ||
| 128 | + | switch k.Kind { | |
| 129 | + | case tui.KeyUp: | |
| 130 | + | if d.cursor > 0 { | |
| 131 | + | d.cursor-- | |
| 132 | + | } | |
| 133 | + | d.mu.Unlock() | |
| 134 | + | return false | |
| 135 | + | case tui.KeyDown: | |
| 136 | + | if d.cursor < len(confirmOptions)-1 { | |
| 137 | + | d.cursor++ | |
| 138 | + | } | |
| 139 | + | d.mu.Unlock() | |
| 140 | + | return false | |
| 141 | + | case tui.KeyEsc, tui.KeyCtrlC: | |
| 142 | + | // Treat esc/ctrl+c as "no, refuse this tool call". The | |
| 143 | + | // active turn can separately be cancelled by the outer esc | |
| 144 | + | // handler, but for the dialog itself, we must always answer | |
| 145 | + | // so the agent goroutine unblocks. | |
| 146 | + | d.pending = d.pending[1:] | |
| 147 | + | if len(d.pending) > 0 { | |
| 148 | + | d.cursor = 0 | |
| 149 | + | d.activeSince = time.Now() | |
| 150 | + | } | |
| 151 | + | d.mu.Unlock() | |
| 152 | + | req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"} | |
| 153 | + | return true | |
| 154 | + | case tui.KeyEnter: | |
| 155 | + | selected := confirmOptions[d.cursor].decision | |
| 156 | + | d.pending = d.pending[1:] | |
| 157 | + | if len(d.pending) > 0 { | |
| 158 | + | d.cursor = 0 | |
| 159 | + | d.activeSince = time.Now() | |
| 160 | + | } | |
| 161 | + | d.mu.Unlock() | |
| 162 | + | req.resp <- selected | |
| 163 | + | return true | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // Numeric shortcuts 1..4 | |
| 167 | + | if k.Kind == tui.KeyRune && k.Rune >= '1' && k.Rune <= '4' { | |
| 168 | + | idx := int(k.Rune - '1') | |
| 169 | + | if idx >= 0 && idx < len(confirmOptions) { | |
| 170 | + | selected := confirmOptions[idx].decision | |
| 171 | + | d.pending = d.pending[1:] | |
| 172 | + | if len(d.pending) > 0 { | |
| 173 | + | d.cursor = 0 | |
| 174 | + | d.activeSince = time.Now() | |
| 175 | + | } | |
| 176 | + | d.mu.Unlock() | |
| 177 | + | req.resp <- selected | |
| 178 | + | return true | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | d.mu.Unlock() | |
| 183 | + | return false | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Render draws the dialog for the head-of-queue request. | |
| 187 | + | func (d *confirmDialog) Render(th tui.Theme, width int) []string { | |
| 188 | + | d.mu.Lock() | |
| 189 | + | defer d.mu.Unlock() | |
| 190 | + | if len(d.pending) == 0 { | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | req := d.pending[0] | |
| 194 | + | cursor := d.cursor | |
| 195 | + | queued := len(d.pending) - 1 | |
| 196 | + | ||
| 197 | + | var lines []string | |
| 198 | + | title := "confirm tool call" | |
| 199 | + | if queued > 0 { | |
| 200 | + | title = fmt.Sprintf("confirm tool call (%d more queued)", queued) | |
| 201 | + | } | |
| 202 | + | lines = append(lines, frameHeader(th, title, width)) | |
| 203 | + | ||
| 204 | + | toolLine := " " + th.FG256(th.Tool, req.toolName) | |
| 205 | + | if req.preview != "" { | |
| 206 | + | toolLine += " " + th.FG256(th.Muted, req.preview) | |
| 207 | + | } | |
| 208 | + | lines = append(lines, toolLine) | |
| 209 | + | lines = append(lines, "") | |
| 210 | + | lines = append(lines, th.FG256(th.Muted, "choose (\u2191/\u2193 or 1-4, enter to pick, esc to refuse):")) | |
| 211 | + | ||
| 212 | + | for i, opt := range confirmOptions { | |
| 213 | + | label := opt.label | |
| 214 | + | prefix := fmt.Sprintf(" %d. ", i+1) | |
| 215 | + | plain := prefix + label | |
| 216 | + | // Truncate the tail if the line would exceed width; keeps | |
| 217 | + | // the option numbers always visible. | |
| 218 | + | if visibleLen(plain) > width-2 { | |
| 219 | + | plain = plain[:width-3] + "\u2026" | |
| 220 | + | } | |
| 221 | + | if i == cursor { | |
| 222 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 223 | + | } else { | |
| 224 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | lines = append(lines, frameRule(th, width)) | |
| 228 | + | return lines | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // visibleLen is a cheap char-count; ANSI codes aren't in the raw | |
| 232 | + | // text at this point so len() is fine. | |
| 233 | + | func visibleLen(s string) int { return strings.Count(s, "") - 1 } | |
| @@ -0,0 +1,233 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/internal/core" | |||
| 10 | + | "github.com/patriceckhart/zot/internal/tui" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // confirmRequest is one pending confirmation. The agent goroutine | |||
| 14 | + | // writes it onto the interactive's queue, the TUI renders the | |||
| 15 | + | // dialog, and the user's answer goes back through `resp`. | |||
| 16 | + | type confirmRequest struct { | |||
| 17 | + | toolName string | |||
| 18 | + | preview string | |||
| 19 | + | resp chan core.ConfirmDecision | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // confirmDialog is the inline prompt shown before every tool call | |||
| 23 | + | // when --no-yolo is on. It presents four options and routes the | |||
| 24 | + | // answer back through the pending request's response channel. | |||
| 25 | + | type confirmDialog struct { | |||
| 26 | + | mu sync.Mutex | |||
| 27 | + | pending []*confirmRequest | |||
| 28 | + | cursor int | |||
| 29 | + | // activeSince timestamps when the current request started | |||
| 30 | + | // showing. Used only for debug/future UX (e.g. flashing the | |||
| 31 | + | // dialog border after N seconds of no answer). | |||
| 32 | + | activeSince time.Time | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func newConfirmDialog() *confirmDialog { return &confirmDialog{} } | |||
| 36 | + | ||||
| 37 | + | // Enqueue adds a pending request. Multiple requests can queue up | |||
| 38 | + | // if the agent produces tool calls in parallel (via extensions); | |||
| 39 | + | // the TUI shows them one at a time, in order. | |||
| 40 | + | func (d *confirmDialog) Enqueue(req *confirmRequest) { | |||
| 41 | + | d.mu.Lock() | |||
| 42 | + | defer d.mu.Unlock() | |||
| 43 | + | d.pending = append(d.pending, req) | |||
| 44 | + | if len(d.pending) == 1 { | |||
| 45 | + | d.cursor = 0 | |||
| 46 | + | d.activeSince = time.Now() | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Active reports whether the dialog is consuming input (there's at | |||
| 51 | + | // least one pending request). | |||
| 52 | + | func (d *confirmDialog) Active() bool { | |||
| 53 | + | if d == nil { | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | d.mu.Lock() | |||
| 57 | + | defer d.mu.Unlock() | |||
| 58 | + | return len(d.pending) > 0 | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // CancelAll refuses every pending request (used when the user | |||
| 62 | + | // cancels the active turn or closes the session while the dialog | |||
| 63 | + | // is open). Safe to call when idle. | |||
| 64 | + | func (d *confirmDialog) CancelAll(reason string) { | |||
| 65 | + | d.mu.Lock() | |||
| 66 | + | pending := d.pending | |||
| 67 | + | d.pending = nil | |||
| 68 | + | d.mu.Unlock() | |||
| 69 | + | for _, req := range pending { | |||
| 70 | + | select { | |||
| 71 | + | case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}: | |||
| 72 | + | default: | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // AllowAllPending approves every pending request and drains the | |||
| 78 | + | // queue. Used by /yolo so any confirmation dialog already on screen | |||
| 79 | + | // resolves immediately as "yes". | |||
| 80 | + | func (d *confirmDialog) AllowAllPending() { | |||
| 81 | + | d.mu.Lock() | |||
| 82 | + | pending := d.pending | |||
| 83 | + | d.pending = nil | |||
| 84 | + | d.mu.Unlock() | |||
| 85 | + | for _, req := range pending { | |||
| 86 | + | select { | |||
| 87 | + | case req.resp <- core.ConfirmDecision{Allow: true, RememberAll: true}: | |||
| 88 | + | default: | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // confirmOptions lists the four responses in vertical order. Keyed | |||
| 94 | + | // by index so HandleKey can send the right decision. | |||
| 95 | + | var confirmOptions = []struct { | |||
| 96 | + | label string | |||
| 97 | + | decision core.ConfirmDecision | |||
| 98 | + | }{ | |||
| 99 | + | { | |||
| 100 | + | label: "yes (run this call)", | |||
| 101 | + | decision: core.ConfirmDecision{Allow: true}, | |||
| 102 | + | }, | |||
| 103 | + | { | |||
| 104 | + | label: "yes, always this tool (skip prompts for this tool for the rest of the session)", | |||
| 105 | + | decision: core.ConfirmDecision{Allow: true, RememberTool: true}, | |||
| 106 | + | }, | |||
| 107 | + | { | |||
| 108 | + | label: "yes, always (skip all prompts for the rest of the session)", | |||
| 109 | + | decision: core.ConfirmDecision{Allow: true, RememberAll: true}, | |||
| 110 | + | }, | |||
| 111 | + | { | |||
| 112 | + | label: "no (refuse and let the model try something else)", | |||
| 113 | + | decision: core.ConfirmDecision{Allow: false, Reason: "user declined"}, | |||
| 114 | + | }, | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // HandleKey advances the selection or resolves the dialog. Returns | |||
| 118 | + | // true when a decision was sent back to the agent (the dialog may | |||
| 119 | + | // remain Active if there are more queued requests behind it). | |||
| 120 | + | func (d *confirmDialog) HandleKey(k tui.Key) bool { | |||
| 121 | + | d.mu.Lock() | |||
| 122 | + | if len(d.pending) == 0 { | |||
| 123 | + | d.mu.Unlock() | |||
| 124 | + | return false | |||
| 125 | + | } | |||
| 126 | + | req := d.pending[0] | |||
| 127 | + | ||||
| 128 | + | switch k.Kind { | |||
| 129 | + | case tui.KeyUp: | |||
| 130 | + | if d.cursor > 0 { | |||
| 131 | + | d.cursor-- | |||
| 132 | + | } | |||
| 133 | + | d.mu.Unlock() | |||
| 134 | + | return false | |||
| 135 | + | case tui.KeyDown: | |||
| 136 | + | if d.cursor < len(confirmOptions)-1 { | |||
| 137 | + | d.cursor++ | |||
| 138 | + | } | |||
| 139 | + | d.mu.Unlock() | |||
| 140 | + | return false | |||
| 141 | + | case tui.KeyEsc, tui.KeyCtrlC: | |||
| 142 | + | // Treat esc/ctrl+c as "no, refuse this tool call". The | |||
| 143 | + | // active turn can separately be cancelled by the outer esc | |||
| 144 | + | // handler, but for the dialog itself, we must always answer | |||
| 145 | + | // so the agent goroutine unblocks. | |||
| 146 | + | d.pending = d.pending[1:] | |||
| 147 | + | if len(d.pending) > 0 { | |||
| 148 | + | d.cursor = 0 | |||
| 149 | + | d.activeSince = time.Now() | |||
| 150 | + | } | |||
| 151 | + | d.mu.Unlock() | |||
| 152 | + | req.resp <- core.ConfirmDecision{Allow: false, Reason: "user cancelled"} | |||
| 153 | + | return true | |||
| 154 | + | case tui.KeyEnter: | |||
| 155 | + | selected := confirmOptions[d.cursor].decision | |||
| 156 | + | d.pending = d.pending[1:] | |||
| 157 | + | if len(d.pending) > 0 { | |||
| 158 | + | d.cursor = 0 | |||
| 159 | + | d.activeSince = time.Now() | |||
| 160 | + | } | |||
| 161 | + | d.mu.Unlock() | |||
| 162 | + | req.resp <- selected | |||
| 163 | + | return true | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // Numeric shortcuts 1..4 | |||
| 167 | + | if k.Kind == tui.KeyRune && k.Rune >= '1' && k.Rune <= '4' { | |||
| 168 | + | idx := int(k.Rune - '1') | |||
| 169 | + | if idx >= 0 && idx < len(confirmOptions) { | |||
| 170 | + | selected := confirmOptions[idx].decision | |||
| 171 | + | d.pending = d.pending[1:] | |||
| 172 | + | if len(d.pending) > 0 { | |||
| 173 | + | d.cursor = 0 | |||
| 174 | + | d.activeSince = time.Now() | |||
| 175 | + | } | |||
| 176 | + | d.mu.Unlock() | |||
| 177 | + | req.resp <- selected | |||
| 178 | + | return true | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | d.mu.Unlock() | |||
| 183 | + | return false | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Render draws the dialog for the head-of-queue request. | |||
| 187 | + | func (d *confirmDialog) Render(th tui.Theme, width int) []string { | |||
| 188 | + | d.mu.Lock() | |||
| 189 | + | defer d.mu.Unlock() | |||
| 190 | + | if len(d.pending) == 0 { | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | req := d.pending[0] | |||
| 194 | + | cursor := d.cursor | |||
| 195 | + | queued := len(d.pending) - 1 | |||
| 196 | + | ||||
| 197 | + | var lines []string | |||
| 198 | + | title := "confirm tool call" | |||
| 199 | + | if queued > 0 { | |||
| 200 | + | title = fmt.Sprintf("confirm tool call (%d more queued)", queued) | |||
| 201 | + | } | |||
| 202 | + | lines = append(lines, frameHeader(th, title, width)) | |||
| 203 | + | ||||
| 204 | + | toolLine := " " + th.FG256(th.Tool, req.toolName) | |||
| 205 | + | if req.preview != "" { | |||
| 206 | + | toolLine += " " + th.FG256(th.Muted, req.preview) | |||
| 207 | + | } | |||
| 208 | + | lines = append(lines, toolLine) | |||
| 209 | + | lines = append(lines, "") | |||
| 210 | + | lines = append(lines, th.FG256(th.Muted, "choose (\u2191/\u2193 or 1-4, enter to pick, esc to refuse):")) | |||
| 211 | + | ||||
| 212 | + | for i, opt := range confirmOptions { | |||
| 213 | + | label := opt.label | |||
| 214 | + | prefix := fmt.Sprintf(" %d. ", i+1) | |||
| 215 | + | plain := prefix + label | |||
| 216 | + | // Truncate the tail if the line would exceed width; keeps | |||
| 217 | + | // the option numbers always visible. | |||
| 218 | + | if visibleLen(plain) > width-2 { | |||
| 219 | + | plain = plain[:width-3] + "\u2026" | |||
| 220 | + | } | |||
| 221 | + | if i == cursor { | |||
| 222 | + | lines = append(lines, th.PadHighlight(plain, width)) | |||
| 223 | + | } else { | |||
| 224 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | lines = append(lines, frameRule(th, width)) | |||
| 228 | + | return lines | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // visibleLen is a cheap char-count; ANSI codes aren't in the raw | |||
| 232 | + | // text at this point so len() is fine. | |||
| 233 | + | func visibleLen(s string) int { return strings.Count(s, "") - 1 } | |||
M internal/agent/modes/interactive.go +77 -1
| @@ -102,6 +102,19 @@ type InteractiveConfig struct { | |||
| 102 | 102 | // MarkChangelogShown call so the same version doesn't show | |
| 103 | 103 | // again on the next launch. | |
| 104 | 104 | OnChangelogDismiss func() | |
| 105 | + | ||
| 106 | + | // NoYolo is true when --no-yolo was passed. Interactive opens | |
| 107 | + | // a confirmation dialog before every tool call and blocks the | |
| 108 | + | // tool until the user picks yes / always-this-tool / | |
| 109 | + | // always-all / no. When false (default), tools run freely. | |
| 110 | + | NoYolo bool | |
| 111 | + | ||
| 112 | + | // ConfirmGate is the session-scoped gate wrapping this | |
| 113 | + | // interactive's Confirmer. When non-nil, /yolo can call | |
| 114 | + | // AllowAll() on it to disable confirmation for the rest of the | |
| 115 | + | // session. When nil (yolo mode), /yolo reports that there's | |
| 116 | + | // nothing to disable. | |
| 117 | + | ConfirmGate *core.ConfirmGate | |
| 105 | 118 | } | |
| 106 | 119 | ||
| 107 | 120 | // ChangelogPayload mirrors agent.ChangelogInfo without the import | |
| @@ -162,6 +175,7 @@ type Interactive struct { | |||
| 162 | 175 | btwDialog *btwDialog | |
| 163 | 176 | skillsDialog *skillsDialog | |
| 164 | 177 | changelogDialog *changelogDialog | |
| 178 | + | confirmDialog *confirmDialog | |
| 165 | 179 | suggest *slashSuggester | |
| 166 | 180 | spin *spinner | |
| 167 | 181 | ||
| @@ -212,6 +226,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 212 | 226 | btwDialog: newBtwDialog(), | |
| 213 | 227 | skillsDialog: newSkillsDialog(), | |
| 214 | 228 | changelogDialog: newChangelogDialog(), | |
| 229 | + | confirmDialog: newConfirmDialog(), | |
| 215 | 230 | suggest: newSlashSuggester(), | |
| 216 | 231 | spin: newSpinner(), | |
| 217 | 232 | } | |
| @@ -401,7 +416,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 401 | 416 | // and the AfterFunc-driven invalidate got dropped on a | |
| 402 | 417 | // full channel. | |
| 403 | 418 | drainPending() | |
| 404 | - | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() { | |
| 419 | + | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() { | |
| 405 | 420 | requestRedraw() // keep the spinner / dialog animation moving | |
| 406 | 421 | } | |
| 407 | 422 | } | |
| @@ -559,6 +574,8 @@ func (i *Interactive) redraw() { | |||
| 559 | 574 | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | |
| 560 | 575 | case i.changelogDialog.Active(): | |
| 561 | 576 | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | |
| 577 | + | case i.confirmDialog.Active(): | |
| 578 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, cols) | |
| 562 | 579 | } | |
| 563 | 580 | ||
| 564 | 581 | // Slash-command autocomplete: popup above the status line, only | |
| @@ -757,6 +774,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 757 | 774 | } | |
| 758 | 775 | ||
| 759 | 776 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | |
| 777 | + | ||
| 778 | + | // Confirm dialog has highest priority: the agent goroutine is | |
| 779 | + | // blocked waiting for an answer, so we must not let keys leak | |
| 780 | + | // anywhere else while it's up. | |
| 781 | + | if i.confirmDialog.Active() { | |
| 782 | + | i.confirmDialog.HandleKey(k) | |
| 783 | + | i.invalidate() | |
| 784 | + | return false | |
| 785 | + | } | |
| 760 | 786 | if i.dialog.Active() { | |
| 761 | 787 | if k.Kind == tui.KeyCtrlC { | |
| 762 | 788 | i.dialog.Close() | |
| @@ -879,6 +905,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 879 | 905 | // editor can clear itself. | |
| 880 | 906 | if i.busy && i.cancelTurn != nil { | |
| 881 | 907 | i.cancelTurn() | |
| 908 | + | // If a confirm dialog is pending, refuse it so the agent | |
| 909 | + | // goroutine unblocks and the context cancellation can | |
| 910 | + | // actually take effect. | |
| 911 | + | i.confirmDialog.CancelAll("turn cancelled") | |
| 882 | 912 | return false | |
| 883 | 913 | } | |
| 884 | 914 | case tui.KeyCtrlD: | |
| @@ -1209,6 +1239,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 1209 | 1239 | i.mu.Unlock() | |
| 1210 | 1240 | case "/reload-ext": | |
| 1211 | 1241 | i.runReloadExt(ctx) | |
| 1242 | + | case "/yolo": | |
| 1243 | + | i.runYoloOn() | |
| 1212 | 1244 | default: | |
| 1213 | 1245 | // Last-resort fallback: try the extension manager. Built-in | |
| 1214 | 1246 | // cases above always win; this branch only fires for slash | |
| @@ -1978,3 +2010,47 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||
| 1978 | 2010 | i.invalidate() | |
| 1979 | 2011 | }() | |
| 1980 | 2012 | } | |
| 2013 | + | ||
| 2014 | + | // Confirm implements core.Confirmer. The agent goroutine calls | |
| 2015 | + | // this synchronously before every tool invocation when --no-yolo is | |
| 2016 | + | // active. We push the request onto the confirmDialog queue, trigger | |
| 2017 | + | // a redraw, and block the caller until the user answers. | |
| 2018 | + | // | |
| 2019 | + | // If the session is cancelled or the TUI exits mid-prompt, any | |
| 2020 | + | // pending request is refused via CancelAll so the agent doesn't | |
| 2021 | + | // deadlock. | |
| 2022 | + | func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision { | |
| 2023 | + | resp := make(chan core.ConfirmDecision, 1) | |
| 2024 | + | i.confirmDialog.Enqueue(&confirmRequest{ | |
| 2025 | + | toolName: toolName, | |
| 2026 | + | preview: preview, | |
| 2027 | + | resp: resp, | |
| 2028 | + | }) | |
| 2029 | + | i.invalidate() | |
| 2030 | + | return <-resp | |
| 2031 | + | } | |
| 2032 | + | ||
| 2033 | + | // runYoloOn disables --no-yolo for the rest of the session. Tool | |
| 2034 | + | // calls run without prompting after this; there's intentionally no | |
| 2035 | + | // way to re-enable gating mid-session, if the user wants that back | |
| 2036 | + | // they can exit and restart with --no-yolo. | |
| 2037 | + | func (i *Interactive) runYoloOn() { | |
| 2038 | + | if i.cfg.ConfirmGate == nil { | |
| 2039 | + | i.mu.Lock() | |
| 2040 | + | i.statusOK = "yolo mode is already on (no --no-yolo in this session)" | |
| 2041 | + | i.statusErr = "" | |
| 2042 | + | i.mu.Unlock() | |
| 2043 | + | i.invalidate() | |
| 2044 | + | return | |
| 2045 | + | } | |
| 2046 | + | i.cfg.ConfirmGate.AllowAll() | |
| 2047 | + | // Also auto-allow any currently pending confirmation so the | |
| 2048 | + | // agent doesn't deadlock if /yolo is typed while a prompt is | |
| 2049 | + | // open. | |
| 2050 | + | i.confirmDialog.AllowAllPending() | |
| 2051 | + | i.mu.Lock() | |
| 2052 | + | i.statusOK = "yolo engaged: no more tool-call confirmations this session" | |
| 2053 | + | i.statusErr = "" | |
| 2054 | + | i.mu.Unlock() | |
| 2055 | + | i.invalidate() | |
| 2056 | + | } | |
| @@ -102,6 +102,19 @@ type InteractiveConfig struct { | |||||
| 102 | // MarkChangelogShown call so the same version doesn't show | 102 | // MarkChangelogShown call so the same version doesn't show | ||
| 103 | // again on the next launch. | 103 | // again on the next launch. | ||
| 104 | OnChangelogDismiss func() | 104 | OnChangelogDismiss func() | ||
| 105 | + | ||||
| 106 | + | // NoYolo is true when --no-yolo was passed. Interactive opens | |||
| 107 | + | // a confirmation dialog before every tool call and blocks the | |||
| 108 | + | // tool until the user picks yes / always-this-tool / | |||
| 109 | + | // always-all / no. When false (default), tools run freely. | |||
| 110 | + | NoYolo bool | |||
| 111 | + | ||||
| 112 | + | // ConfirmGate is the session-scoped gate wrapping this | |||
| 113 | + | // interactive's Confirmer. When non-nil, /yolo can call | |||
| 114 | + | // AllowAll() on it to disable confirmation for the rest of the | |||
| 115 | + | // session. When nil (yolo mode), /yolo reports that there's | |||
| 116 | + | // nothing to disable. | |||
| 117 | + | ConfirmGate *core.ConfirmGate | |||
| 105 | } | 118 | } | ||
| 106 | 119 | ||||
| 107 | // ChangelogPayload mirrors agent.ChangelogInfo without the import | 120 | // ChangelogPayload mirrors agent.ChangelogInfo without the import | ||
| @@ -162,6 +175,7 @@ type Interactive struct { | |||||
| 162 | btwDialog *btwDialog | 175 | btwDialog *btwDialog | ||
| 163 | skillsDialog *skillsDialog | 176 | skillsDialog *skillsDialog | ||
| 164 | changelogDialog *changelogDialog | 177 | changelogDialog *changelogDialog | ||
| 178 | + | confirmDialog *confirmDialog | |||
| 165 | suggest *slashSuggester | 179 | suggest *slashSuggester | ||
| 166 | spin *spinner | 180 | spin *spinner | ||
| 167 | 181 | ||||
| @@ -212,6 +226,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 212 | btwDialog: newBtwDialog(), | 226 | btwDialog: newBtwDialog(), | ||
| 213 | skillsDialog: newSkillsDialog(), | 227 | skillsDialog: newSkillsDialog(), | ||
| 214 | changelogDialog: newChangelogDialog(), | 228 | changelogDialog: newChangelogDialog(), | ||
| 229 | + | confirmDialog: newConfirmDialog(), | |||
| 215 | suggest: newSlashSuggester(), | 230 | suggest: newSlashSuggester(), | ||
| 216 | spin: newSpinner(), | 231 | spin: newSpinner(), | ||
| 217 | } | 232 | } | ||
| @@ -401,7 +416,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 401 | // and the AfterFunc-driven invalidate got dropped on a | 416 | // and the AfterFunc-driven invalidate got dropped on a | ||
| 402 | // full channel. | 417 | // full channel. | ||
| 403 | drainPending() | 418 | drainPending() | ||
| 404 | - | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() { | 419 | + | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() { |
| 405 | requestRedraw() // keep the spinner / dialog animation moving | 420 | requestRedraw() // keep the spinner / dialog animation moving | ||
| 406 | } | 421 | } | ||
| 407 | } | 422 | } | ||
| @@ -559,6 +574,8 @@ func (i *Interactive) redraw() { | |||||
| 559 | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | 574 | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | ||
| 560 | case i.changelogDialog.Active(): | 575 | case i.changelogDialog.Active(): | ||
| 561 | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | 576 | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | ||
| 577 | + | case i.confirmDialog.Active(): | |||
| 578 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, cols) | |||
| 562 | } | 579 | } | ||
| 563 | 580 | ||||
| 564 | // Slash-command autocomplete: popup above the status line, only | 581 | // Slash-command autocomplete: popup above the status line, only | ||
| @@ -757,6 +774,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 757 | } | 774 | } | ||
| 758 | 775 | ||||
| 759 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | 776 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | ||
| 777 | + | ||||
| 778 | + | // Confirm dialog has highest priority: the agent goroutine is | |||
| 779 | + | // blocked waiting for an answer, so we must not let keys leak | |||
| 780 | + | // anywhere else while it's up. | |||
| 781 | + | if i.confirmDialog.Active() { | |||
| 782 | + | i.confirmDialog.HandleKey(k) | |||
| 783 | + | i.invalidate() | |||
| 784 | + | return false | |||
| 785 | + | } | |||
| 760 | if i.dialog.Active() { | 786 | if i.dialog.Active() { | ||
| 761 | if k.Kind == tui.KeyCtrlC { | 787 | if k.Kind == tui.KeyCtrlC { | ||
| 762 | i.dialog.Close() | 788 | i.dialog.Close() | ||
| @@ -879,6 +905,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 879 | // editor can clear itself. | 905 | // editor can clear itself. | ||
| 880 | if i.busy && i.cancelTurn != nil { | 906 | if i.busy && i.cancelTurn != nil { | ||
| 881 | i.cancelTurn() | 907 | i.cancelTurn() | ||
| 908 | + | // If a confirm dialog is pending, refuse it so the agent | |||
| 909 | + | // goroutine unblocks and the context cancellation can | |||
| 910 | + | // actually take effect. | |||
| 911 | + | i.confirmDialog.CancelAll("turn cancelled") | |||
| 882 | return false | 912 | return false | ||
| 883 | } | 913 | } | ||
| 884 | case tui.KeyCtrlD: | 914 | case tui.KeyCtrlD: | ||
| @@ -1209,6 +1239,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 1209 | i.mu.Unlock() | 1239 | i.mu.Unlock() | ||
| 1210 | case "/reload-ext": | 1240 | case "/reload-ext": | ||
| 1211 | i.runReloadExt(ctx) | 1241 | i.runReloadExt(ctx) | ||
| 1242 | + | case "/yolo": | |||
| 1243 | + | i.runYoloOn() | |||
| 1212 | default: | 1244 | default: | ||
| 1213 | // Last-resort fallback: try the extension manager. Built-in | 1245 | // Last-resort fallback: try the extension manager. Built-in | ||
| 1214 | // cases above always win; this branch only fires for slash | 1246 | // cases above always win; this branch only fires for slash | ||
| @@ -1978,3 +2010,47 @@ func (i *Interactive) runReloadExt(ctx context.Context) { | |||||
| 1978 | i.invalidate() | 2010 | i.invalidate() | ||
| 1979 | }() | 2011 | }() | ||
| 1980 | } | 2012 | } | ||
| 2013 | + | ||||
| 2014 | + | // Confirm implements core.Confirmer. The agent goroutine calls | |||
| 2015 | + | // this synchronously before every tool invocation when --no-yolo is | |||
| 2016 | + | // active. We push the request onto the confirmDialog queue, trigger | |||
| 2017 | + | // a redraw, and block the caller until the user answers. | |||
| 2018 | + | // | |||
| 2019 | + | // If the session is cancelled or the TUI exits mid-prompt, any | |||
| 2020 | + | // pending request is refused via CancelAll so the agent doesn't | |||
| 2021 | + | // deadlock. | |||
| 2022 | + | func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision { | |||
| 2023 | + | resp := make(chan core.ConfirmDecision, 1) | |||
| 2024 | + | i.confirmDialog.Enqueue(&confirmRequest{ | |||
| 2025 | + | toolName: toolName, | |||
| 2026 | + | preview: preview, | |||
| 2027 | + | resp: resp, | |||
| 2028 | + | }) | |||
| 2029 | + | i.invalidate() | |||
| 2030 | + | return <-resp | |||
| 2031 | + | } | |||
| 2032 | + | ||||
| 2033 | + | // runYoloOn disables --no-yolo for the rest of the session. Tool | |||
| 2034 | + | // calls run without prompting after this; there's intentionally no | |||
| 2035 | + | // way to re-enable gating mid-session, if the user wants that back | |||
| 2036 | + | // they can exit and restart with --no-yolo. | |||
| 2037 | + | func (i *Interactive) runYoloOn() { | |||
| 2038 | + | if i.cfg.ConfirmGate == nil { | |||
| 2039 | + | i.mu.Lock() | |||
| 2040 | + | i.statusOK = "yolo mode is already on (no --no-yolo in this session)" | |||
| 2041 | + | i.statusErr = "" | |||
| 2042 | + | i.mu.Unlock() | |||
| 2043 | + | i.invalidate() | |||
| 2044 | + | return | |||
| 2045 | + | } | |||
| 2046 | + | i.cfg.ConfirmGate.AllowAll() | |||
| 2047 | + | // Also auto-allow any currently pending confirmation so the | |||
| 2048 | + | // agent doesn't deadlock if /yolo is typed while a prompt is | |||
| 2049 | + | // open. | |||
| 2050 | + | i.confirmDialog.AllowAllPending() | |||
| 2051 | + | i.mu.Lock() | |||
| 2052 | + | i.statusOK = "yolo engaged: no more tool-call confirmations this session" | |||
| 2053 | + | i.statusErr = "" | |||
| 2054 | + | i.mu.Unlock() | |||
| 2055 | + | i.invalidate() | |||
| 2056 | + | } | |||
M internal/agent/modes/slash_suggest.go +1 -0
| @@ -44,6 +44,7 @@ var slashCatalog = []slashCommand{ | |||
| 44 | 44 | {Name: "/lock", Desc: "confine tools to the current directory"}, | |
| 45 | 45 | {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"}, | |
| 46 | 46 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | |
| 47 | + | {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"}, | |
| 47 | 48 | {Name: "/clear", Desc: "clear the chat transcript"}, | |
| 48 | 49 | {Name: "/exit", Desc: "exit zot"}, | |
| 49 | 50 | } | |
| @@ -44,6 +44,7 @@ var slashCatalog = []slashCommand{ | |||||
| 44 | {Name: "/lock", Desc: "confine tools to the current directory"}, | 44 | {Name: "/lock", Desc: "confine tools to the current directory"}, | ||
| 45 | {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"}, | 45 | {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"}, | ||
| 46 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | 46 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | ||
| 47 | + | {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"}, | |||
| 47 | {Name: "/clear", Desc: "clear the chat transcript"}, | 48 | {Name: "/clear", Desc: "clear the chat transcript"}, | ||
| 48 | {Name: "/exit", Desc: "exit zot"}, | 49 | {Name: "/exit", Desc: "exit zot"}, | ||
| 49 | } | 50 | } | ||
A internal/core/confirm.go +182 -0
| @@ -0,0 +1,182 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "strings" | |
| 6 | + | "sync" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ConfirmDecision is the outcome of a confirmation prompt for a | |
| 10 | + | // tool call. It answers two questions: should this call run, and | |
| 11 | + | // should future calls of this shape skip the prompt for the rest | |
| 12 | + | // of the session. | |
| 13 | + | type ConfirmDecision struct { | |
| 14 | + | Allow bool | |
| 15 | + | // Reason is shown to the model as the tool error when | |
| 16 | + | // Allow=false. Examples: "user declined", "user refused: rm -rf | |
| 17 | + | // looks dangerous". | |
| 18 | + | Reason string | |
| 19 | + | // RememberTool, when true, auto-allows every future call of the | |
| 20 | + | // same tool name for the rest of the session without prompting. | |
| 21 | + | RememberTool bool | |
| 22 | + | // RememberAll, when true, auto-allows every future call of any | |
| 23 | + | // tool for the rest of the session without prompting. | |
| 24 | + | // Effectively turns yolo back on for this session. | |
| 25 | + | RememberAll bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Confirmer asks the user to approve or refuse a single tool call. | |
| 29 | + | // Implementations block until the user responds (or the agent's | |
| 30 | + | // context is cancelled, in which case they should return | |
| 31 | + | // Allow=false with a cancellation reason). | |
| 32 | + | // | |
| 33 | + | // preview is a short one-line summary of the args (the shell | |
| 34 | + | // command, the file path, the URL) that the TUI shows alongside | |
| 35 | + | // the tool name. It is intentionally short: no full tool outputs. | |
| 36 | + | type Confirmer interface { | |
| 37 | + | Confirm(toolName string, preview string) ConfirmDecision | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // ConfirmGate wraps a Confirmer with session-scoped memory for the | |
| 41 | + | // "allow, always" decisions. Once the user picks RememberTool on a | |
| 42 | + | // given tool name, the gate short-circuits subsequent calls of that | |
| 43 | + | // name. Once the user picks RememberAll, the gate short-circuits | |
| 44 | + | // everything for the rest of the session. | |
| 45 | + | // | |
| 46 | + | // Safe for concurrent use: the allow-lists are guarded by a mutex | |
| 47 | + | // because the agent can queue tool calls from different goroutines. | |
| 48 | + | type ConfirmGate struct { | |
| 49 | + | inner Confirmer | |
| 50 | + | ||
| 51 | + | mu sync.Mutex | |
| 52 | + | allowAll bool | |
| 53 | + | allowedTool map[string]bool | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // NewConfirmGate returns a gate backed by inner. When inner is nil, | |
| 57 | + | // the gate operates in auto-refuse mode: every tool call is denied | |
| 58 | + | // with a fixed reason. That's what non-interactive modes (-p / | |
| 59 | + | // --json / rpc) use when --no-yolo is on, since they have no way | |
| 60 | + | // to prompt the user. | |
| 61 | + | func NewConfirmGate(inner Confirmer) *ConfirmGate { | |
| 62 | + | return &ConfirmGate{ | |
| 63 | + | inner: inner, | |
| 64 | + | allowedTool: map[string]bool{}, | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Check is the BeforeToolExecute-style entry point. Returns | |
| 69 | + | // allowed, reason, modifiedArgs. modifiedArgs is always nil: the | |
| 70 | + | // gate never rewrites args; it only allows or denies. | |
| 71 | + | // | |
| 72 | + | // A nil ConfirmGate always allows (treat as yolo mode). | |
| 73 | + | func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMessage) { | |
| 74 | + | if g == nil { | |
| 75 | + | return true, "", nil | |
| 76 | + | } | |
| 77 | + | g.mu.Lock() | |
| 78 | + | if g.allowAll { | |
| 79 | + | g.mu.Unlock() | |
| 80 | + | return true, "", nil | |
| 81 | + | } | |
| 82 | + | if g.allowedTool[toolName] { | |
| 83 | + | g.mu.Unlock() | |
| 84 | + | return true, "", nil | |
| 85 | + | } | |
| 86 | + | g.mu.Unlock() | |
| 87 | + | ||
| 88 | + | g.mu.Lock() | |
| 89 | + | inner := g.inner | |
| 90 | + | g.mu.Unlock() | |
| 91 | + | if inner == nil { | |
| 92 | + | return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | decision := inner.Confirm(toolName, preview) | |
| 96 | + | ||
| 97 | + | g.mu.Lock() | |
| 98 | + | if decision.Allow { | |
| 99 | + | if decision.RememberAll { | |
| 100 | + | g.allowAll = true | |
| 101 | + | } | |
| 102 | + | if decision.RememberTool { | |
| 103 | + | g.allowedTool[toolName] = true | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | g.mu.Unlock() | |
| 107 | + | ||
| 108 | + | reason := strings.TrimSpace(decision.Reason) | |
| 109 | + | if !decision.Allow && reason == "" { | |
| 110 | + | reason = "tool call refused by user" | |
| 111 | + | } | |
| 112 | + | return decision.Allow, reason, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Reset clears the session memory. Invoked when the user toggles | |
| 116 | + | // yolo mode back on via /yolo or closes the session. | |
| 117 | + | func (g *ConfirmGate) Reset() { | |
| 118 | + | if g == nil { | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | g.mu.Lock() | |
| 122 | + | g.allowAll = false | |
| 123 | + | g.allowedTool = map[string]bool{} | |
| 124 | + | g.mu.Unlock() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // AllowAll flips the gate into "always allow" for the rest of the | |
| 128 | + | // session. Used by /yolo on to turn confirmation off at runtime | |
| 129 | + | // without having to restart with the flag flipped. | |
| 130 | + | func (g *ConfirmGate) AllowAll() { | |
| 131 | + | if g == nil { | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | g.mu.Lock() | |
| 135 | + | g.allowAll = true | |
| 136 | + | g.mu.Unlock() | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // SetConfirmer replaces the inner Confirmer. Used by the cli to | |
| 140 | + | // hand the Interactive TUI to a gate that was constructed before | |
| 141 | + | // the TUI existed (a construction-order knot we unwind here). | |
| 142 | + | // Safe to call from any goroutine. | |
| 143 | + | func (g *ConfirmGate) SetConfirmer(c Confirmer) { | |
| 144 | + | if g == nil { | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | g.mu.Lock() | |
| 148 | + | g.inner = c | |
| 149 | + | g.mu.Unlock() | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // BuildPreview turns a tool call's JSON args into a short one-line | |
| 153 | + | // summary the TUI can show in the confirmation prompt. Prioritises | |
| 154 | + | // obvious human-readable fields (command, path, url) over raw JSON. | |
| 155 | + | // Returns at most maxLen characters. | |
| 156 | + | func BuildPreview(args json.RawMessage, maxLen int) string { | |
| 157 | + | if maxLen <= 0 { | |
| 158 | + | maxLen = 120 | |
| 159 | + | } | |
| 160 | + | if len(args) == 0 { | |
| 161 | + | return "" | |
| 162 | + | } | |
| 163 | + | var m map[string]any | |
| 164 | + | if err := json.Unmarshal(args, &m); err != nil { | |
| 165 | + | return truncatePreview(string(args), maxLen) | |
| 166 | + | } | |
| 167 | + | for _, k := range []string{"command", "path", "file_path", "url", "query", "name"} { | |
| 168 | + | if v, ok := m[k].(string); ok && v != "" { | |
| 169 | + | return truncatePreview(v, maxLen) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | b, _ := json.Marshal(m) | |
| 173 | + | return truncatePreview(string(b), maxLen) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func truncatePreview(s string, n int) string { | |
| 177 | + | s = strings.TrimSpace(s) | |
| 178 | + | if len(s) <= n { | |
| 179 | + | return s | |
| 180 | + | } | |
| 181 | + | return s[:n-1] + "\u2026" | |
| 182 | + | } | |
| @@ -0,0 +1,182 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ConfirmDecision is the outcome of a confirmation prompt for a | |||
| 10 | + | // tool call. It answers two questions: should this call run, and | |||
| 11 | + | // should future calls of this shape skip the prompt for the rest | |||
| 12 | + | // of the session. | |||
| 13 | + | type ConfirmDecision struct { | |||
| 14 | + | Allow bool | |||
| 15 | + | // Reason is shown to the model as the tool error when | |||
| 16 | + | // Allow=false. Examples: "user declined", "user refused: rm -rf | |||
| 17 | + | // looks dangerous". | |||
| 18 | + | Reason string | |||
| 19 | + | // RememberTool, when true, auto-allows every future call of the | |||
| 20 | + | // same tool name for the rest of the session without prompting. | |||
| 21 | + | RememberTool bool | |||
| 22 | + | // RememberAll, when true, auto-allows every future call of any | |||
| 23 | + | // tool for the rest of the session without prompting. | |||
| 24 | + | // Effectively turns yolo back on for this session. | |||
| 25 | + | RememberAll bool | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Confirmer asks the user to approve or refuse a single tool call. | |||
| 29 | + | // Implementations block until the user responds (or the agent's | |||
| 30 | + | // context is cancelled, in which case they should return | |||
| 31 | + | // Allow=false with a cancellation reason). | |||
| 32 | + | // | |||
| 33 | + | // preview is a short one-line summary of the args (the shell | |||
| 34 | + | // command, the file path, the URL) that the TUI shows alongside | |||
| 35 | + | // the tool name. It is intentionally short: no full tool outputs. | |||
| 36 | + | type Confirmer interface { | |||
| 37 | + | Confirm(toolName string, preview string) ConfirmDecision | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // ConfirmGate wraps a Confirmer with session-scoped memory for the | |||
| 41 | + | // "allow, always" decisions. Once the user picks RememberTool on a | |||
| 42 | + | // given tool name, the gate short-circuits subsequent calls of that | |||
| 43 | + | // name. Once the user picks RememberAll, the gate short-circuits | |||
| 44 | + | // everything for the rest of the session. | |||
| 45 | + | // | |||
| 46 | + | // Safe for concurrent use: the allow-lists are guarded by a mutex | |||
| 47 | + | // because the agent can queue tool calls from different goroutines. | |||
| 48 | + | type ConfirmGate struct { | |||
| 49 | + | inner Confirmer | |||
| 50 | + | ||||
| 51 | + | mu sync.Mutex | |||
| 52 | + | allowAll bool | |||
| 53 | + | allowedTool map[string]bool | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // NewConfirmGate returns a gate backed by inner. When inner is nil, | |||
| 57 | + | // the gate operates in auto-refuse mode: every tool call is denied | |||
| 58 | + | // with a fixed reason. That's what non-interactive modes (-p / | |||
| 59 | + | // --json / rpc) use when --no-yolo is on, since they have no way | |||
| 60 | + | // to prompt the user. | |||
| 61 | + | func NewConfirmGate(inner Confirmer) *ConfirmGate { | |||
| 62 | + | return &ConfirmGate{ | |||
| 63 | + | inner: inner, | |||
| 64 | + | allowedTool: map[string]bool{}, | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Check is the BeforeToolExecute-style entry point. Returns | |||
| 69 | + | // allowed, reason, modifiedArgs. modifiedArgs is always nil: the | |||
| 70 | + | // gate never rewrites args; it only allows or denies. | |||
| 71 | + | // | |||
| 72 | + | // A nil ConfirmGate always allows (treat as yolo mode). | |||
| 73 | + | func (g *ConfirmGate) Check(toolName, preview string) (bool, string, json.RawMessage) { | |||
| 74 | + | if g == nil { | |||
| 75 | + | return true, "", nil | |||
| 76 | + | } | |||
| 77 | + | g.mu.Lock() | |||
| 78 | + | if g.allowAll { | |||
| 79 | + | g.mu.Unlock() | |||
| 80 | + | return true, "", nil | |||
| 81 | + | } | |||
| 82 | + | if g.allowedTool[toolName] { | |||
| 83 | + | g.mu.Unlock() | |||
| 84 | + | return true, "", nil | |||
| 85 | + | } | |||
| 86 | + | g.mu.Unlock() | |||
| 87 | + | ||||
| 88 | + | g.mu.Lock() | |||
| 89 | + | inner := g.inner | |||
| 90 | + | g.mu.Unlock() | |||
| 91 | + | if inner == nil { | |||
| 92 | + | return false, "tool call refused: --no-yolo is active and there is no interactive prompt in this mode; ask the user what to do instead", nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | decision := inner.Confirm(toolName, preview) | |||
| 96 | + | ||||
| 97 | + | g.mu.Lock() | |||
| 98 | + | if decision.Allow { | |||
| 99 | + | if decision.RememberAll { | |||
| 100 | + | g.allowAll = true | |||
| 101 | + | } | |||
| 102 | + | if decision.RememberTool { | |||
| 103 | + | g.allowedTool[toolName] = true | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | g.mu.Unlock() | |||
| 107 | + | ||||
| 108 | + | reason := strings.TrimSpace(decision.Reason) | |||
| 109 | + | if !decision.Allow && reason == "" { | |||
| 110 | + | reason = "tool call refused by user" | |||
| 111 | + | } | |||
| 112 | + | return decision.Allow, reason, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Reset clears the session memory. Invoked when the user toggles | |||
| 116 | + | // yolo mode back on via /yolo or closes the session. | |||
| 117 | + | func (g *ConfirmGate) Reset() { | |||
| 118 | + | if g == nil { | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | g.mu.Lock() | |||
| 122 | + | g.allowAll = false | |||
| 123 | + | g.allowedTool = map[string]bool{} | |||
| 124 | + | g.mu.Unlock() | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // AllowAll flips the gate into "always allow" for the rest of the | |||
| 128 | + | // session. Used by /yolo on to turn confirmation off at runtime | |||
| 129 | + | // without having to restart with the flag flipped. | |||
| 130 | + | func (g *ConfirmGate) AllowAll() { | |||
| 131 | + | if g == nil { | |||
| 132 | + | return | |||
| 133 | + | } | |||
| 134 | + | g.mu.Lock() | |||
| 135 | + | g.allowAll = true | |||
| 136 | + | g.mu.Unlock() | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // SetConfirmer replaces the inner Confirmer. Used by the cli to | |||
| 140 | + | // hand the Interactive TUI to a gate that was constructed before | |||
| 141 | + | // the TUI existed (a construction-order knot we unwind here). | |||
| 142 | + | // Safe to call from any goroutine. | |||
| 143 | + | func (g *ConfirmGate) SetConfirmer(c Confirmer) { | |||
| 144 | + | if g == nil { | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 147 | + | g.mu.Lock() | |||
| 148 | + | g.inner = c | |||
| 149 | + | g.mu.Unlock() | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // BuildPreview turns a tool call's JSON args into a short one-line | |||
| 153 | + | // summary the TUI can show in the confirmation prompt. Prioritises | |||
| 154 | + | // obvious human-readable fields (command, path, url) over raw JSON. | |||
| 155 | + | // Returns at most maxLen characters. | |||
| 156 | + | func BuildPreview(args json.RawMessage, maxLen int) string { | |||
| 157 | + | if maxLen <= 0 { | |||
| 158 | + | maxLen = 120 | |||
| 159 | + | } | |||
| 160 | + | if len(args) == 0 { | |||
| 161 | + | return "" | |||
| 162 | + | } | |||
| 163 | + | var m map[string]any | |||
| 164 | + | if err := json.Unmarshal(args, &m); err != nil { | |||
| 165 | + | return truncatePreview(string(args), maxLen) | |||
| 166 | + | } | |||
| 167 | + | for _, k := range []string{"command", "path", "file_path", "url", "query", "name"} { | |||
| 168 | + | if v, ok := m[k].(string); ok && v != "" { | |||
| 169 | + | return truncatePreview(v, maxLen) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | b, _ := json.Marshal(m) | |||
| 173 | + | return truncatePreview(string(b), maxLen) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func truncatePreview(s string, n int) string { | |||
| 177 | + | s = strings.TrimSpace(s) | |||
| 178 | + | if len(s) <= n { | |||
| 179 | + | return s | |||
| 180 | + | } | |||
| 181 | + | return s[:n-1] + "\u2026" | |||
| 182 | + | } | |||
A internal/core/confirm_test.go +184 -0
| @@ -0,0 +1,184 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type recordingConfirmer struct { | |
| 10 | + | mu sync.Mutex | |
| 11 | + | calls []string | |
| 12 | + | replies []ConfirmDecision | |
| 13 | + | idx int | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision { | |
| 17 | + | r.mu.Lock() | |
| 18 | + | defer r.mu.Unlock() | |
| 19 | + | r.calls = append(r.calls, toolName+"/"+preview) | |
| 20 | + | if r.idx >= len(r.replies) { | |
| 21 | + | return ConfirmDecision{Allow: false, Reason: "no reply queued"} | |
| 22 | + | } | |
| 23 | + | d := r.replies[r.idx] | |
| 24 | + | r.idx++ | |
| 25 | + | return d | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestConfirmGateNilAllowsEverything(t *testing.T) { | |
| 29 | + | var g *ConfirmGate | |
| 30 | + | allow, reason, args := g.Check("bash", "rm -rf /") | |
| 31 | + | if !allow || reason != "" || args != nil { | |
| 32 | + | t.Fatalf("nil gate should allow, got allow=%v reason=%q args=%s", allow, reason, args) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestConfirmGateNilInnerRefuses(t *testing.T) { | |
| 37 | + | g := NewConfirmGate(nil) | |
| 38 | + | allow, reason, _ := g.Check("bash", "ls") | |
| 39 | + | if allow { | |
| 40 | + | t.Fatal("gate with nil inner should refuse") | |
| 41 | + | } | |
| 42 | + | if reason == "" { | |
| 43 | + | t.Fatal("refusal must carry a reason for the model to learn from") | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestConfirmGateAllowOnce(t *testing.T) { | |
| 48 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |
| 49 | + | {Allow: true}, | |
| 50 | + | {Allow: true}, | |
| 51 | + | }} | |
| 52 | + | g := NewConfirmGate(rc) | |
| 53 | + | if allow, _, _ := g.Check("bash", "ls"); !allow { | |
| 54 | + | t.Fatal("call 1 should allow") | |
| 55 | + | } | |
| 56 | + | if allow, _, _ := g.Check("bash", "ls"); !allow { | |
| 57 | + | t.Fatal("call 2 should allow") | |
| 58 | + | } | |
| 59 | + | // Two calls, two confirmer invocations (no remember). | |
| 60 | + | if len(rc.calls) != 2 { | |
| 61 | + | t.Errorf("want 2 confirmer calls, got %d", len(rc.calls)) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestConfirmGateRememberTool(t *testing.T) { | |
| 66 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |
| 67 | + | {Allow: true, RememberTool: true}, | |
| 68 | + | }} | |
| 69 | + | g := NewConfirmGate(rc) | |
| 70 | + | ||
| 71 | + | // First call prompts and remembers. | |
| 72 | + | if allow, _, _ := g.Check("bash", "ls"); !allow { | |
| 73 | + | t.Fatal("call 1 should allow") | |
| 74 | + | } | |
| 75 | + | // Second call short-circuits; confirmer must NOT be invoked. | |
| 76 | + | if allow, _, _ := g.Check("bash", "pwd"); !allow { | |
| 77 | + | t.Fatal("call 2 should allow from memory") | |
| 78 | + | } | |
| 79 | + | // Different tool still prompts. | |
| 80 | + | rc.replies = append(rc.replies, ConfirmDecision{Allow: false, Reason: "no"}) | |
| 81 | + | if allow, reason, _ := g.Check("read", "foo.txt"); allow || reason != "no" { | |
| 82 | + | t.Errorf("different tool should re-prompt; got allow=%v reason=%q", allow, reason) | |
| 83 | + | } | |
| 84 | + | if len(rc.calls) != 2 { | |
| 85 | + | t.Errorf("want 2 confirmer calls (bash+read), got %d: %v", len(rc.calls), rc.calls) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestConfirmGateRememberAll(t *testing.T) { | |
| 90 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |
| 91 | + | {Allow: true, RememberAll: true}, | |
| 92 | + | }} | |
| 93 | + | g := NewConfirmGate(rc) | |
| 94 | + | if allow, _, _ := g.Check("bash", "ls"); !allow { | |
| 95 | + | t.Fatal("call 1 should allow") | |
| 96 | + | } | |
| 97 | + | // From now on everything short-circuits. | |
| 98 | + | if allow, _, _ := g.Check("read", "foo.txt"); !allow { | |
| 99 | + | t.Fatal("call 2 should allow") | |
| 100 | + | } | |
| 101 | + | if allow, _, _ := g.Check("write", "bar.txt"); !allow { | |
| 102 | + | t.Fatal("call 3 should allow") | |
| 103 | + | } | |
| 104 | + | if len(rc.calls) != 1 { | |
| 105 | + | t.Errorf("want 1 confirmer call (remember-all short-circuits the rest), got %d", len(rc.calls)) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestConfirmGateRefuseSurfacesReason(t *testing.T) { | |
| 110 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |
| 111 | + | {Allow: false, Reason: "do not run sudo"}, | |
| 112 | + | }} | |
| 113 | + | g := NewConfirmGate(rc) | |
| 114 | + | allow, reason, _ := g.Check("bash", "sudo rm -rf /") | |
| 115 | + | if allow || reason != "do not run sudo" { | |
| 116 | + | t.Errorf("want block + reason, got allow=%v reason=%q", allow, reason) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestConfirmGateRefuseEmptyReasonGetsDefault(t *testing.T) { | |
| 121 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |
| 122 | + | {Allow: false}, | |
| 123 | + | }} | |
| 124 | + | g := NewConfirmGate(rc) | |
| 125 | + | _, reason, _ := g.Check("bash", "x") | |
| 126 | + | if reason == "" { | |
| 127 | + | t.Fatal("empty reason must be replaced with a sensible default") | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestConfirmGateAllowAllRuntime(t *testing.T) { | |
| 132 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |
| 133 | + | {Allow: false, Reason: "no"}, | |
| 134 | + | }} | |
| 135 | + | g := NewConfirmGate(rc) | |
| 136 | + | // Refuse first call | |
| 137 | + | if allow, _, _ := g.Check("bash", "ls"); allow { | |
| 138 | + | t.Fatal("call 1 should refuse") | |
| 139 | + | } | |
| 140 | + | // User types /yolo | |
| 141 | + | g.AllowAll() | |
| 142 | + | // All subsequent calls allowed without prompting. | |
| 143 | + | if allow, _, _ := g.Check("bash", "rm -rf tmp"); !allow { | |
| 144 | + | t.Fatal("after AllowAll, should allow") | |
| 145 | + | } | |
| 146 | + | if allow, _, _ := g.Check("read", "x"); !allow { | |
| 147 | + | t.Fatal("after AllowAll, should allow any tool") | |
| 148 | + | } | |
| 149 | + | if len(rc.calls) != 1 { | |
| 150 | + | t.Errorf("want 1 confirmer call (before AllowAll), got %d", len(rc.calls)) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestBuildPreview(t *testing.T) { | |
| 155 | + | cases := []struct { | |
| 156 | + | name string | |
| 157 | + | args string | |
| 158 | + | want string | |
| 159 | + | }{ | |
| 160 | + | {"bash command", `{"command":"ls -la"}`, "ls -la"}, | |
| 161 | + | {"path", `{"path":"/tmp/x.txt"}`, "/tmp/x.txt"}, | |
| 162 | + | {"file_path", `{"file_path":"a.go"}`, "a.go"}, | |
| 163 | + | {"url", `{"url":"https://example.com"}`, "https://example.com"}, | |
| 164 | + | {"truncation", `{"command":"` + string(make([]byte, 200)) + `"}`, ""}, | |
| 165 | + | {"unparseable", `{not json`, `{not json`}, | |
| 166 | + | {"empty", ``, ``}, | |
| 167 | + | } | |
| 168 | + | for _, c := range cases { | |
| 169 | + | t.Run(c.name, func(t *testing.T) { | |
| 170 | + | got := BuildPreview(json.RawMessage(c.args), 50) | |
| 171 | + | if c.name == "truncation" && !hasEllipsis(got) { | |
| 172 | + | t.Errorf("%s: expected ellipsis truncation, got %q", c.name, got) | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | if c.want != "" && got != c.want { | |
| 176 | + | t.Errorf("%s: want %q, got %q", c.name, c.want, got) | |
| 177 | + | } | |
| 178 | + | }) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func hasEllipsis(s string) bool { | |
| 183 | + | return len(s) > 0 && s[len(s)-len("\u2026"):] == "\u2026" | |
| 184 | + | } | |
| @@ -0,0 +1,184 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | type recordingConfirmer struct { | |||
| 10 | + | mu sync.Mutex | |||
| 11 | + | calls []string | |||
| 12 | + | replies []ConfirmDecision | |||
| 13 | + | idx int | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision { | |||
| 17 | + | r.mu.Lock() | |||
| 18 | + | defer r.mu.Unlock() | |||
| 19 | + | r.calls = append(r.calls, toolName+"/"+preview) | |||
| 20 | + | if r.idx >= len(r.replies) { | |||
| 21 | + | return ConfirmDecision{Allow: false, Reason: "no reply queued"} | |||
| 22 | + | } | |||
| 23 | + | d := r.replies[r.idx] | |||
| 24 | + | r.idx++ | |||
| 25 | + | return d | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestConfirmGateNilAllowsEverything(t *testing.T) { | |||
| 29 | + | var g *ConfirmGate | |||
| 30 | + | allow, reason, args := g.Check("bash", "rm -rf /") | |||
| 31 | + | if !allow || reason != "" || args != nil { | |||
| 32 | + | t.Fatalf("nil gate should allow, got allow=%v reason=%q args=%s", allow, reason, args) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestConfirmGateNilInnerRefuses(t *testing.T) { | |||
| 37 | + | g := NewConfirmGate(nil) | |||
| 38 | + | allow, reason, _ := g.Check("bash", "ls") | |||
| 39 | + | if allow { | |||
| 40 | + | t.Fatal("gate with nil inner should refuse") | |||
| 41 | + | } | |||
| 42 | + | if reason == "" { | |||
| 43 | + | t.Fatal("refusal must carry a reason for the model to learn from") | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestConfirmGateAllowOnce(t *testing.T) { | |||
| 48 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |||
| 49 | + | {Allow: true}, | |||
| 50 | + | {Allow: true}, | |||
| 51 | + | }} | |||
| 52 | + | g := NewConfirmGate(rc) | |||
| 53 | + | if allow, _, _ := g.Check("bash", "ls"); !allow { | |||
| 54 | + | t.Fatal("call 1 should allow") | |||
| 55 | + | } | |||
| 56 | + | if allow, _, _ := g.Check("bash", "ls"); !allow { | |||
| 57 | + | t.Fatal("call 2 should allow") | |||
| 58 | + | } | |||
| 59 | + | // Two calls, two confirmer invocations (no remember). | |||
| 60 | + | if len(rc.calls) != 2 { | |||
| 61 | + | t.Errorf("want 2 confirmer calls, got %d", len(rc.calls)) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestConfirmGateRememberTool(t *testing.T) { | |||
| 66 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |||
| 67 | + | {Allow: true, RememberTool: true}, | |||
| 68 | + | }} | |||
| 69 | + | g := NewConfirmGate(rc) | |||
| 70 | + | ||||
| 71 | + | // First call prompts and remembers. | |||
| 72 | + | if allow, _, _ := g.Check("bash", "ls"); !allow { | |||
| 73 | + | t.Fatal("call 1 should allow") | |||
| 74 | + | } | |||
| 75 | + | // Second call short-circuits; confirmer must NOT be invoked. | |||
| 76 | + | if allow, _, _ := g.Check("bash", "pwd"); !allow { | |||
| 77 | + | t.Fatal("call 2 should allow from memory") | |||
| 78 | + | } | |||
| 79 | + | // Different tool still prompts. | |||
| 80 | + | rc.replies = append(rc.replies, ConfirmDecision{Allow: false, Reason: "no"}) | |||
| 81 | + | if allow, reason, _ := g.Check("read", "foo.txt"); allow || reason != "no" { | |||
| 82 | + | t.Errorf("different tool should re-prompt; got allow=%v reason=%q", allow, reason) | |||
| 83 | + | } | |||
| 84 | + | if len(rc.calls) != 2 { | |||
| 85 | + | t.Errorf("want 2 confirmer calls (bash+read), got %d: %v", len(rc.calls), rc.calls) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestConfirmGateRememberAll(t *testing.T) { | |||
| 90 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |||
| 91 | + | {Allow: true, RememberAll: true}, | |||
| 92 | + | }} | |||
| 93 | + | g := NewConfirmGate(rc) | |||
| 94 | + | if allow, _, _ := g.Check("bash", "ls"); !allow { | |||
| 95 | + | t.Fatal("call 1 should allow") | |||
| 96 | + | } | |||
| 97 | + | // From now on everything short-circuits. | |||
| 98 | + | if allow, _, _ := g.Check("read", "foo.txt"); !allow { | |||
| 99 | + | t.Fatal("call 2 should allow") | |||
| 100 | + | } | |||
| 101 | + | if allow, _, _ := g.Check("write", "bar.txt"); !allow { | |||
| 102 | + | t.Fatal("call 3 should allow") | |||
| 103 | + | } | |||
| 104 | + | if len(rc.calls) != 1 { | |||
| 105 | + | t.Errorf("want 1 confirmer call (remember-all short-circuits the rest), got %d", len(rc.calls)) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestConfirmGateRefuseSurfacesReason(t *testing.T) { | |||
| 110 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |||
| 111 | + | {Allow: false, Reason: "do not run sudo"}, | |||
| 112 | + | }} | |||
| 113 | + | g := NewConfirmGate(rc) | |||
| 114 | + | allow, reason, _ := g.Check("bash", "sudo rm -rf /") | |||
| 115 | + | if allow || reason != "do not run sudo" { | |||
| 116 | + | t.Errorf("want block + reason, got allow=%v reason=%q", allow, reason) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestConfirmGateRefuseEmptyReasonGetsDefault(t *testing.T) { | |||
| 121 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |||
| 122 | + | {Allow: false}, | |||
| 123 | + | }} | |||
| 124 | + | g := NewConfirmGate(rc) | |||
| 125 | + | _, reason, _ := g.Check("bash", "x") | |||
| 126 | + | if reason == "" { | |||
| 127 | + | t.Fatal("empty reason must be replaced with a sensible default") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func TestConfirmGateAllowAllRuntime(t *testing.T) { | |||
| 132 | + | rc := &recordingConfirmer{replies: []ConfirmDecision{ | |||
| 133 | + | {Allow: false, Reason: "no"}, | |||
| 134 | + | }} | |||
| 135 | + | g := NewConfirmGate(rc) | |||
| 136 | + | // Refuse first call | |||
| 137 | + | if allow, _, _ := g.Check("bash", "ls"); allow { | |||
| 138 | + | t.Fatal("call 1 should refuse") | |||
| 139 | + | } | |||
| 140 | + | // User types /yolo | |||
| 141 | + | g.AllowAll() | |||
| 142 | + | // All subsequent calls allowed without prompting. | |||
| 143 | + | if allow, _, _ := g.Check("bash", "rm -rf tmp"); !allow { | |||
| 144 | + | t.Fatal("after AllowAll, should allow") | |||
| 145 | + | } | |||
| 146 | + | if allow, _, _ := g.Check("read", "x"); !allow { | |||
| 147 | + | t.Fatal("after AllowAll, should allow any tool") | |||
| 148 | + | } | |||
| 149 | + | if len(rc.calls) != 1 { | |||
| 150 | + | t.Errorf("want 1 confirmer call (before AllowAll), got %d", len(rc.calls)) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestBuildPreview(t *testing.T) { | |||
| 155 | + | cases := []struct { | |||
| 156 | + | name string | |||
| 157 | + | args string | |||
| 158 | + | want string | |||
| 159 | + | }{ | |||
| 160 | + | {"bash command", `{"command":"ls -la"}`, "ls -la"}, | |||
| 161 | + | {"path", `{"path":"/tmp/x.txt"}`, "/tmp/x.txt"}, | |||
| 162 | + | {"file_path", `{"file_path":"a.go"}`, "a.go"}, | |||
| 163 | + | {"url", `{"url":"https://example.com"}`, "https://example.com"}, | |||
| 164 | + | {"truncation", `{"command":"` + string(make([]byte, 200)) + `"}`, ""}, | |||
| 165 | + | {"unparseable", `{not json`, `{not json`}, | |||
| 166 | + | {"empty", ``, ``}, | |||
| 167 | + | } | |||
| 168 | + | for _, c := range cases { | |||
| 169 | + | t.Run(c.name, func(t *testing.T) { | |||
| 170 | + | got := BuildPreview(json.RawMessage(c.args), 50) | |||
| 171 | + | if c.name == "truncation" && !hasEllipsis(got) { | |||
| 172 | + | t.Errorf("%s: expected ellipsis truncation, got %q", c.name, got) | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | if c.want != "" && got != c.want { | |||
| 176 | + | t.Errorf("%s: want %q, got %q", c.name, c.want, got) | |||
| 177 | + | } | |||
| 178 | + | }) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func hasEllipsis(s string) bool { | |||
| 183 | + | return len(s) > 0 && s[len(s)-len("\u2026"):] == "\u2026" | |||
| 184 | + | } | |||