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.
This commit is contained in:
patriceckhart 2026-04-19 19:17:05 +02:00
parent ac6d556f0a
commit e2f2092478
5 changed files with 22 additions and 36 deletions

View file

@ -152,7 +152,7 @@ zot --help
| `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. |
| `--with-skills` | Also load user-installed skills. Without this, only the built-in skills shipped in the binary are loaded. |
| `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. |
| `--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. |
| `--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. |
## Tools

View file

@ -69,9 +69,12 @@ type Args struct {
// and waits for an explicit yes/no. The user can also pick
// "always for this tool this session" or "always for anything
// this session" to stop being prompted again. Defaults off
// (yolo mode): tools run without asking. In -p / --json / rpc
// modes there's no interactive TUI, so --no-yolo auto-refuses
// every tool call with a reason the model can learn from.
// (yolo mode): tools run without asking.
//
// No effect in -p / --json / rpc modes, which have no
// interactive prompt. A warning is printed to stderr on startup
// so scripts know the flag is ignored, but tools still run
// freely so automated workflows keep working.
NoYolo bool
ListModels bool
@ -287,8 +290,8 @@ flags:
default: only built-in skills load
--no-yolo ask before running every tool call
(interactive mode only; in -p / --json
/ rpc this refuses every tool call)
(interactive tui only; ignored with
a stderr warning in -p / --json / rpc)
--max-steps N agent loop iteration cap (default 50)
--list-models print known models and exit

View file

@ -184,12 +184,14 @@ func Run(rawArgs []string, version string) error {
// ---- print / json modes: require credentials, run single-shot ----
func runPrintMode(ctx context.Context, args Args, version string) error {
if args.NoYolo {
fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
}
r, err := Resolve(args, true)
if err != nil {
return err
}
ag := r.NewAgent()
wireNoYoloAutoRefuse(ag, args)
sess, _ := openOrCreateSession(args, r, ag, version)
defer sess.Close()
@ -208,36 +210,15 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
return err
}
// wireNoYoloAutoRefuse installs a BeforeToolExecute hook that
// refuses every tool call when --no-yolo is active but there's no
// interactive UI to prompt (print / json / rpc modes). The reason
// is written in a way the model can learn from so it proposes a
// different action rather than looping on the same tool.
func wireNoYoloAutoRefuse(ag *core.Agent, args Args) {
if !args.NoYolo || ag == nil {
return
}
gate := core.NewConfirmGate(nil) // nil inner = auto-refuse
prev := ag.BeforeToolExecute
ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
ok, reason, _ := gate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
if !ok {
return false, reason, nil
}
if prev != nil {
return prev(call)
}
return true, "", nil
}
}
func runJSONMode(ctx context.Context, args Args, version string) error {
if args.NoYolo {
fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
}
r, err := Resolve(args, true)
if err != nil {
return err
}
ag := r.NewAgent()
wireNoYoloAutoRefuse(ag, args)
sess, _ := openOrCreateSession(args, r, ag, version)
defer sess.Close()

View file

@ -40,6 +40,9 @@ import (
// Auth: if $ZOTCORE_RPC_TOKEN is set, the first command must be
// {"type":"hello","token":"..."} or the connection is closed.
func runRPCMode(ctx context.Context, args Args, version string) error {
if args.NoYolo {
fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in rpc mode (no interactive prompt available); tools will run without confirmation")
}
r, err := Resolve(args, true)
if err != nil {
return err

View file

@ -53,11 +53,10 @@ type ConfirmGate struct {
allowedTool map[string]bool
}
// NewConfirmGate returns a gate backed by inner. When inner is nil,
// the gate operates in auto-refuse mode: every tool call is denied
// with a fixed reason. That's what non-interactive modes (-p /
// --json / rpc) use when --no-yolo is on, since they have no way
// to prompt the user.
// NewConfirmGate returns a gate backed by inner. Inner can be nil;
// in that case every not-yet-allowed tool call is refused with a
// fixed reason (the gate is effectively a blocker until AllowAll /
// SetConfirmer is called).
func NewConfirmGate(inner Confirmer) *ConfirmGate {
return &ConfirmGate{
inner: inner,