fix(llm): guard unreadable tool schemas

This commit is contained in:
Vincent Koc
2026-06-03 01:47:12 +02:00
parent 585e89adbe
commit 94b4f8200d
2 changed files with 313 additions and 4 deletions

View File

@@ -38,4 +38,197 @@ describe("validateToolArguments", () => {
}),
).toThrow(/Validation failed for tool "decimal-tool"/);
});
it("reports unreadable nested schema maps before TypeBox traversal", () => {
const unreadableProperties = new Proxy(
{},
{
ownKeys() {
throw new Error("llm properties ownKeys exploded");
},
},
);
const tool = {
name: "hostile-tool",
description: "test tool",
parameters: {
type: "object",
properties: unreadableProperties,
additionalProperties: { type: "number" },
},
} as unknown as Tool;
expect(() =>
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "hostile-tool",
arguments: { amount: "42" },
}),
).toThrow(
'Unsupported tool schema for "hostile-tool": unreadable schema at parameters.properties',
);
});
it("reports non-enumerable unreadable schema keyword accessors", () => {
const parameters = {
type: "object",
};
Object.defineProperty(parameters, "properties", {
enumerable: false,
get() {
throw new Error("llm non-enumerable properties exploded");
},
});
const tool = {
name: "non-enumerable-hostile-tool",
description: "test tool",
parameters,
} as unknown as Tool;
expect(() =>
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "non-enumerable-hostile-tool",
arguments: {},
}),
).toThrow(
'Unsupported tool schema for "non-enumerable-hostile-tool": unreadable schema at parameters.properties',
);
});
it("reports unreadable root schemas before TypeBox traversal", () => {
const unreadableParameters = new Proxy(
{},
{
ownKeys() {
throw new Error("llm root ownKeys exploded");
},
},
);
const tool = {
name: "root-hostile-tool",
description: "test tool",
parameters: unreadableParameters,
} as unknown as Tool;
expect(() =>
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "root-hostile-tool",
arguments: {},
}),
).toThrow('Unsupported tool schema for "root-hostile-tool": unreadable schema at parameters');
});
it("reports unreadable array schema items before TypeBox traversal", () => {
const unreadableAnyOf = [{ type: "string" }];
Object.defineProperty(unreadableAnyOf, "0", {
enumerable: true,
get() {
throw new Error("llm anyOf item exploded");
},
});
const tool = {
name: "array-hostile-tool",
description: "test tool",
parameters: {
type: "object",
anyOf: unreadableAnyOf,
},
} as unknown as Tool;
expect(() =>
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "array-hostile-tool",
arguments: {},
}),
).toThrow(
'Unsupported tool schema for "array-hostile-tool": unreadable schema at parameters.anyOf.0',
);
});
it("bounds schema readability inspection before TypeBox traversal", () => {
const properties: Record<string, unknown> = {};
for (let index = 0; index < 1001; index += 1) {
properties[`field_${index}`] = { type: "string" };
}
const tool = {
name: "wide-hostile-tool",
description: "test tool",
parameters: {
type: "object",
properties,
},
} as unknown as Tool;
expect(() =>
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "wide-hostile-tool",
arguments: {},
}),
).toThrow(
'Unsupported tool schema for "wide-hostile-tool": schema field count exceeds inspection budget at parameters.properties',
);
});
it("uses the tool call name when schema error reporting cannot read tool metadata", () => {
const unreadableParameters = new Proxy(
{},
{
ownKeys() {
throw new Error("llm root ownKeys exploded");
},
},
);
const tool = {
description: "test tool",
parameters: unreadableParameters,
} as unknown as Tool;
Object.defineProperty(tool, "name", {
enumerable: true,
get() {
throw new Error("tool name unavailable");
},
});
expect(() =>
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "fallback-tool-name",
arguments: {},
}),
).toThrow('Unsupported tool schema for "fallback-tool-name": unreadable schema at parameters');
});
it("reports unreadable parameter accessors before TypeBox traversal", () => {
const tool = {
name: "accessor-hostile-tool",
description: "test tool",
} as unknown as Tool;
Object.defineProperty(tool, "parameters", {
enumerable: true,
get() {
throw new Error("tool parameters unavailable");
},
});
expect(() =>
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "accessor-hostile-tool",
arguments: {},
}),
).toThrow(
'Unsupported tool schema for "accessor-hostile-tool": unreadable schema at parameters',
);
});
});

View File

@@ -5,6 +5,8 @@ import type { Tool, ToolCall } from "./types.js";
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
const MAX_SCHEMA_READABILITY_DEPTH = 20;
const MAX_SCHEMA_READABILITY_FIELDS = 1000;
interface JsonSchemaObject {
type?: string | string[];
@@ -63,6 +65,118 @@ function isValidatorSchema(value: unknown): value is Tool["parameters"] {
return isRecord(value);
}
function formatSchemaKey(key: PropertyKey): string {
return typeof key === "symbol" ? String(key) : String(key);
}
function formatSchemaPath(parent: string, key: PropertyKey): string {
const segment = formatSchemaKey(key);
return parent ? `${parent}.${segment}` : segment;
}
function readSchemaKeys(value: object): PropertyKey[] | undefined {
try {
return Reflect.ownKeys(value);
} catch {
return undefined;
}
}
function readSchemaValue(
value: object,
key: PropertyKey,
): { ok: true; value: unknown } | { ok: false } {
try {
return { ok: true, value: Reflect.get(value, key) };
} catch {
return { ok: false };
}
}
type SchemaReadabilityIssue = {
path: string;
reason: "too_deep" | "too_large" | "unreadable";
};
function findUnreadableSchemaPath(
value: unknown,
path: string,
depth = MAX_SCHEMA_READABILITY_DEPTH,
seen = new WeakSet<object>(),
): SchemaReadabilityIssue | undefined {
if (!isRecord(value)) {
return undefined;
}
if (seen.has(value)) {
return undefined;
}
if (depth <= 0) {
return { path, reason: "too_deep" };
}
seen.add(value);
const keys = readSchemaKeys(value);
if (!keys) {
return { path, reason: "unreadable" };
}
if (keys.length > MAX_SCHEMA_READABILITY_FIELDS) {
return { path, reason: "too_large" };
}
for (const key of keys) {
const childPath = formatSchemaPath(path, key);
const childValue = readSchemaValue(value, key);
if (!childValue.ok) {
return { path: childPath, reason: "unreadable" };
}
const issue = findUnreadableSchemaPath(childValue.value, childPath, depth - 1, seen);
if (issue) {
return issue;
}
}
return undefined;
}
function toolErrorName(tool: Tool, fallbackName: string): string {
try {
return typeof tool.name === "string" && tool.name ? tool.name : fallbackName;
} catch {
return fallbackName;
}
}
function readToolParameters(tool: Tool, fallbackName: string): Tool["parameters"] {
try {
return tool.parameters;
} catch {
throw new Error(
`Unsupported tool schema for "${toolErrorName(tool, fallbackName)}": unreadable schema at parameters`,
);
}
}
function assertToolSchemaReadable(
parameters: Tool["parameters"],
tool: Tool,
fallbackName: string,
): void {
const issue = findUnreadableSchemaPath(parameters, "parameters");
if (issue) {
const reason =
issue.reason === "too_deep"
? "schema nesting exceeds inspection budget"
: issue.reason === "too_large"
? "schema field count exceeds inspection budget"
: "unreadable schema";
throw new Error(
`Unsupported tool schema for "${toolErrorName(tool, fallbackName)}": ${reason} at ${
issue.path
}`,
);
}
}
const JSON_NUMBER_TOKEN_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/iu;
function parseJsonNumberString(value: string): number | undefined {
@@ -292,14 +406,16 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown {
/** Validates tool arguments against TypeBox or plain JSON-schema parameters. */
export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown {
const parameters = readToolParameters(tool, toolCall.name);
assertToolSchemaReadable(parameters, tool, toolCall.name);
const args = structuredClone(toolCall.arguments);
Value.Convert(tool.parameters, args);
Value.Convert(parameters, args);
const validator = getValidator(tool.parameters);
if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) {
const validator = getValidator(parameters);
if (!hasTypeBoxMetadata(parameters) && isJsonSchemaObject(parameters)) {
// TypeBox Value.Convert is intentionally conservative for plain JSON schemas;
// mirror the provider-facing coercions so model-emitted string numbers validate.
const coerced = coerceWithJsonSchema(args, tool.parameters);
const coerced = coerceWithJsonSchema(args, parameters);
if (coerced !== args) {
if (isRecord(args) && isRecord(coerced)) {
for (const key of Object.keys(args)) {