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 },