From b8e66c0f1d45993e353816e96abc00d18d2f1e39 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 9 Aug 2026 10:22:59 +0900 Subject: [PATCH] fix(google): bound tool schema ref expansion --- src/adapters/google-tool-schema.ts | 37 ++++++++++++++++++++++-------- tests/google-tool-schema.test.ts | 26 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/adapters/google-tool-schema.ts b/src/adapters/google-tool-schema.ts index 36273ff0d8..fecbf8a854 100644 --- a/src/adapters/google-tool-schema.ts +++ b/src/adapters/google-tool-schema.ts @@ -7,6 +7,12 @@ type Schema = Record; const ALLOWED_TYPES = new Set(["string", "integer", "number", "boolean", "array", "object"]); const MAX_SCHEMA_DEPTH = 24; // Google's documented nesting limit is 32; leave headroom for CCA. const MAX_DEREF_DEPTH = 16; +const MAX_SCHEMA_NODES = 1_024; + +interface SanitizeState { + activeRefs: Set; + remainingNodes: number; +} function isRecord(value: unknown): value is Schema { return !!value && typeof value === "object" && !Array.isArray(value); @@ -63,11 +69,12 @@ function sanitizeEnum(value: unknown): string[] | undefined { function normalizeAnyOf( value: unknown, defs: Map, + state: SanitizeState, depth: number, refDepth: number, ): Schema { if (!Array.isArray(value) || value.length === 0) return {}; - const schemas = value.map(item => sanitizeSchema(item, defs, depth + 1, refDepth, true)); + const schemas = value.map(item => sanitizeSchema(item, defs, state, depth + 1, refDepth, true)); const nonNullSchemas = schemas.filter(schema => schema.type !== "null"); const nullSchemas = schemas.filter(schema => schema.type === "null"); @@ -98,6 +105,7 @@ function normalizeAnyOf( function sanitizeProperties( value: unknown, defs: Map, + state: SanitizeState, depth: number, refDepth: number, ): Record | undefined { @@ -105,7 +113,7 @@ function sanitizeProperties( const properties: Record = Object.create(null) as Record; for (const [name, schema] of Object.entries(value)) { // Property names form a name bag and must never be interpreted as schema keywords. - properties[name] = sanitizeSchema(schema, defs, depth + 1, refDepth, false); + properties[name] = sanitizeSchema(schema, defs, state, depth + 1, refDepth, false); } return properties; } @@ -113,20 +121,30 @@ function sanitizeProperties( function sanitizeSchema( node: unknown, defs: Map, + state: SanitizeState, depth: number, refDepth: number, preserveNullType: boolean, ): Schema { - if (depth >= MAX_SCHEMA_DEPTH || !isRecord(node)) return {}; + if (depth >= MAX_SCHEMA_DEPTH || !isRecord(node) || state.remainingNodes-- <= 0) return {}; - if (typeof node.$ref === "string" && refDepth < MAX_DEREF_DEPTH) { + if ( + typeof node.$ref === "string" + && refDepth < MAX_DEREF_DEPTH + && !state.activeRefs.has(node.$ref) + ) { const target = resolveRef(node.$ref, defs); if (isRecord(target)) { const merged: Schema = { ...target }; for (const [key, value] of Object.entries(node)) { if (key !== "$ref") merged[key] = value; } - return sanitizeSchema(merged, defs, depth, refDepth + 1, preserveNullType); + state.activeRefs.add(node.$ref); + try { + return sanitizeSchema(merged, defs, state, depth, refDepth + 1, preserveNullType); + } finally { + state.activeRefs.delete(node.$ref); + } } } @@ -140,18 +158,18 @@ function sanitizeSchema( const enumValues = sanitizeEnum(node.enum ?? (typeof node.const === "string" ? [node.const] : undefined)); if (enumValues) out.enum = enumValues; - const properties = sanitizeProperties(node.properties, defs, depth, refDepth); + const properties = sanitizeProperties(node.properties, defs, state, depth, refDepth); if (properties) out.properties = properties; if (isRecord(node.items)) { - out.items = sanitizeSchema(node.items, defs, depth + 1, refDepth, false); + out.items = sanitizeSchema(node.items, defs, state, depth + 1, refDepth, false); } if (Array.isArray(node.required)) { out.required = [...new Set(node.required.filter((item): item is string => typeof item === "string"))]; } - if (node.anyOf !== undefined) Object.assign(out, normalizeAnyOf(node.anyOf, defs, depth, refDepth)); + if (node.anyOf !== undefined) Object.assign(out, normalizeAnyOf(node.anyOf, defs, state, depth, refDepth)); return out; } @@ -159,7 +177,8 @@ export function sanitizeGeminiToolParameters(parameters: unknown): Record(); collectDefs(parameters, defs); - const root = sanitizeSchema(parameters, defs, 0, 0, false); + const state: SanitizeState = { activeRefs: new Set(), remainingNodes: MAX_SCHEMA_NODES }; + const root = sanitizeSchema(parameters, defs, state, 0, 0, false); // Function arguments are always an object. Claude additionally rejects root composition and a // missing root type even when those forms are valid general-purpose JSON Schema. diff --git a/tests/google-tool-schema.test.ts b/tests/google-tool-schema.test.ts index 745f5d5a50..622e3357e6 100644 --- a/tests/google-tool-schema.test.ts +++ b/tests/google-tool-schema.test.ts @@ -296,6 +296,32 @@ describe("sanitizeGeminiToolParameters", () => { }); }); + test("bounds expansion of branching recursive refs", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { tree: { $ref: "#/$defs/Tree" } }, + $defs: { + Tree: { + type: "object", + properties: { + left: { $ref: "#/$defs/Tree" }, + right: { $ref: "#/$defs/Tree" }, + }, + }, + }, + }); + + expect(out).toEqual({ + type: "object", + properties: { + tree: { + type: "object", + properties: { left: {}, right: {} }, + }, + }, + }); + }); + test("never leaks the internal null type used while normalizing unions", () => { const out = sanitizeGeminiToolParameters({ type: "object",