From 88dab563ba12172e66b4eb2778f89eaa785918cc Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:05:19 +0900 Subject: [PATCH] fix(responses): avoid recursive encrypted payload traversal --- src/server/responses/encrypted-payload.ts | 96 ++++++++++++++--------- tests/multi-agent-compat.test.ts | 52 +++++++++++- 2 files changed, 109 insertions(+), 39 deletions(-) diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 70e4473c223..9de44cd2a2d 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -265,47 +265,67 @@ export function hasEncryptedContentPart(content: unknown): boolean { export function sanitizeEncryptedContentInPlace(input: unknown): number { if (!Array.isArray(input)) return 0; let rewritten = 0; - const visit = (node: unknown): number => { - const before = rewritten; - if (Array.isArray(node)) { - for (let i = 0; i < node.length; i += 1) { - const child = node[i] as unknown; - if ( - child && typeof child === "object" - && (child as { type?: unknown }).type === "encrypted_content" - && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string" - ) { - const payload = (child as { encrypted_content: string }).encrypted_content; - if (!looksLikeBackendCiphertext(payload)) { - const parts = encryptedSlotParts(payload); - node.splice(i, 1, ...parts); - i += parts.length - 1; - rewritten += 1; - continue; - } - } - const childRewrites = visit(child); - if ( - childRewrites > 0 - && child && typeof child === "object" - && (child as { type?: unknown }).type === "agent_message" - && !hasEncryptedContentPart((child as { content?: unknown }).content) - ) { - const message = child as { type: string; role?: string; id?: unknown; author?: unknown; recipient?: unknown }; - message.type = "message"; - message.role = "user"; - delete message.id; - delete message.author; - delete message.recipient; + type VisitFrame = + | { kind: "visit"; node: unknown } + | { kind: "array"; node: unknown[]; index: number } + | { kind: "object"; values: unknown[]; index: number } + | { kind: "agent"; message: Record; rewrittenBefore: number }; + const stack: VisitFrame[] = [{ kind: "visit", node: input }]; + + while (stack.length > 0) { + const frame = stack.pop()!; + if (frame.kind === "visit") { + if (Array.isArray(frame.node)) { + stack.push({ kind: "array", node: frame.node, index: 0 }); + } else if (frame.node && typeof frame.node === "object") { + stack.push({ kind: "object", values: Object.values(frame.node), index: 0 }); + } + continue; + } + + if (frame.kind === "array") { + if (frame.index >= frame.node.length) continue; + const child = frame.node[frame.index] as unknown; + if ( + child && typeof child === "object" + && (child as { type?: unknown }).type === "encrypted_content" + && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string" + ) { + const payload = (child as { encrypted_content: string }).encrypted_content; + if (!looksLikeBackendCiphertext(payload)) { + const parts = encryptedSlotParts(payload); + frame.node.splice(frame.index, 1, ...parts); + rewritten += 1; + stack.push({ kind: "array", node: frame.node, index: frame.index + parts.length }); + continue; } } - return rewritten - before; + stack.push({ kind: "array", node: frame.node, index: frame.index + 1 }); + if (child && typeof child === "object" && (child as { type?: unknown }).type === "agent_message") { + stack.push({ kind: "agent", message: child as Record, rewrittenBefore: rewritten }); + } + stack.push({ kind: "visit", node: child }); + continue; } - if (node && typeof node === "object") { - for (const value of Object.values(node)) visit(value); + + if (frame.kind === "object") { + if (frame.index >= frame.values.length) continue; + stack.push({ kind: "object", values: frame.values, index: frame.index + 1 }); + stack.push({ kind: "visit", node: frame.values[frame.index] }); + continue; } - return rewritten - before; - }; - visit(input); + + if ( + rewritten > frame.rewrittenBefore + && frame.message.type === "agent_message" + && !hasEncryptedContentPart(frame.message.content) + ) { + frame.message.type = "message"; + frame.message.role = "user"; + delete frame.message.id; + delete frame.message.author; + delete frame.message.recipient; + } + } return rewritten; } diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index fb31293ebd4..25f6f50aab9 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -1138,14 +1138,64 @@ describe("sanitizeEncryptedContentInPlace", () => { expect(sanitizeEncryptedContentInPlace(undefined)).toBe(0); }); + test("deep unknown input does not overflow the call stack", () => { + const root: Array> = [{ type: "unknown" }]; + let cursor = root[0]!; + for (let depth = 0; depth < 30_000; depth += 1) { + const child: Record = {}; + cursor.child = child; + cursor = child; + } + cursor.content = [{ type: "encrypted_content", encrypted_content: "deep plaintext" }]; + + expect(sanitizeEncryptedContentInPlace(root)).toBe(1); + expect(cursor.content).toEqual([{ type: "input_text", text: "deep plaintext" }]); + }); + + test("nested agent messages normalize after child rewrites without changing the rewrite count", () => { + const input = [{ + type: "agent_message", + id: "outer", + author: "/root", + recipient: "/root/outer", + content: [{ + type: "agent_message", + id: "inner", + author: "/root/outer", + recipient: "/root/outer/inner", + content: [ + { type: "encrypted_content", encrypted_content: "first plaintext" }, + { type: "encrypted_content", encrypted_content: "second plaintext" }, + ], + }], + }]; + + expect(sanitizeEncryptedContentInPlace(input)).toBe(2); + const outer = input[0] as Record; + const inner = (outer.content as Array>)[0]!; + expect(outer).toMatchObject({ type: "message", role: "user" }); + expect(inner).toMatchObject({ type: "message", role: "user" }); + for (const message of [outer, inner]) { + expect(message).not.toHaveProperty("id"); + expect(message).not.toHaveProperty("author"); + expect(message).not.toHaveProperty("recipient"); + } + }); + test("mixed slot (hook preamble + embedded Fernet task) splits into text + encrypted parts", () => { const fernet = fernetFixture(); const input = [ - { type: "message", role: "user", content: [ + { type: "agent_message", id: "mixed", author: "/root", recipient: "/root/worker", content: [ { type: "encrypted_content", encrypted_content: `[CXC-LEAF-GUARD] follow the rules.\n\n${fernet}` }, ] }, ]; expect(sanitizeEncryptedContentInPlace(input)).toBe(1); + expect(input[0]).toMatchObject({ + type: "agent_message", + id: "mixed", + author: "/root", + recipient: "/root/worker", + }); const parts = (input[0] as { content: Array> }).content; expect(parts).toHaveLength(2); expect(parts[0]).toEqual({ type: "input_text", text: "[CXC-LEAF-GUARD] follow the rules.\n\n" });