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
171 changes: 156 additions & 15 deletions src/server/responses-undeclared-tool-guard.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,41 @@
import { namespacedToolName } from "../types";
import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";

/**
* Item types the CLIENT executes by name. Hosted calls (`web_search_call`,
* `image_generation_call`, `local_shell_call`, `tool_search_call`, …) are run upstream or
* carry no tool name, so they are never matched against the request catalog.
*/
/** Item types the client executes through a request-declared wire name. */
const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]);

/** Nameless declaration kinds whose response items still require client execution. */
const NAMELESS_CLIENT_DECLARATION_CALL_TYPES = new Map([
["local_shell", "local_shell_call"],
["tool_search", "tool_search_call"],
["computer_use_preview", "computer_call"],
["computer_use", "computer_call"],
]);

const NAMELESS_CLIENT_CALL_DISPLAY_NAMES = new Map([
["local_shell_call", "local_shell"],
["tool_search_call", "tool_search"],
["computer_call", "computer_use"],
]);

const EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES: ReadonlySet<string> = new Set();

/** Supported hosted/private declarations that carry no client-executable wire name. */
const NAMELESS_TOOL_SPEC_TYPES = new Set([
"web_search",
"web_search_preview",
"file_search",
"computer_use_preview",
"computer_use",
"code_interpreter",
"image_generation",
"image_gen",
"mcp",
"tool_search",
"local_shell",
"x_search",
]);

/** An upstream-supplied name reaches the error message; keep it bounded. */
const MAX_REPORTED_NAME_CHARS = 100;

Expand All @@ -18,12 +46,40 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
}

function addWireToolName(names: Set<string>, tool: unknown, namespace?: string): void {
if (!isPlainObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) return;
names.add(tool.name);
if (!isPlainObject(tool)) return;
const nestedFunction = tool.type === "function" && isPlainObject(tool.function)
? tool.function
: undefined;
const name = typeof tool.name === "string" && tool.name.length > 0
? tool.name
: typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0
? nestedFunction.name
: undefined;
if (!name) return;
names.add(name);
// Codex routes MCP calls by an explicit `namespace` field, so the same tool is reachable
// as a bare inner name or as the flattened form; accept both rather than guess which
// coordinate system this provider echoes back.
if (namespace) names.add(namespacedToolName(namespace, tool.name));
if (namespace) names.add(namespacedToolName(namespace, name));
}

/**
* Catalog view owned by the current Responses turn.
*
* `previous_response_id` expansion prepends stored input items, including historical
* `additional_tools` declarations. Those items remain conversation history but cannot grant
* execution authority to this turn. Top-level `tools` always belongs to the current request;
* only input catalogs at or after the replay boundary are current.
*/
export function currentTurnWireToolCatalogBody(
body: unknown,
replayPrefixLength: number | undefined,
): unknown {
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
if (typeof replayPrefixLength !== "number" || !Number.isFinite(replayPrefixLength)) return body;
const start = Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength)));
if (start === 0) return body;
return { ...body, input: body.input.slice(start) };
}

function addWireToolSpecs(names: Set<string>, specs: unknown): void {
Expand Down Expand Up @@ -53,15 +109,97 @@ export function collectDeclaredWireToolNames(body: unknown): Set<string> {
addWireToolSpecs(names, body.tools);
if (Array.isArray(body.input)) {
for (const item of body.input) {
if (isPlainObject(item) && item.type === "additional_tools") addWireToolSpecs(names, item.tools);
if (
isPlainObject(item)
&& (item.type === "additional_tools" || item.type === "tool_search_output")
) addWireToolSpecs(names, item.tools);
}
}
return names;
}

function undeclaredNameInItem(item: unknown, declared: ReadonlySet<string>): string | undefined {
function addNamelessClientCallTypes(callTypes: Set<string>, specs: unknown): void {
if (!Array.isArray(specs)) return;
for (const spec of specs) {
if (!isPlainObject(spec) || typeof spec.type !== "string") continue;
const callType = NAMELESS_CLIENT_DECLARATION_CALL_TYPES.get(spec.type);
if (callType) callTypes.add(callType);
}
}

/** Nameless client-call item types authorized by supported request tool declarations. */
export function collectDeclaredNamelessClientCallTypes(body: unknown): Set<string> {
const callTypes = new Set<string>();
if (!isPlainObject(body)) return callTypes;
addNamelessClientCallTypes(callTypes, body.tools);
if (Array.isArray(body.input)) {
for (const item of body.input) {
if (
isPlainObject(item)
&& (item.type === "additional_tools" || item.type === "tool_search_output")
) {
addNamelessClientCallTypes(callTypes, item.tools);
}
}
}
return callTypes;
}

function isReadableWireToolSpec(spec: unknown): boolean {
if (!isPlainObject(spec) || typeof spec.type !== "string" || spec.type.length === 0) return false;
if (spec.type === "function") {
return (typeof spec.name === "string" && spec.name.length > 0)
|| (isPlainObject(spec.function)
&& typeof spec.function.name === "string"
&& spec.function.name.length > 0);
}
if (spec.type === "custom") return typeof spec.name === "string" && spec.name.length > 0;
if (spec.type === "namespace") {
return typeof spec.name === "string"
&& spec.name.length > 0
&& Array.isArray(spec.tools)
&& (spec.tools.length === 0 || spec.tools.some(inner =>
isPlainObject(inner)
&& (inner.type === "function" || inner.type === "custom")
&& typeof inner.name === "string"
&& inner.name.length > 0
));
}
if (NAMELESS_TOOL_SPEC_TYPES.has(spec.type)) return true;
return typeof spec.name === "string" && spec.name.length > 0;
}

function isReadableWireToolCatalog(value: unknown): boolean {
return Array.isArray(value)
&& (value.length === 0 || value.some(isReadableWireToolSpec));
}

/** Whether a request contains a supported catalog, including an explicit empty deny-all array. */
export function hasExplicitWireToolCatalog(body: unknown): boolean {
if (!isPlainObject(body)) return false;
if (isReadableWireToolCatalog(body.tools)) return true;
Comment thread
luvs01 marked this conversation as resolved.
if (!Array.isArray(body.input)) return false;
return body.input.some(item =>
isPlainObject(item)
&& item.type === "additional_tools"
&& isReadableWireToolCatalog(item.tools)
);
}

function undeclaredNameInItem(
item: unknown,
declared: ReadonlySet<string>,
declaredNamelessClientCallTypes: ReadonlySet<string>,
): string | undefined {
if (!isPlainObject(item)) return undefined;
if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined;
if (typeof item.type !== "string") return undefined;
const namelessDisplayName = NAMELESS_CLIENT_CALL_DISPLAY_NAMES.get(item.type);
if (namelessDisplayName !== undefined) {
// Only Codex's explicit `execution: "client"` form delegates tool search to the client.
if (item.type === "tool_search_call" && item.execution !== "client") return undefined;
return declaredNamelessClientCallTypes.has(item.type) ? undefined : namelessDisplayName;
}
if (!CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined;
const name = item.name;
if (typeof name !== "string" || name.length === 0) return undefined;
if (declared.has(name)) return undefined;
Expand All @@ -75,14 +213,15 @@ function undeclaredNameInItem(item: unknown, declared: ReadonlySet<string>): str
export function undeclaredToolCallName(
payload: unknown,
declared: ReadonlySet<string>,
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
): string | undefined {
if (!isPlainObject(payload)) return undefined;
if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") {
return undeclaredNameInItem(payload.item, declared);
return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes);
}
// Sparse gateways skip incremental items and only ever ship the terminal snapshot.
if (payload.type === "response.completed" || payload.type === "response.incomplete") {
return undeclaredToolCallNameInResponse(payload.response, declared);
return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes);
}
return undefined;
}
Expand All @@ -91,10 +230,11 @@ export function undeclaredToolCallName(
export function undeclaredToolCallNameInResponse(
response: unknown,
declared: ReadonlySet<string>,
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
): string | undefined {
if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined;
for (const item of response.output) {
const name = undeclaredNameInItem(item, declared);
const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes);
if (name !== undefined) return name;
}
return undefined;
Expand Down Expand Up @@ -133,6 +273,7 @@ function failedBlocks(name: string, newline: string): readonly string[] {
*/
export function createUndeclaredToolCallGuardBlockRewrite(
declared: ReadonlySet<string>,
declaredNamelessClientCallTypes: ReadonlySet<string> = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES,
): SseBlockRewrite {
let tripped = false;
return (block: string) => {
Expand All @@ -145,7 +286,7 @@ export function createUndeclaredToolCallGuardBlockRewrite(
} catch {
return [block];
}
const name = undeclaredToolCallName(parsed, declared);
const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes);
if (name === undefined) return [block];
tripped = true;
return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n");
Expand Down
Loading
Loading