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
30 changes: 2 additions & 28 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { parseDataUrl } from "./image";
import { enforceAnthropicImageLimits } from "./anthropic-image-guard";
import { normalizeAnthropicImages } from "./anthropic-image-normalize";
import { normalizeAnthropicOutputSchema } from "./anthropic-output-schema";
import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
import { identifyRoutedModel } from "./identity";
import { redactSecretString } from "../lib/redact";
import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint";
Expand Down Expand Up @@ -721,35 +722,8 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
return converted;
}

// Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on
// collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an
// annotation for the ChatGPT backend only. Anthropic input_schema is strict
// JSON Schema; strip the marker defensively everywhere it can appear as a
// schema keyword, while preserving properties literally named "encrypted".
const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);

function stripEncryptedMarker(node: unknown, inNameBag = false): unknown {
if (Array.isArray(node)) return node.map(item => stripEncryptedMarker(item));
if (!node || typeof node !== "object") return node;

const out: Record<string, unknown> = {};

for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if (inNameBag) {
out[key] = stripEncryptedMarker(value);
} else if (key !== "encrypted") {
out[key] = ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)
? value
: stripEncryptedMarker(value, ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key));
}
}

return out;
}

function normalizeAnthropicInputSchema(schema: unknown): Record<string, unknown> {
const stripped = stripEncryptedMarker(schema);
const stripped = stripResponsesOnlyEncryptedMarker(schema);
const obj = stripped && typeof stripped === "object" && !Array.isArray(stripped)
? stripped as Record<string, unknown>
: {};
Expand Down
5 changes: 3 additions & 2 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalo
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
import { canSerializeServiceTierForChatModel } from "../providers/service-tier";
import { openaiChatCompletionsUrl } from "./openai-chat-url";
import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
import {
isTranslatorBudgetExceededError,
retainTranslatedEventBatch,
Expand Down Expand Up @@ -1091,9 +1092,9 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
if (tools.length === 0) return undefined;
const xaiTarget = isXaiSchemaTarget(provider);
const formatted = tools.flatMap(t => {
const parameters = xaiTarget
const parameters = stripResponsesOnlyEncryptedMarker(xaiTarget
? normalizeXaiToolParameters(t.parameters)
: ensureRootObjectType(t.parameters);
: ensureRootObjectType(t.parameters));

if (parameters === undefined) return [];
return [{
Expand Down
67 changes: 67 additions & 0 deletions src/adapters/responses-tool-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on
// collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an
// annotation for the ChatGPT backend only, so translated provider schemas must
// drop it without removing properties or definitions literally named `encrypted`.
const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([
"properties",
"patternProperties",
"$defs",
"definitions",
"dependencies",
"dependentSchemas",
"dependentRequired",
]);
const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);

/**
* The schema is caller-supplied, so its nesting depth is attacker-influenced. Native recursion
* would turn a deep schema into a stack overflow that takes down the request path, so this walks
* an explicit stack instead: depth costs heap, which is bounded and recoverable.
*/
export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = false): unknown {
type Assign = (value: unknown) => void;
interface Frame { node: unknown; inNameBag: boolean; assign: Assign }

let result: unknown;
const stack: Frame[] = [{ node, inNameBag, assign: value => { result = value; } }];

while (stack.length > 0) {
const frame = stack.pop()!;
const current = frame.node;

if (Array.isArray(current)) {
const out: unknown[] = new Array(current.length);
frame.assign(out);
// Array items are schemas in their own right, never a name bag.
for (let i = current.length - 1; i >= 0; i--) {
stack.push({ node: current[i], inNameBag: false, assign: value => { out[i] = value; } });
}
continue;
}
if (!current || typeof current !== "object") {
frame.assign(current);
continue;
}

// A schema name may be `__proto__`; a null-prototype record keeps it as data.
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
frame.assign(out);

for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
if (frame.inNameBag) {
// Inside a name bag every key is a caller-chosen name, so `encrypted` here is data.
stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } });
} else if (key !== "encrypted") {
if (ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)) {
// Literal payloads are values, not schemas: an `encrypted` key inside them is data.
out[key] = value;
} else {
const childInNameBag = ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key);
stack.push({ node: value, inNameBag: childInNameBag, assign: v => { out[key] = v; } });
}
}
}
}

return result;
}
101 changes: 101 additions & 0 deletions tests/openai-chat-hardening.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat";
import { stripResponsesOnlyEncryptedMarker } from "../src/adapters/responses-tool-schema";
import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer";
import { resetDebugSettingsForTests } from "../src/lib/debug-settings";
import { routeModel } from "../src/router";
Expand Down Expand Up @@ -59,6 +60,106 @@ function routedProvider(name: "litellm" | "ollama", apiKey?: string): OcxProvide
return routeModel(config, `${name}/test-model`).provider;
}

describe("openai-chat request hardening", () => {
test("strips Responses-only encrypted annotations without changing schema names or literal values", () => {
const parameters = {
type: "object",
properties: {
encrypted: { type: "boolean", description: "A legitimate tool argument name" },
message: { type: "string", encrypted: true },
nested: {
type: "object",
properties: { value: { type: "string", encrypted: false } },
},
literalData: {
type: "object",
const: { encrypted: true },
default: { encrypted: false },
enum: [{ encrypted: true }],
examples: [{ encrypted: false }],
},
},
patternProperties: { encrypted: { type: "string", encrypted: true } },
$defs: { encrypted: { type: "number", encrypted: true } },
definitions: { encrypted: { type: "integer", encrypted: false } },
dependencies: { encrypted: ["message"], other: { type: "object", encrypted: true } },
dependentSchemas: { encrypted: { type: "string", encrypted: true } },
dependentRequired: { encrypted: ["message"] },
propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string", encrypted: true } } },
required: ["message", "encrypted"],
};
const before = structuredClone(parameters);
const request = createOpenAIChatAdapter(provider()).buildRequest({
...parsed(),
context: {
messages: [{ role: "user", content: "delegate", timestamp: 0 }],
tools: [{
name: "spawn_agent",
namespace: "collaboration",
description: "Spawn a child agent",
parameters,
}],
},
});
const body = JSON.parse(request.body) as {
tools: Array<{ function: { parameters: Record<string, unknown> } }>;
};

expect(body.tools[0].function.parameters).toEqual({
type: "object",
properties: {
encrypted: { type: "boolean", description: "A legitimate tool argument name" },
message: { type: "string" },
nested: {
type: "object",
properties: { value: { type: "string" } },
},
literalData: {
type: "object",
const: { encrypted: true },
default: { encrypted: false },
enum: [{ encrypted: true }],
examples: [{ encrypted: false }],
},
},
patternProperties: { encrypted: { type: "string" } },
$defs: { encrypted: { type: "number" } },
definitions: { encrypted: { type: "integer" } },
dependencies: { encrypted: ["message"], other: { type: "object" } },
dependentSchemas: { encrypted: { type: "string" } },
dependentRequired: { encrypted: ["message"] },
propertiesWithSpecialName: { type: "object", properties: { ["__proto__"]: { type: "string" } } },
required: ["message", "encrypted"],
});
expect(parameters).toEqual(before);
});

test("a deeply nested schema is stripped without exhausting the stack", () => {
// The schema is caller-supplied, so its depth is attacker-influenced: a recursive walk
// would take the request path down with a stack overflow instead of answering.
const depth = 50_000;
const root: Record<string, unknown> = { type: "object", encrypted: true };
let cursor = root;
for (let i = 0; i < depth; i++) {
const child: Record<string, unknown> = { type: "object", encrypted: true };
cursor.properties = { encrypted: child };
cursor = child;
}
cursor.leaf = { type: "string", encrypted: true };

const stripped = stripResponsesOnlyEncryptedMarker(root) as Record<string, unknown>;
expect(stripped.encrypted).toBeUndefined();
let walk = stripped;
for (let i = 0; i < depth; i++) {
// Each level keeps the property literally named `encrypted` and drops the keyword.
walk = (walk.properties as Record<string, Record<string, unknown>>).encrypted;
expect(walk.encrypted).toBeUndefined();
expect(walk.type).toBe("object");
}
expect((walk.leaf as Record<string, unknown>).encrypted).toBeUndefined();
});
});

describe("openai-chat non-stream response hardening", () => {
test("surfaces an upstream error envelope message", async () => {
const adapter = createOpenAIChatAdapter(provider());
Expand Down
Loading