Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1791,9 +1791,14 @@
// has to see and stalling a hands-free session. Leaving the message
// unclaimed lets the policy decision stand and keeps the message queued.
const queueMayAnswerThisAsk = !(blanketDenyEngaged && type === "command")
// A queued message must not short-circuit protected asks (e.g.
// DCG-blocked commands): claiming one here skips checkAutoApproval, and
// the drain below would auto-approve what protection intentionally
// leaves pending for explicit user approval.
const queuedMessage =
partial === true ||
type === "command_output" ||
isProtected ||
!queueMayAnswerThisAsk ||
!this.mayDrainQueuedMessageForAsk()
? undefined
Expand Down Expand Up @@ -2186,10 +2191,13 @@
// suggestion click that was incorrectly queued due to UI state), consume it
// immediately so the task doesn't hang. Command asks under blanket deny are
// excluded (`queueMayAnswerThisAsk`): a queued message must never stand in
// for the explicit approval the policy withheld.
// for the explicit approval the policy withheld. Protected asks are exempt
// too: like the pre-block drain above, they must wait for explicit user
// approval.
if (
queueMayAnswerThisAsk &&
shouldDrainQueuedMessageForAsk &&
!isProtected &&

Check warning on line 2200 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:2200: Survived BooleanLiteral mutant (replacement: isProtected). See the job summary for the complete list and resolution guidance.
!queuedCommandPolicyCheck &&
this.mayDrainQueuedMessageForAsk()
) {
Expand Down
114 changes: 114 additions & 0 deletions src/core/task/__tests__/ask-queued-message-drain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,120 @@ describe("Task.ask queued message drain", () => {
expect(task.messageQueueService.isEmpty()).toBe(true)
})

it("does not auto-approve a protected command ask from a queued message", async () => {
const task = await createTask({
getState: async () => ({ autoApprovalEnabled: true, alwaysAllowExecute: true }),
})
task.messageQueueService.addMessage("run it anyway")

const ask = task.ask("command", "rm -rf build", false, undefined, true)

// The drain must not resolve the protected ask; only an explicit user
// approval may.
const premature = await Promise.race([
ask,
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 250)),
])
expect(premature).toBe("pending")
expect(task.messageQueueService.isEmpty()).toBe(false)

task.approveAsk()
const result = await ask
expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined })
// claimNextMessage only returns unclaimed messages: the queued message must
// still be present and unclaimed after the protected ask resolved.
expect(task.messageQueueService.claimNextMessage()?.text).toBe("run it anyway")
})

it("does not auto-approve a protected command ask when a message is queued mid-wait", async () => {
const task = await createTask({
getState: async () => ({ autoApprovalEnabled: true, alwaysAllowExecute: true }),
})

const ask = task.ask("command", "rm -rf build", false, undefined, true)
task.messageQueueService.addMessage("mid-wait message")

const premature = await Promise.race([
ask,
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 250)),
])
expect(premature).toBe("pending")
expect(task.messageQueueService.isEmpty()).toBe(false)

task.approveAsk()
const result = await ask
expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined })
// claimNextMessage only returns unclaimed messages: the queued message must
// still be present and unclaimed after the protected ask resolved.
expect(task.messageQueueService.claimNextMessage()?.text).toBe("mid-wait message")
})

// The drain guards key on the isProtected flag, so they also cover protected
// write-tool asks (WriteToFileTool/EditTool/ApplyDiff/ApplyPatch mark blocked
// writes protected via presentAssistantMessage). These mirror the command pair
// above: alwaysAllowWrite covers the write, but without alwaysAllowWriteProtected
// the protection keeps the ask pending, and a queued message must not stand in
// for the explicit approval.
it("does not auto-approve a protected write-tool ask from a queued message", async () => {
const task = await createTask({
getState: async () => ({ autoApprovalEnabled: true, alwaysAllowWrite: true }),
})
task.messageQueueService.addMessage("write it anyway")

const ask = task.ask(
"tool",
JSON.stringify({ tool: "newFileCreated", path: "AGENTS.md" }),
false,
undefined,
true,
)

// The drain must not resolve the protected ask; only an explicit user
// approval may.
const premature = await Promise.race([
ask,
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 250)),
])
expect(premature).toBe("pending")
expect(task.messageQueueService.isEmpty()).toBe(false)

task.approveAsk()
const result = await ask
expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined })
// claimNextMessage only returns unclaimed messages: the queued message must
// still be present and unclaimed after the protected ask resolved.
expect(task.messageQueueService.claimNextMessage()?.text).toBe("write it anyway")
})

it("does not auto-approve a protected write-tool ask when a message is queued mid-wait", async () => {
const task = await createTask({
getState: async () => ({ autoApprovalEnabled: true, alwaysAllowWrite: true }),
})

const ask = task.ask(
"tool",
JSON.stringify({ tool: "newFileCreated", path: "AGENTS.md" }),
false,
undefined,
true,
)
task.messageQueueService.addMessage("mid-wait message")

const premature = await Promise.race([
ask,
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 250)),
])
expect(premature).toBe("pending")
expect(task.messageQueueService.isEmpty()).toBe(false)

task.approveAsk()
const result = await ask
expect(result).toMatchObject({ response: "yesButtonClicked", text: undefined })
// claimNextMessage only returns unclaimed messages: the queued message must
// still be present and unclaimed after the protected ask resolved.
expect(task.messageQueueService.claimNextMessage()?.text).toBe("mid-wait message")
})

it("claims lifecycle feedback that arrives while an ask is waiting", async () => {
const task = await createTask()
const ask = task.ask("tool", JSON.stringify({ tool: "finishTask" }), false)
Expand Down
Loading