Skip to content

[BUG] Queued message drain auto-approves DCG-protected commands (isProtected bypass) #1170

Description

@edelauna

Summary

When a Destructive Command Guard (DCG) protected command is waiting for user approval, the queued-message drain auto-approves it without checking isProtected. This bypasses the one safety layer DCG adds on top of alwaysAllowExecute.

Same root cause class as #998 (the message-queue drain in Task#ask), but this variant specifically defeats the DCG protection added in the new command guard.

Root cause

Task#ask() has two places that drain a queued user message and auto-approve a pending tool/command ask:

  • src/core/task/Task.ts:1352 (pre-block drain)
  • src/core/task/Task.ts:1385 (in-loop drain)

Both contain this branch:

} else if (isMessageQueued && shouldDrainQueuedMessageForAsk) {
    const message = this.messageQueueService.dequeueMessage()
    if (message) {
        if (type === "tool" || type === "command" || type === "use_mcp_server") {
            this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images)
        }
        ...
    }
}

handleWebviewAskResponse("yesButtonClicked") approves the ask directly. It never checks approval.isProtected.

Normally isProtected forces a real prompt: checkAutoApproval returns { decision: "ask" } early for protected commands (src/core/auto-approval/index.ts:119), even when alwaysAllowExecute === true && destructiveCommandGuardEnabled === true. But the drain path skips checkAutoApproval entirely, so a protected (DCG-blocked) command is auto-approved the moment a queued user message is drained.

Reproduction

  1. Enable alwaysAllowExecute: true and destructiveCommandGuardEnabled: true.
  2. Run a command that DCG flags as destructive (for example rm -rf). DCG sets isProtected = true, so the command ask blocks for a real prompt instead of auto-approving.
  3. While the ask is pending, send any user message (type in the chat input).
  4. The message is queued, then drained by the isMessageQueued branch. The drain calls handleWebviewAskResponse("yesButtonClicked"), which approves the destructive command without the intended prompt.

Expected: the protected command stays blocked until the user explicitly approves it.

Actual: the queued message auto-approves the protected command.

Suggested fix

Guard both drain sites with isProtected:

if (isMessageQueued && shouldDrainQueuedMessageForAsk && !isProtected) {

isProtected is already in scope at both sites (the ask() parameter, src/core/task/Task.ts:1152).

E2E test encoding

Reproducible as an e2e test using the fixture workflow in apps/vscode-e2e/AGENTS.md:

  • Start a task with mode: "ask", alwaysAllowExecute: true, and DCG enabled.
  • Turn 1 fixture (sequenceIndex: 0, match on unique prompt text) responds with one execute_command tool call whose command the DCG flags as destructive, giving it a unique id.
  • After the ask is pending, call api.sendMessage(<queued text>) to populate the message queue that triggers the drain.
  • Turn 2 fixture (match on the tool id from turn 1) responds with attempt_completion.
  • Assert the protected command was NOT auto-approved: the command must not execute unless the user explicitly approves. The test should fail on current code (drain auto-approves) and pass after the fix.

The challenge is forcing isProtected deterministically in e2e, since DCG is the real external binary. A test-only override, or a command the real DCG reliably flags, is needed; see AGENTS.md for the fixture-matching rules (substring match, no timestamps in match strings).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions