fix(no-yolo): don't auto-refuse tool calls in non-interactive modes
Previously --no-yolo in -p / --json / rpc modes auto-refused every
tool call. That made the flag dangerous to pass to scripts: a
single --no-yolo in a shell config or wrapper script would silently
break any tool-using prompt.
New behaviour:
- Default: every mode is yolo (tools run freely, no prompts).
- --no-yolo + interactive TUI: confirm dialog before each tool.
- --no-yolo + -p / --json / rpc: stderr warning and ignore the
flag. Tools run freely; scripts keep working.
The TUI confirm dialog and /yolo runtime toggle still work as
before. Also removed the unused wireNoYoloAutoRefuse helper and
simplified core.NewConfirmGate's doc comment.- commit
- e2f209247883cbc51baf392249c8ccdcd5717af1
- author
- patriceckhart · · 2026-04-19 17:17 UTC
- parent
- ac6d556
- signature
- unsigned
- refs
- tag: v0.0.43
5 files · +22 -36
M README.md +1 -1
| @@ -152,7 +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 | + | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). 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. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. Type `/yolo` in the TUI to disable the gate for the rest of the session. | | |
| 156 | 156 | ||
| 157 | 157 | ## Tools | |
| 158 | 158 | ||
| @@ -152,7 +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 | + | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). 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. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. Type `/yolo` in the TUI to disable the gate for the rest of the session. | |
| 156 | 156 | ||||
| 157 | ## Tools | 157 | ## Tools | ||
| 158 | 158 | ||||
M internal/agent/args.go +8 -5
| @@ -69,9 +69,12 @@ type Args struct { | |||
| 69 | 69 | // and waits for an explicit yes/no. The user can also pick | |
| 70 | 70 | // "always for this tool this session" or "always for anything | |
| 71 | 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. | |
| 72 | + | // (yolo mode): tools run without asking. | |
| 73 | + | // | |
| 74 | + | // No effect in -p / --json / rpc modes, which have no | |
| 75 | + | // interactive prompt. A warning is printed to stderr on startup | |
| 76 | + | // so scripts know the flag is ignored, but tools still run | |
| 77 | + | // freely so automated workflows keep working. | |
| 75 | 78 | NoYolo bool | |
| 76 | 79 | ||
| 77 | 80 | ListModels bool | |
| @@ -287,8 +290,8 @@ flags: | |||
| 287 | 290 | default: only built-in skills load | |
| 288 | 291 | ||
| 289 | 292 | --no-yolo ask before running every tool call | |
| 290 | - | (interactive mode only; in -p / --json | |
| 291 | - | / rpc this refuses every tool call) | |
| 293 | + | (interactive tui only; ignored with | |
| 294 | + | a stderr warning in -p / --json / rpc) | |
| 292 | 295 | ||
| 293 | 296 | --max-steps N agent loop iteration cap (default 50) | |
| 294 | 297 | --list-models print known models and exit | |
| @@ -69,9 +69,12 @@ type Args struct { | |||||
| 69 | // and waits for an explicit yes/no. The user can also pick | 69 | // and waits for an explicit yes/no. The user can also pick | ||
| 70 | // "always for this tool this session" or "always for anything | 70 | // "always for this tool this session" or "always for anything | ||
| 71 | // this session" to stop being prompted again. Defaults off | 71 | // this session" to stop being prompted again. Defaults off | ||
| 72 | - | // (yolo mode): tools run without asking. In -p / --json / rpc | 72 | + | // (yolo mode): tools run without asking. |
| 73 | - | // modes there's no interactive TUI, so --no-yolo auto-refuses | 73 | + | // |
| 74 | - | // every tool call with a reason the model can learn from. | 74 | + | // No effect in -p / --json / rpc modes, which have no |
| 75 | + | // interactive prompt. A warning is printed to stderr on startup | |||
| 76 | + | // so scripts know the flag is ignored, but tools still run | |||
| 77 | + | // freely so automated workflows keep working. | |||
| 75 | NoYolo bool | 78 | NoYolo bool | ||
| 76 | 79 | ||||
| 77 | ListModels bool | 80 | ListModels bool | ||
| @@ -287,8 +290,8 @@ flags: | |||||
| 287 | default: only built-in skills load | 290 | default: only built-in skills load | ||
| 288 | 291 | ||||
| 289 | --no-yolo ask before running every tool call | 292 | --no-yolo ask before running every tool call | ||
| 290 | - | (interactive mode only; in -p / --json | 293 | + | (interactive tui only; ignored with |
| 291 | - | / rpc this refuses every tool call) | 294 | + | a stderr warning in -p / --json / rpc) |
| 292 | 295 | ||||
| 293 | --max-steps N agent loop iteration cap (default 50) | 296 | --max-steps N agent loop iteration cap (default 50) | ||
| 294 | --list-models print known models and exit | 297 | --list-models print known models and exit | ||
M internal/agent/cli.go +6 -25
| @@ -184,12 +184,14 @@ func Run(rawArgs []string, version string) error { | |||
| 184 | 184 | // ---- print / json modes: require credentials, run single-shot ---- | |
| 185 | 185 | ||
| 186 | 186 | func runPrintMode(ctx context.Context, args Args, version string) error { | |
| 187 | + | if args.NoYolo { | |
| 188 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation") | |
| 189 | + | } | |
| 187 | 190 | r, err := Resolve(args, true) | |
| 188 | 191 | if err != nil { | |
| 189 | 192 | return err | |
| 190 | 193 | } | |
| 191 | 194 | ag := r.NewAgent() | |
| 192 | - | wireNoYoloAutoRefuse(ag, args) | |
| 193 | 195 | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 194 | 196 | defer sess.Close() | |
| 195 | 197 | ||
| @@ -208,36 +210,15 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 208 | 210 | return err | |
| 209 | 211 | } | |
| 210 | 212 | ||
| 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 | - | ||
| 234 | 213 | func runJSONMode(ctx context.Context, args Args, version string) error { | |
| 214 | + | if args.NoYolo { | |
| 215 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation") | |
| 216 | + | } | |
| 235 | 217 | r, err := Resolve(args, true) | |
| 236 | 218 | if err != nil { | |
| 237 | 219 | return err | |
| 238 | 220 | } | |
| 239 | 221 | ag := r.NewAgent() | |
| 240 | - | wireNoYoloAutoRefuse(ag, args) | |
| 241 | 222 | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 242 | 223 | defer sess.Close() | |
| 243 | 224 | ||
| @@ -184,12 +184,14 @@ func Run(rawArgs []string, version string) error { | |||||
| 184 | // ---- print / json modes: require credentials, run single-shot ---- | 184 | // ---- print / json modes: require credentials, run single-shot ---- | ||
| 185 | 185 | ||||
| 186 | func runPrintMode(ctx context.Context, args Args, version string) error { | 186 | func runPrintMode(ctx context.Context, args Args, version string) error { | ||
| 187 | + | if args.NoYolo { | |||
| 188 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation") | |||
| 189 | + | } | |||
| 187 | r, err := Resolve(args, true) | 190 | r, err := Resolve(args, true) | ||
| 188 | if err != nil { | 191 | if err != nil { | ||
| 189 | return err | 192 | return err | ||
| 190 | } | 193 | } | ||
| 191 | ag := r.NewAgent() | 194 | ag := r.NewAgent() | ||
| 192 | - | wireNoYoloAutoRefuse(ag, args) | |||
| 193 | sess, _ := openOrCreateSession(args, r, ag, version) | 195 | sess, _ := openOrCreateSession(args, r, ag, version) | ||
| 194 | defer sess.Close() | 196 | defer sess.Close() | ||
| 195 | 197 | ||||
| @@ -208,36 +210,15 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||||
| 208 | return err | 210 | return err | ||
| 209 | } | 211 | } | ||
| 210 | 212 | ||||
| 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 | - | ||||
| 234 | func runJSONMode(ctx context.Context, args Args, version string) error { | 213 | func runJSONMode(ctx context.Context, args Args, version string) error { | ||
| 214 | + | if args.NoYolo { | |||
| 215 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation") | |||
| 216 | + | } | |||
| 235 | r, err := Resolve(args, true) | 217 | r, err := Resolve(args, true) | ||
| 236 | if err != nil { | 218 | if err != nil { | ||
| 237 | return err | 219 | return err | ||
| 238 | } | 220 | } | ||
| 239 | ag := r.NewAgent() | 221 | ag := r.NewAgent() | ||
| 240 | - | wireNoYoloAutoRefuse(ag, args) | |||
| 241 | sess, _ := openOrCreateSession(args, r, ag, version) | 222 | sess, _ := openOrCreateSession(args, r, ag, version) | ||
| 242 | defer sess.Close() | 223 | defer sess.Close() | ||
| 243 | 224 | ||||
M internal/agent/rpc.go +3 -0
| @@ -40,6 +40,9 @@ import ( | |||
| 40 | 40 | // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be | |
| 41 | 41 | // {"type":"hello","token":"..."} or the connection is closed. | |
| 42 | 42 | func runRPCMode(ctx context.Context, args Args, version string) error { | |
| 43 | + | if args.NoYolo { | |
| 44 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in rpc mode (no interactive prompt available); tools will run without confirmation") | |
| 45 | + | } | |
| 43 | 46 | r, err := Resolve(args, true) | |
| 44 | 47 | if err != nil { | |
| 45 | 48 | return err | |
| @@ -40,6 +40,9 @@ import ( | |||||
| 40 | // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be | 40 | // Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be | ||
| 41 | // {"type":"hello","token":"..."} or the connection is closed. | 41 | // {"type":"hello","token":"..."} or the connection is closed. | ||
| 42 | func runRPCMode(ctx context.Context, args Args, version string) error { | 42 | func runRPCMode(ctx context.Context, args Args, version string) error { | ||
| 43 | + | if args.NoYolo { | |||
| 44 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in rpc mode (no interactive prompt available); tools will run without confirmation") | |||
| 45 | + | } | |||
| 43 | r, err := Resolve(args, true) | 46 | r, err := Resolve(args, true) | ||
| 44 | if err != nil { | 47 | if err != nil { | ||
| 45 | return err | 48 | return err | ||
M internal/core/confirm.go +4 -5
| @@ -53,11 +53,10 @@ type ConfirmGate struct { | |||
| 53 | 53 | allowedTool map[string]bool | |
| 54 | 54 | } | |
| 55 | 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. | |
| 56 | + | // NewConfirmGate returns a gate backed by inner. Inner can be nil; | |
| 57 | + | // in that case every not-yet-allowed tool call is refused with a | |
| 58 | + | // fixed reason (the gate is effectively a blocker until AllowAll / | |
| 59 | + | // SetConfirmer is called). | |
| 61 | 60 | func NewConfirmGate(inner Confirmer) *ConfirmGate { | |
| 62 | 61 | return &ConfirmGate{ | |
| 63 | 62 | inner: inner, | |
| @@ -53,11 +53,10 @@ type ConfirmGate struct { | |||||
| 53 | allowedTool map[string]bool | 53 | allowedTool map[string]bool | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | - | // NewConfirmGate returns a gate backed by inner. When inner is nil, | 56 | + | // NewConfirmGate returns a gate backed by inner. Inner can be nil; |
| 57 | - | // the gate operates in auto-refuse mode: every tool call is denied | 57 | + | // in that case every not-yet-allowed tool call is refused with a |
| 58 | - | // with a fixed reason. That's what non-interactive modes (-p / | 58 | + | // fixed reason (the gate is effectively a blocker until AllowAll / |
| 59 | - | // --json / rpc) use when --no-yolo is on, since they have no way | 59 | + | // SetConfirmer is called). |
| 60 | - | // to prompt the user. | |||
| 61 | func NewConfirmGate(inner Confirmer) *ConfirmGate { | 60 | func NewConfirmGate(inner Confirmer) *ConfirmGate { | ||
| 62 | return &ConfirmGate{ | 61 | return &ConfirmGate{ | ||
| 63 | inner: inner, | 62 | inner: inner, | ||