From 2c3303141b734e825ac6dcc8fd863a7e3fbd2e79 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 04:44:02 +0900 Subject: [PATCH] fix(responses): notice an unreadable MESSAGE reply, not just NEW_TASK #3021: after an adapter_eof, a delegated subagent's MESSAGE reply reached the parent conversation as a raw gAAAA... payload instead of plaintext or a structured error. hasUnreadableEncryptedAgentTask decides "unreadable" by stripping the routing envelope and asking whether any plaintext survives. AGENT_MESSAGE_ROUTING_ENVELOPE matched only NEW_TASK, so a MESSAGE header was never stripped and counted as surviving text -- a reply whose entire body was one Fernet token measured as READABLE and was forwarded verbatim. Measured on dev before the change, with a structurally valid Fernet token: NEW_TASK -> true (detected) MESSAGE -> false (#3021's case, forwarded) and after: NEW_TASK -> true MESSAGE -> true This is the DETECTION half only. Recovery stays NEW_TASK-only, deliberately: recoverEncryptedAgentTask decrypts, and decrypting a MESSAGE on the parent's behalf would build a plaintext oracle out of a payload the parent's session may have no entitlement to read. Widening the strip only lets the proxy notice that what it is about to forward is unreadable ciphertext, which is what the report asks for -- fail closed rather than paste the token. Mutation-checked: reverting the pattern to NEW_TASK-only fails exactly the two new MESSAGE tests (21 pass / 2 fail). The control test -- a MESSAGE reply that carries real text alongside a token -- stays readable in both directions, so this does not turn every agent reply into a blocked one. Closes #3021. --- src/server/responses/encrypted-payload.ts | 19 +++++++++- tests/v2-agent-message-failfast.test.ts | 44 +++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 9de44cd2a2..3df72f769a 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -175,7 +175,24 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[]) return `${text}${payload.slice(last)}`; } -export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*NEW_TASK[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi; +/** + * The routing header codex-rs writes above a delegated agent payload. + * + * `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK -- + * recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the + * parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector + * decides "unreadable" by stripping the envelope and asking whether any plaintext + * survives, so an envelope shape it does not recognise counts as surviving text: a + * `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded + * verbatim. + * + * Widening the strip is not the same as widening recovery. Recovery decrypts, and + * decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a + * payload the parent's session may have no right to read. This only lets the proxy + * NOTICE that what it is about to forward is unreadable ciphertext, which is what the + * report asks for: fail closed with a structured error rather than paste the token. + */ +export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi; // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph: // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are diff --git a/tests/v2-agent-message-failfast.test.ts b/tests/v2-agent-message-failfast.test.ts index 5501c3362b..9e218dec11 100644 --- a/tests/v2-agent-message-failfast.test.ts +++ b/tests/v2-agent-message-failfast.test.ts @@ -32,6 +32,10 @@ const ROUTING_ENVELOPE = [ "", ].join("\n"); +// The same envelope a delegated agent uses to REPLY, as opposed to being spawned. +// #3021 saw one of these reach the parent conversation as raw `gAAAA...` text. +const MESSAGE_ROUTING_ENVELOPE = ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE"); + afterEach(() => { globalThis.fetch = originalFetch; }); @@ -135,6 +139,46 @@ describe("V2 routed agent-message ciphertext guard", () => { ]))).toBe(true); }); + /** + * #3021: a delegated subagent's MESSAGE reply reached the parent conversation as + * raw `gAAAA...` ciphertext after an `adapter_eof`. + * + * The detector decides "unreadable" by stripping the routing envelope and asking + * whether any plaintext survives, so an envelope shape it does not recognise counts + * as surviving text. The envelope pattern matched only NEW_TASK, so a MESSAGE whose + * entire body was one Fernet token measured as READABLE and was forwarded verbatim. + * + * This is the detection half only. Recovery stays NEW_TASK-only on purpose: + * decrypting a MESSAGE on the parent's behalf would build a plaintext oracle out of + * a payload the parent's session may not be entitled to read. + */ + test("blocks a MESSAGE reply envelope followed only by a Fernet payload", () => { + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: MESSAGE_ROUTING_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(true); + }); + + test("blocks a MESSAGE envelope carried inside the encrypted slot itself", () => { + // The shape the report describes: header and ciphertext arrive as one + // encrypted_content string rather than as separate parts. + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { + type: "encrypted_content", + encrypted_content: `${MESSAGE_ROUTING_ENVELOPE}${FERNET_TASK}`, + }, + ]))).toBe(true); + }); + + test("a MESSAGE reply that carries real text stays readable", () => { + // The control. Widening the envelope must not turn every agent reply into a + // blocked one -- only the ones with nothing left after the header comes off. + expect(hasUnreadableEncryptedAgentTask(agentMessage([ + { type: "input_text", text: `${MESSAGE_ROUTING_ENVELOPE}the worker finished the migration` }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]))).toBe(false); + }); + test("blocks a control preamble mixed into the Fernet slot before sanitization", async () => { const input = agentMessage([ { type: "input_text", text: ROUTING_ENVELOPE },