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
19 changes: 18 additions & 1 deletion src/server/responses/encrypted-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions tests/v2-agent-message-failfast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down Expand Up @@ -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 },
Expand Down
Loading