Skip to content

Bug: Multiple execute_command tool calls in single request only ask permission for first command #998

Description

@easonLiangWorldedtech

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

  1. Command feat: support OAuth 2.1 for streamable-http MCP servers #1: askApproval("command", "echo \"cmd1\"") → displays prompt → user clicks approve → handleWebviewAskResponse("yesButtonClicked") sets this.askResponse = "yesButtonClicked"

  2. Loop continues to command Roo to zoo upgrade #2 (line 962):

    return presentAssistantMessage(cline) // Recursive sequential loop
  3. Command Roo to zoo upgrade #2: askApproval("command", "echo \"cmd2\"") → task.ask("tool", message) → line 1256 resets this.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 !== askTs check may pass if timestamps are too close together, causing the wait to be skipped entirely.
  4. Result: Command Roo to zoo upgrade #2 (and all subsequent commands) return "yesButtonClicked" without showing any prompt to user.

Key Suspects

  • messageQueueService auto-dequeue behavior (Task.ts line 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
  • askResponse global 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 waiting
  • src/core/assistant-message/presentAssistantMessage.ts — Line 962: Recursive loop processes batch tool calls sequentially without per-call UI delay
  • src/core/tools/ExecuteCommandTool.ts — Line 109: Each call uses askApproval("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.

20260724-1713-25.7491772.mp4

Activity

  1. 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
  2. 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
  3. edelauna commented on Jul 24, 2026

    @edelauna
    Contributor

    Detailed Root Cause Analysis & Path Forward

    We completed a deep-dive investigation into the codebase to pinpoint why subsequent execute_command tool 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.ts lines 1354–1368 & 1380–1393)

    When a command is running in the VS Code terminal, isStreaming is true in ChatView.tsx. If the user types any text into the chat input and hits Send/Enter while Command 1 is running, that text gets pushed into messageQueueService.

    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 with yesButtonClicked. Queued conversational user text should never act as a permission grant token for unreviewed CLI execution.

    2. Missing isAnswered Marking for Command Asks (Task.ts lines 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". For execute_command calls, the message type is msg.ask === "command".
    Because "command" does not match "tool", Command 1's ask message is never marked isAnswered = true in clineMessages. When the updated message history is posted to ChatView.tsx for Command 2, the webview UI gets confused by multiple unanswered ask messages in sequence and fails to render the new approval buttons cleanly.

    3. lastMessageTs Mismatches & Silently Swallowed AskIgnoredError (Task.ts lines 1399–1403)

    If any background progress update or system event invokes task.say() without { isNonInteractive: true } while task.ask() is waiting, this.lastMessageTs gets updated:

    if (this.lastMessageTs !== askTs) {
        throw new AskIgnoredError("superseded")
    }

    In presentAssistantMessage.ts, handleError silently catches AskIgnoredError and 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)

    1. Update handleWebviewAskResponse() in Task.ts:
      Expand the isAnswered = true check 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()
          }
      }
    2. 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").

    3. Audit Asynchronous task.say() Calls:
      Ensure all background status or terminal output messages emitted during execution pass { isNonInteractive: true } so they don't overwrite lastMessageTs and supersede pending asks.

  4. nh2 commented on Jul 28, 2026

    @nh2
    Contributor

    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!

  5. edelauna commented on Aug 6, 2026

    @edelauna
    Contributor

    Reproduced — same root cause family, plus a webview-side mechanism

    I reproduced this as a hang (not auto-execution): with parallel execute_command calls, 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.json shows 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 an ask command at ts …1822 that was never answered and never rendered.
    • The final command_output say for command 1 landed 16ms later, at ts …1838.

    The task sat blocked in Task.ask()'s pWaitFor waiting 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 state push: Task.addToClineMessages() → ClineProvider.postStateToWebviewWithoutTaskHistory(). That push awaits getStateToPostToWebview(), which reads clineMessages only after several awaits (taskHistoryStore.initialized, getState(), CloudService, auth checks). By the time it snapshots the array, the immediately-following command_output say is already in it.

    ChatView drives the approval buttons off lastMessage = messages.at(-1) (useEffect at ChatView.tsx:283). Because the ask and the following say land in the same snapshot, the ask is never the last message, clineAsk is never set to "command", and no Run Command button renders. The code already flags this fragility at ChatView.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_command that 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 in runTest.ts:

    • Turn 1: match { userMessage: "PARALLEL_COMMANDS_APPROVAL_REPRO" } → 3 parallel execute_command tool 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 }:

    1. startNewTask with text PARALLEL_COMMANDS_APPROVAL_REPRO.
    2. Collect RooCodeEventName.Message events.
    3. waitFor an ask message with ask === "command" and text containing git ls-files -u.
    4. Assert:
      • the git status ask is isAnswered:true / autoApprovalDecision:"approve" (auto-approved),
      • the git ls-files -u ask is present, isAnswered falsy, no autoApprovalDecision (not auto-approved, not auto-executed),
      • parallel-cmd.txt does not exist on disk (the echo command has not run),
      • no say error / no mistake-limit reached.
    5. api.approveCurrentAsk() → git ls-files -u runs.
    6. waitFor the ask command for the echo command → assert not auto-approved, file still absent → api.approveCurrentAsk().
    7. waitUntilCompleted.
    8. Assert: parallel-cmd.txt exists with PARALLEL_CMD_3; no say error; every tool_use_id (call_par_1..3) has a matching tool_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 missing tool_result (the silently-swallowed AskIgnoredError path).

    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 main ChatView.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_output say must not clear a pending command ask (only a pending command_output ask, per the current clineAskRef.current === "command_output" gate at ChatView.tsx:470).

    Suggested fix directions

    1. Webview (robust): stop deriving the pending approval from lastMessage. Find the latest ask message that is not isAnswered and present its buttons regardless of what followed.
    2. 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_output say can't mask it.

    This complements the existing analysis; the two layers (extension queue/isAnswered/AskIgnoredError + webview lastMessage race) should be fixed together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions