mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(llm): guard unreadable tool schemas
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user