Repository navigation
Bug: Multiple execute_command tool calls in single request only ask permission for first command #998
Description
Bug: Multiple execute_command tool calls in single request only ask permission for first command
Description
When the AI sends multiple execute_command tool calls in a single request (e.g., 3 commands), Zoo Code processes them via a recursive loop (presentAssistantMessage.ts line 962). After the user approves the first command, all subsequent commands execute automatically without showing individual permission prompts.
This means if AI sends:
[
{"tool": "execute_command", "command": "echo \"cmd1\""},
{"tool": "execute_command", "command": "echo \"cmd2\""},
{"tool": "execute_command", "command": "echo \"cmd3\""}
]User sees prompt for cmd1 only. After clicking approve, all 3 commands execute immediately without further prompts — even with auto-approve completely disabled.
Root Cause Analysis
Code Flow
In src/core/assistant-message/presentAssistantMessage.ts, tool calls from a single AI request are processed via a recursive sequential loop:
// Line 962 - Recursive loop processes each tool call sequentially
return presentAssistantMessage(cline)Each execute_command tool call invokes askApproval("command", ...) which calls task.ask("tool", message) in Task.ts. The bug occurs because of two interacting mechanisms:
Mechanism 1 — Message Queue Auto-Approve (Line 1354-1368):
} else if (isMessageQueued && shouldDrainQueuedMessageForAsk) {
const message = this.messageQueueService.dequeueMessage()
if (message) {
// For tool approvals, auto-dequeue and approve!
if (type === "tool" || type === "command" || type === "use_mcp_server") {
this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images)
}
}
}Mechanism 2 — Shared Timestamp Check (Line 1374):
await pWaitFor(() => {
if (this.askResponse !== undefined || this.lastMessageTs !== askTs) {
return true // Skip waiting, use existing response
}
...
})Bug Flow
-
Command feat: support OAuth 2.1 for streamable-http MCP servers #1:
askApproval("command", "echo \"cmd1\"")→ displays prompt → user clicks approve →handleWebviewAskResponse("yesButtonClicked")setsthis.askResponse = "yesButtonClicked" -
Loop continues to command Roo to zoo upgrade #2 (line 962):
return presentAssistantMessage(cline) // Recursive sequential loop
-
Command Roo to zoo upgrade #2:
askApproval("command", "echo \"cmd2\"")→task.ask("tool", message)→ line 1256 resetsthis.askResponse = undefined→ BUT:- Line 1354: If a queued message exists (from the rapid iteration), it's auto-dequeued and handled as
"yesButtonClicked"— no prompt shown to user. - OR Line 1374: The
lastMessageTs !== askTscheck may pass if timestamps are too close together, causing the wait to be skipped entirely.
- Line 1354: If a queued message exists (from the rapid iteration), it's auto-dequeued and handled as
-
Result: Command Roo to zoo upgrade #2 (and all subsequent commands) return
"yesButtonClicked"without showing any prompt to user.
Key Suspects
messageQueueServiceauto-dequeue behavior (Task.tsline 1354-1368): Queued messages from rapid loop iteration are auto-approved for command tool calls- Loop iteration speed vs UI update delay: The recursive loop may iterate faster than the UI can display each prompt
askResponseglobal state: Shared across batch command tool calls, causing approval state to leak between iterations
Example
Input: AI sends 3x execute_command in one request
[
{"tool": "execute_command", "command": "echo \"cmd1\""},
{"tool": "execute_command", "command": "echo \"cmd2\""},
{"tool": "execute_command", "command": "echo \"cmd3\""}
]Current behavior:
- User sees: Prompt for
echo "cmd1"only - User clicks ✅ Approve
- All 3 commands execute immediately without further prompts
Expected behavior:
- Show prompt for each command individually
- Wait for user approval before executing each command
Affected Files
src/core/task/Task.ts— Line 1354-1368: Message queue auto-dequeue approves tool calls silently; Line 1374: Shared timestamp check may skip waitingsrc/core/assistant-message/presentAssistantMessage.ts— Line 962: Recursive loop processes batch tool calls sequentially without per-call UI delaysrc/core/tools/ExecuteCommandTool.ts— Line 109: Each call usesaskApproval("command", canonicalCommand)but may not display prompt due to queue/state issues
Suggested Fix Directions
Option A: Prevent message queue auto-dequeue for tool calls within the same batch. Only dequeue when the user explicitly queues a follow-up action, not during rapid sequential iteration.
Option B: Add a per-tool-use-id approval cache so that each individual command in a batch gets its own prompt and approval state, rather than relying on global askResponse.
Option C: Introduce a small delay or UI lock between tool calls in the same batch to ensure each prompt is displayed before the next iteration begins.
ref
Sometimes it runs the next command directly.
Activity
[-]Bug: Multi-line commands approved once, all sub-commands execute without individual permission checks[/-][+]Bug: Batch tool calls execute without individual permission checks after first approval[/+][-]Bug: Batch tool calls execute without individual permission checks after first approval[/-][+]Bug: Multiple execute_command tool calls in single request only ask permission for first command[/+]edelauna commentedon Jul 24, 2026
Detailed Root Cause Analysis & Path Forward
We completed a deep-dive investigation into the codebase to pinpoint why subsequent
execute_commandtool calls in a single response turn skip approval prompts or execute directly.Here is the exact breakdown of the underlying mechanisms and a clean path forward to fix it.
Root Causes
1. Message Queue Overloading (
Task.tslines 1354–1368 & 1380–1393)When a command is running in the VS Code terminal,
isStreamingistrueinChatView.tsx. If the user types any text into the chat input and hits Send/Enter while Command 1 is running, that text gets pushed intomessageQueueService.When Command 1 finishes and Command 2 initiates
task.ask("command", command2):task.ask()sees a message in the queue, dequeues it, and automatically approves Command 2 withyesButtonClicked. Queued conversational user text should never act as a permission grant token for unreviewed CLI execution.2. Missing
isAnsweredMarking for Command Asks (Task.tslines 1460–1474)In
handleWebviewAskResponse():Notice
msg.ask === "tool". Forexecute_commandcalls, the message type ismsg.ask === "command".Because
"command"does not match"tool", Command 1's ask message is never markedisAnswered = trueinclineMessages. When the updated message history is posted toChatView.tsxfor Command 2, the webview UI gets confused by multiple unanswered ask messages in sequence and fails to render the new approval buttons cleanly.3.
lastMessageTsMismatches & Silently SwallowedAskIgnoredError(Task.tslines 1399–1403)If any background progress update or system event invokes
task.say()without{ isNonInteractive: true }whiletask.ask()is waiting,this.lastMessageTsgets updated:In
presentAssistantMessage.ts,handleErrorsilently catchesAskIgnoredErrorand returns. This silently aborts Command 2 before it can display its prompt or execute, corrupting the tool execution turn and eventually triggering the mistake limit ("Proceed Anyways").Recommended Fix Plan (~20–30 lines)
Update
handleWebviewAskResponse()inTask.ts:Expand the
isAnswered = truecheck to include"command"and"use_mcp_server"alongside"tool":Restrict Message Queue Draining in
Task.ts:In
task.ask(), do NOT auto-approve tool/command security requests (type === "command" || type === "tool" || type === "use_mcp_server") when a message is queued. Queued user messages should only auto-fulfill conversational follow-ups (type === "followup").Audit Asynchronous
task.say()Calls:Ensure all background status or terminal output messages emitted during execution pass
{ isNonInteractive: true }so they don't overwritelastMessageTsand supersede pending asks.nh2 commentedon Jul 28, 2026
This should be a high-priority item.
It completely breaks Zoo's protection against prompt injection.
So far if you read commands before approving them, you'd be safe. Now you arent!
edelauna commentedon Aug 6, 2026
Reproduced — same root cause family, plus a webview-side mechanism
I reproduced this as a hang (not auto-execution): with parallel
execute_commandcalls, the ask for the second command is created on the extension host but never presented in the webview, so the task blocks forever with no Run Command button.Repro data (delegated child task, parallel native tool calls)
One assistant message contained 5 parallel tool calls:
update_todo_list+ 4×execute_command(git status --porcelain,git ls-files -u,ls -la .git/…,git log --oneline).ui_messages.jsonshows the exact stall:git status --porcelain) was auto-approved (isAnswered:true,autoApprovalDecision:"approve"), ran ~5s, completed cleanly.git ls-files -u) created anask commandatts …1822that was never answered and never rendered.command_outputsay for command 1 landed 16ms later, atts …1838.The task sat blocked in
Task.ask()'spWaitForwaiting for a response the UI never offers.Why the ask never appears (webview layer — not in the analysis above)
New messages reach the webview only via a full
statepush:Task.addToClineMessages()→ClineProvider.postStateToWebviewWithoutTaskHistory(). That push awaitsgetStateToPostToWebview(), which readsclineMessagesonly after several awaits (taskHistoryStore.initialized,getState(),CloudService, auth checks). By the time it snapshots the array, the immediately-followingcommand_outputsay is already in it.ChatViewdrives the approval buttons offlastMessage = messages.at(-1)(useEffectatChatView.tsx:283). Because the ask and the following say land in the same snapshot, the ask is never the last message,clineAskis never set to"command", and no Run Command button renders. The code already flags this fragility atChatView.tsx:162-165.Parallel calls are the only flow that puts an ask and a say ~16ms apart. Single-call flows block on the user for seconds, so the ask always wins the race and this never surfaced before.
Failing expectation: every parallel
execute_commandthat is not auto-approved must present its own approval ask and block until answered.Test design 1 — e2e (extension contract)
Reproduces the extension-side behaviour this issue is about. The exact webview render is better covered by design 2 (the e2e harness is extension-host only, no webview DOM).
Fixture — new
apps/vscode-e2e/src/fixtures/parallel-command-approval.ts, registered inrunTest.ts:{ userMessage: "PARALLEL_COMMANDS_APPROVAL_REPRO" }→ 3 parallelexecute_commandtool calls:git status(call_par_1),git ls-files -u(call_par_2),echo 'PARALLEL_CMD_3' >> parallel-cmd.txt(call_par_3).{ userMessage: "PARALLEL_CMD_3" }(echo's output inside the tool_result of the request that carries all three results) →attempt_completion.Test steps — new
apps/vscode-e2e/src/suite/tools/parallel-command-approval.test.ts, config{ mode:"code", autoApprovalEnabled:true, alwaysAllowExecute:true, allowedCommands:["git status"], terminalShellIntegrationDisabled:true }:startNewTaskwith textPARALLEL_COMMANDS_APPROVAL_REPRO.RooCodeEventName.Messageevents.waitForanaskmessage withask === "command"and text containinggit ls-files -u.git statusask isisAnswered:true/autoApprovalDecision:"approve"(auto-approved),git ls-files -uask is present,isAnsweredfalsy, noautoApprovalDecision(not auto-approved, not auto-executed),parallel-cmd.txtdoes not exist on disk (the echo command has not run),say error/ no mistake-limit reached.api.approveCurrentAsk()→git ls-files -uruns.waitFortheask commandfor the echo command → assert not auto-approved, file still absent →api.approveCurrentAsk().waitUntilCompleted.parallel-cmd.txtexists withPARALLEL_CMD_3; nosay error; everytool_use_id(call_par_1..3) has a matchingtool_result(no missing-tool_result protocol error).This is a regression anchor: on the broken build it fails whenever a later command auto-executes without a prompt (the message-queue path in
Task.ts:1354-1368) or produces a missingtool_result(the silently-swallowedAskIgnoredErrorpath).Test design 2 — webview-ui unit (exact reproducer of the hang)
In
webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx(or the mainChatView.spec.tsx):[ask command "git ls-files -u" (isAnswered: undefined), say command_output (partial: false)]— the exact 16ms interleave.clineAsk === "command"and the Run Command primary button is rendered and enabled.command_outputsay must not clear a pendingcommandask (only a pendingcommand_outputask, per the currentclineAskRef.current === "command_output"gate atChatView.tsx:470).Suggested fix directions
lastMessage. Find the latestaskmessage that is notisAnsweredand present its buttons regardless of what followed.command_outputsay can't mask it.This complements the existing analysis; the two layers (extension queue/isAnswered/AskIgnoredError + webview lastMessage race) should be fixed together.