Skip to content

Commit a799927

Browse files
committed
fix(agents): harden OpenAI strict schema inspection
1 parent f789081 commit a799927

2 files changed

Lines changed: 366 additions & 37 deletions

File tree

src/agents/openai-tool-schema.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it } from "vitest";
22
import {
33
clearOpenAIToolSchemaCacheForTest,
4+
findOpenAIStrictToolSchemaDiagnostics,
45
isStrictOpenAIJsonSchemaCompatible,
56
normalizeStrictOpenAIJsonSchema,
67
resolveOpenAIStrictToolFlagForInventory,
@@ -91,4 +92,162 @@ describe("OpenAI strict tool schema normalization", () => {
9192
}),
9293
).toBe(third);
9394
});
95+
96+
it("reports circular strict schemas without recursing forever", () => {
97+
const schema: {
98+
type: "object";
99+
properties: Record<string, unknown>;
100+
required: string[];
101+
additionalProperties: false;
102+
} = {
103+
type: "object",
104+
properties: {},
105+
required: ["self"],
106+
additionalProperties: false,
107+
};
108+
schema.properties.self = schema;
109+
110+
expect(() => normalizeStrictOpenAIJsonSchema(schema)).not.toThrow();
111+
expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(false);
112+
expect(findOpenAIStrictToolSchemaDiagnostics([{ name: "cycle", parameters: schema }])).toEqual([
113+
{
114+
toolIndex: 0,
115+
toolName: "cycle",
116+
violations: ["cycle.parameters is not inspectable for OpenAI strict schema compatibility"],
117+
},
118+
]);
119+
expect(
120+
resolveOpenAIStrictToolFlagForInventory([{ name: "cycle", parameters: schema }], true),
121+
).toBe(false);
122+
});
123+
124+
it("reports hostile properties maps without throwing", () => {
125+
const hostileProperties = new Proxy(
126+
{},
127+
{
128+
ownKeys() {
129+
throw new Error("strict schema properties ownKeys exploded");
130+
},
131+
},
132+
);
133+
const schema = {
134+
type: "object",
135+
properties: hostileProperties,
136+
required: [],
137+
additionalProperties: false,
138+
};
139+
140+
expect(() => normalizeStrictOpenAIJsonSchema(schema)).not.toThrow();
141+
expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(false);
142+
expect(
143+
findOpenAIStrictToolSchemaDiagnostics([{ name: "hostile", parameters: schema }]),
144+
).toEqual([
145+
{
146+
toolIndex: 0,
147+
toolName: "hostile",
148+
violations: [
149+
"hostile.parameters is not inspectable for OpenAI strict schema compatibility",
150+
],
151+
},
152+
]);
153+
});
154+
155+
it("reports circular schema arrays without recursing forever", () => {
156+
const enumValues: unknown[] = [];
157+
enumValues.push(enumValues);
158+
const schema = {
159+
type: "object",
160+
properties: {
161+
choice: { type: "string", enum: enumValues },
162+
},
163+
required: ["choice"],
164+
additionalProperties: false,
165+
};
166+
167+
expect(() => normalizeStrictOpenAIJsonSchema(schema)).not.toThrow();
168+
expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(false);
169+
expect(
170+
findOpenAIStrictToolSchemaDiagnostics([{ name: "circular_enum", parameters: schema }]),
171+
).toEqual([
172+
{
173+
toolIndex: 0,
174+
toolName: "circular_enum",
175+
violations: [
176+
"circular_enum.parameters is not inspectable for OpenAI strict schema compatibility",
177+
],
178+
},
179+
]);
180+
});
181+
182+
it("normalizes schema arrays before strict compatibility walkers inspect them", () => {
183+
const required = new Proxy(["path"], {
184+
get(target, property, receiver) {
185+
if (property === "filter") {
186+
throw new Error("source required.filter should not be used");
187+
}
188+
return Reflect.get(target, property, receiver);
189+
},
190+
});
191+
const schema = {
192+
type: "object",
193+
properties: { path: { type: "string" } },
194+
required,
195+
additionalProperties: false,
196+
};
197+
198+
expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(true);
199+
expect(findOpenAIStrictToolSchemaDiagnostics([{ name: "read", parameters: schema }])).toEqual(
200+
[],
201+
);
202+
});
203+
204+
it("does not trust source tool array traversal methods", () => {
205+
const healthy = {
206+
name: "healthy",
207+
parameters: {
208+
type: "object",
209+
properties: {},
210+
required: [],
211+
additionalProperties: false,
212+
},
213+
};
214+
const tools = new Proxy([healthy], {
215+
get(target, property, receiver) {
216+
if (property === "every" || property === "flatMap") {
217+
throw new Error(`source ${property} should not be used`);
218+
}
219+
return Reflect.get(target, property, receiver);
220+
},
221+
});
222+
223+
expect(resolveOpenAIStrictToolFlagForInventory(tools, true)).toBe(true);
224+
expect(findOpenAIStrictToolSchemaDiagnostics(tools)).toEqual([]);
225+
});
226+
227+
it("reports unreadable tool schemas before strict compatibility checks", () => {
228+
const tool = {
229+
name: "fuzzplugin_unreadable",
230+
parameters: {
231+
type: "object",
232+
properties: {},
233+
required: [],
234+
additionalProperties: false,
235+
},
236+
};
237+
Object.defineProperty(tool, "parameters", {
238+
enumerable: true,
239+
get() {
240+
throw new Error("strict schema parameters getter exploded");
241+
},
242+
});
243+
244+
expect(resolveOpenAIStrictToolFlagForInventory([tool], true)).toBe(false);
245+
expect(findOpenAIStrictToolSchemaDiagnostics([tool])).toEqual([
246+
{
247+
toolIndex: 0,
248+
toolName: "fuzzplugin_unreadable",
249+
violations: ["fuzzplugin_unreadable.parameters is unreadable"],
250+
},
251+
]);
252+
});
94253
});

0 commit comments

Comments
 (0)