Repository navigation
Bug: Multiple execute_command tool calls in single request only ask permission for first command #998
Description
Activity
- changed the title
[-]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[/+]on Jul 24, 2026 - changed the title
[-]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[/+]on 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):} else if (isMessageQueued && shouldDrainQueuedMessageForAsk) { const message = this.messageQueueService.dequeueMessage() if (message) { if (type === "tool" || type === "command" || type === "use_mcp_server") { // Automatically approves Command 2 using the queued user text! this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images) } } }
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():if (askResponse === "yesButtonClicked") { const lastToolAskIndex = findLastIndex( this.clineMessages, (msg) => msg.type === "ask" && msg.ask === "tool" && !msg.isAnswered, ) if (lastToolAskIndex !== -1) { this.clineMessages[lastToolAskIndex].isAnswered = true ... } }
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:if (this.lastMessageTs !== askTs) { throw new AskIgnoredError("superseded") }
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 theisAnswered = truecheck to include"command"and"use_mcp_server"alongside"tool":if (askResponse === "yesButtonClicked") { const lastToolAskIndex = findLastIndex( this.clineMessages, (msg) => msg.type === "ask" && (msg.ask === "tool" || msg.ask === "command" || msg.ask === "use_mcp_server") && !msg.isAnswered, ) if (lastToolAskIndex !== -1) { this.clineMessages[lastToolAskIndex].isAnswered = true void this.updateClineMessage(this.clineMessages[lastToolAskIndex]) this.saveClineMessages() } }
-
Restrict Message Queue Draining in
Task.ts:
Intask.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.
-
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!
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:- Command 1 (
git status --porcelain) was auto-approved (isAnswered:true,autoApprovalDecision:"approve"), ran ~5s, completed cleanly. - Command 2 (
git ls-files -u) created anask commandatts …1822that was never answered and never rendered. - The final
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:- Turn 1: match
{ 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). - Turn 2: match
{ 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.- Collect
RooCodeEventName.Messageevents. waitForanaskmessage withask === "command"and text containinggit ls-files -u.- Assert:
- the
git statusask isisAnswered:true/autoApprovalDecision:"approve"(auto-approved), - the
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),- no
say error/ no mistake-limit reached.
- the
api.approveCurrentAsk()→git ls-files -uruns.waitFortheask commandfor the echo command → assert not auto-approved, file still absent →api.approveCurrentAsk().waitUntilCompleted.- Assert:
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):- Render ChatView with messages ending in
[ask command "git ls-files -u" (isAnswered: undefined), say command_output (partial: false)]— the exact 16ms interleave. - Assert
clineAsk === "command"and the Run Command primary button is rendered and enabled. - Regression assertion: a
command_outputsay must not clear a pendingcommandask (only a pendingcommand_outputask, per the currentclineAskRef.current === "command_output"gate atChatView.tsx:470).
Suggested fix directions
- Webview (robust): stop deriving the pending approval from
lastMessage. Find the latestaskmessage that is notisAnsweredand present its buttons regardless of what followed. - Extension (defensive): don't let the full-state push for an ask be overtaken — either send new asks via an appending incremental message, or guard ask presentation so a following
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.
- Command 1 (
Bug: Multiple execute_command tool calls in single request only ask permission for first command
Description
When the AI sends multiple
execute_commandtool calls in a single request (e.g., 3 commands), Zoo Code processes them via a recursive loop (presentAssistantMessage.tsline 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
cmd1only. 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:Each
execute_commandtool call invokesaskApproval("command", ...)which callstask.ask("tool", message)inTask.ts. The bug occurs because of two interacting mechanisms:Mechanism 1 — Message Queue Auto-Approve (Line 1354-1368):
Mechanism 2 — Shared Timestamp Check (Line 1374):
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):
Command Roo to zoo upgrade #2:
askApproval("command", "echo \"cmd2\"")→task.ask("tool", message)→ line 1256 resetsthis.askResponse = undefined→ BUT:"yesButtonClicked"— no prompt shown to user.lastMessageTs !== askTscheck may pass if timestamps are too close together, causing the wait to be skipped entirely.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 callsaskResponseglobal state: Shared across batch command tool calls, causing approval state to leak between iterationsExample
Input: AI sends 3x
execute_commandin one request[ {"tool": "execute_command", "command": "echo \"cmd1\""}, {"tool": "execute_command", "command": "echo \"cmd2\""}, {"tool": "execute_command", "command": "echo \"cmd3\""} ]Current behavior:
echo "cmd1"onlyExpected behavior:
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 issuesSuggested 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.
20260724-1713-25.7491772.mp4