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
- Enable
alwaysAllowExecute: true and destructiveCommandGuardEnabled: true.
- 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.
- While the ask is pending, send any user message (type in the chat input).
- 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).
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 ofalwaysAllowExecute.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:
handleWebviewAskResponse("yesButtonClicked")approves the ask directly. It never checksapproval.isProtected.Normally
isProtectedforces a real prompt:checkAutoApprovalreturns{ decision: "ask" }early for protected commands (src/core/auto-approval/index.ts:119), even whenalwaysAllowExecute === true && destructiveCommandGuardEnabled === true. But the drain path skipscheckAutoApprovalentirely, so a protected (DCG-blocked) command is auto-approved the moment a queued user message is drained.Reproduction
alwaysAllowExecute: trueanddestructiveCommandGuardEnabled: true.rm -rf). DCG setsisProtected = true, so the command ask blocks for a real prompt instead of auto-approving.isMessageQueuedbranch. The drain callshandleWebviewAskResponse("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:isProtectedis already in scope at both sites (theask()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:mode: "ask",alwaysAllowExecute: true, and DCG enabled.sequenceIndex: 0, match on unique prompt text) responds with oneexecute_commandtool call whosecommandthe DCG flags as destructive, giving it a uniqueid.api.sendMessage(<queued text>)to populate the message queue that triggers the drain.idfrom turn 1) responds withattempt_completion.The challenge is forcing
isProtecteddeterministically 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).