Skip to content

Commit d5addd7

Browse files
committed
fix(agents): guard Anthropic tool descriptors
1 parent f8d5f16 commit d5addd7

2 files changed

Lines changed: 173 additions & 23 deletions

File tree

src/agents/anthropic-transport-stream.test.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,16 @@ describe("anthropic transport stream", () => {
622622
systemPrompt: "Follow policy.",
623623
messages: [{ role: "user", content: "Read the file" }],
624624
tools: [
625+
{
626+
get name() {
627+
throw new Error("anthropic oauth tool name getter exploded");
628+
},
629+
description: "unreadable name",
630+
parameters: {
631+
type: "object",
632+
properties: {},
633+
},
634+
},
625635
{
626636
name: "read",
627637
description: "Read a file",
@@ -1186,11 +1196,75 @@ describe("anthropic transport stream", () => {
11861196
});
11871197

11881198
it("skips malformed tools when building Anthropic payloads", async () => {
1199+
const unreadableName = {
1200+
get name() {
1201+
throw new Error("anthropic tool name getter exploded");
1202+
},
1203+
description: "unreadable name",
1204+
parameters: {
1205+
type: "object",
1206+
properties: {},
1207+
},
1208+
};
1209+
const unreadableDescription = {
1210+
name: "description_poisoned_tool",
1211+
get description() {
1212+
throw new Error("anthropic tool description getter exploded");
1213+
},
1214+
parameters: {
1215+
type: "object",
1216+
properties: {
1217+
query: { type: "string" },
1218+
},
1219+
},
1220+
};
1221+
const unreadableParameters = {
1222+
name: "parameters_poisoned_tool",
1223+
description: "unreadable parameters",
1224+
get parameters() {
1225+
throw new Error("anthropic tool parameters getter exploded");
1226+
},
1227+
};
1228+
const dynamicSchema = {
1229+
name: "dynamic_schema_tool",
1230+
description: "unsupported dynamic schema",
1231+
parameters: {
1232+
type: "object",
1233+
properties: {
1234+
target: { $dynamicRef: "#target" },
1235+
},
1236+
},
1237+
};
1238+
const toJsonProjectedSchema = {
1239+
name: "tojson_projected_tool",
1240+
description: "schema projection differs from live properties",
1241+
parameters: {
1242+
type: "object",
1243+
properties: {
1244+
target: { $dynamicRef: "#target" },
1245+
},
1246+
toJSON() {
1247+
return {
1248+
type: "object",
1249+
properties: {
1250+
safe: { type: "string" },
1251+
},
1252+
required: ["safe"],
1253+
};
1254+
},
1255+
},
1256+
};
1257+
11891258
await runTransportStream(
11901259
makeAnthropicTransportModel(),
11911260
{
11921261
messages: [{ role: "user", content: "hello" }],
11931262
tools: [
1263+
unreadableName,
1264+
unreadableDescription,
1265+
unreadableParameters,
1266+
dynamicSchema,
1267+
toJsonProjectedSchema,
11941268
{
11951269
name: "bad_plugin_tool",
11961270
description: "missing schema",
@@ -1215,10 +1289,24 @@ describe("anthropic transport stream", () => {
12151289
);
12161290

12171291
const tools = requireArray(latestAnthropicRequest().payload.tools, "tools");
1218-
expect(tools).toHaveLength(1);
1219-
const tool = requireRecord(tools[0], "tool");
1220-
expect(tool.name).toBe("good_plugin_tool");
1221-
expect(requireRecord(tool.input_schema, "input schema").properties).toEqual({
1292+
expect(tools).toHaveLength(3);
1293+
const descriptionPoisonedTool = requireRecord(tools[0], "description poisoned tool");
1294+
expect(descriptionPoisonedTool.name).toBe("description_poisoned_tool");
1295+
expect(descriptionPoisonedTool).not.toHaveProperty("description");
1296+
expect(requireRecord(descriptionPoisonedTool.input_schema, "input schema").properties).toEqual({
1297+
query: { type: "string" },
1298+
});
1299+
const projectedTool = requireRecord(tools[1], "projected tool");
1300+
expect(projectedTool.name).toBe("tojson_projected_tool");
1301+
expect(requireRecord(projectedTool.input_schema, "input schema")).toMatchObject({
1302+
properties: {
1303+
safe: { type: "string" },
1304+
},
1305+
required: ["safe"],
1306+
});
1307+
const goodTool = requireRecord(tools[2], "good tool");
1308+
expect(goodTool.name).toBe("good_plugin_tool");
1309+
expect(requireRecord(goodTool.input_schema, "input schema").properties).toEqual({
12221310
query: { type: "string" },
12231311
});
12241312
});

src/agents/anthropic-transport-stream.ts

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { parseJsonObjectPreservingUnsafeIntegers } from "./json-unsafe-integers.
1414
import { resolveProviderEndpoint } from "./provider-attribution.js";
1515
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
1616
import type { StreamFn } from "./runtime/index.js";
17+
import { projectRuntimeToolInputSchema } from "./tool-schema-projection.js";
1718
import { transformTransportMessages } from "./transport-message-transform.js";
1819
import {
1920
coerceTransportToolCallArguments,
@@ -241,13 +242,22 @@ function toClaudeCodeName(name: string): string {
241242
}
242243

243244
function fromClaudeCodeName(name: string, tools: Context["tools"] | undefined): string {
244-
if (tools && tools.length > 0) {
245-
const lowerName = normalizeLowercaseStringOrEmpty(name);
246-
const matchedTool = tools.find(
247-
(tool) => normalizeLowercaseStringOrEmpty(tool.name) === lowerName,
248-
);
249-
if (matchedTool) {
250-
return matchedTool.name;
245+
if (!tools) {
246+
return name;
247+
}
248+
const toolCount = readAnthropicToolCount(tools);
249+
if (!toolCount) {
250+
return name;
251+
}
252+
const lowerName = normalizeLowercaseStringOrEmpty(name);
253+
for (let toolIndex = 0; toolIndex < toolCount; toolIndex += 1) {
254+
const tool = readAnthropicToolAt(tools, toolIndex);
255+
if (!tool) {
256+
continue;
257+
}
258+
const toolName = readAnthropicToolField(tool, "name");
259+
if (typeof toolName === "string" && normalizeLowercaseStringOrEmpty(toolName) === lowerName) {
260+
return toolName;
251261
}
252262
}
253263
return name;
@@ -474,10 +484,48 @@ function ensureNonEmptyAnthropicMessages(messages: Array<Record<string, unknown>
474484
: [{ role: "user", content: EMPTY_ANTHROPIC_MESSAGES_FALLBACK_TEXT }];
475485
}
476486

487+
function readAnthropicToolField<TField extends keyof NonNullable<Context["tools"]>[number]>(
488+
tool: NonNullable<Context["tools"]>[number],
489+
field: TField,
490+
): NonNullable<Context["tools"]>[number][TField] | undefined {
491+
try {
492+
return tool[field];
493+
} catch {
494+
return undefined;
495+
}
496+
}
497+
498+
function isAnthropicObjectSchema(value: unknown): value is Record<string, unknown> {
499+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
500+
}
501+
502+
function readAnthropicToolCount(tools: NonNullable<Context["tools"]>): number | undefined {
503+
try {
504+
return tools.length;
505+
} catch {
506+
return undefined;
507+
}
508+
}
509+
510+
function readAnthropicToolAt(
511+
tools: NonNullable<Context["tools"]>,
512+
index: number,
513+
): NonNullable<Context["tools"]>[number] | undefined {
514+
try {
515+
return tools[index];
516+
} catch {
517+
return undefined;
518+
}
519+
}
520+
477521
function convertAnthropicTools(tools: Context["tools"], isOAuthToken: boolean) {
478522
if (!tools) {
479523
return [];
480524
}
525+
const toolCount = readAnthropicToolCount(tools);
526+
if (toolCount === undefined) {
527+
return [];
528+
}
481529
const converted: Array<{
482530
name: string;
483531
description?: string;
@@ -487,24 +535,38 @@ function convertAnthropicTools(tools: Context["tools"], isOAuthToken: boolean) {
487535
required: unknown;
488536
};
489537
}> = [];
490-
for (const tool of tools) {
538+
for (let toolIndex = 0; toolIndex < toolCount; toolIndex += 1) {
491539
// Main quarantine happens when plugin tools materialize; this keeps Anthropic
492540
// safe for direct/custom tool arrays that bypass the plugin registry.
493-
const parameters =
494-
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
495-
? (tool.parameters as Record<string, unknown>)
496-
: undefined;
497-
if (!parameters) {
541+
const tool = readAnthropicToolAt(tools, toolIndex);
542+
if (!tool) {
498543
continue;
499544
}
500-
converted.push({
501-
name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
502-
description: tool.description,
545+
const rawName = readAnthropicToolField(tool, "name");
546+
const parameters = readAnthropicToolField(tool, "parameters");
547+
const description = readAnthropicToolField(tool, "description");
548+
if (typeof rawName !== "string" || !rawName) {
549+
continue;
550+
}
551+
const projectedSchema = projectRuntimeToolInputSchema(parameters, `${rawName}.parameters`);
552+
if (projectedSchema.violations.length > 0) {
553+
continue;
554+
}
555+
if (!isAnthropicObjectSchema(projectedSchema.schema)) {
556+
continue;
557+
}
558+
const parametersRecord = projectedSchema.schema;
559+
const convertedTool = {
560+
name: isOAuthToken ? toClaudeCodeName(rawName) : rawName,
503561
input_schema: {
504-
type: "object",
505-
properties: parameters.properties || {},
506-
required: parameters.required || [],
562+
type: "object" as const,
563+
properties: parametersRecord.properties || {},
564+
required: parametersRecord.required || [],
507565
},
566+
};
567+
converted.push({
568+
...convertedTool,
569+
...(typeof description === "string" ? { description } : {}),
508570
});
509571
}
510572
return converted;

0 commit comments

Comments
 (0)