fix(api): resolve swarm-field tool names by own-property (#2123)

filterSwarmFieldsFromSchema() looked the tool name up with a bare
SWARM_FIELDS_BY_TOOL[toolName] on a plain-object map. A tool whose name
collides with an Object.prototype member (constructor, hasOwnProperty,
isPrototypeOf, propertyIsEnumerable) resolves the inherited function,
which is truthy with .length === 1 — so the empty/undefined guard is
bypassed and the subsequent for...of throws "is not iterable", failing
schema construction for the entire request.

Guard the lookup with Object.hasOwn so a prototype-named tool is treated
as unmapped, matching the own-key guard already used for provider-
supplied tool names in services/api/toolArgumentNormalization.ts.
This commit is contained in:
0xfandom
2026-08-14 10:12:53 +08:00
committed by GitHub
parent fb9102c422
commit f553d0896d
2 changed files with 57 additions and 2 deletions
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from 'bun:test'
import type Anthropic from '@anthropic-ai/sdk'
import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js'
import { filterSwarmFieldsFromSchema } from './api.js'
const schema: Anthropic.Tool.InputSchema = {
type: 'object',
properties: {
name: { type: 'string' },
team_name: { type: 'string' },
mode: { type: 'string' },
prompt: { type: 'string' },
},
required: ['name', 'prompt'],
}
// `filterSwarmFieldsFromSchema` looks the tool name up in a plain-object map.
// A name that collides with an Object.prototype member (`constructor`,
// `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable`) resolves the
// inherited function — truthy with `.length === 1` — which slips past the
// `!fieldsToRemove || length === 0` guard and then throws in the `for...of`.
for (const name of [
'constructor',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
]) {
test(`prototype-named tool "${name}" is treated as unmapped, not crashed`, () => {
expect(() => filterSwarmFieldsFromSchema(name, schema)).not.toThrow()
// Unmapped tool → schema returned unchanged (same reference).
expect(filterSwarmFieldsFromSchema(name, schema)).toBe(schema)
})
}
test('a genuinely mapped tool still has its swarm fields removed', () => {
const filtered = filterSwarmFieldsFromSchema(AGENT_TOOL_NAME, schema)
expect(Object.keys(filtered.properties ?? {})).toEqual(['prompt'])
expect(filtered.required).toEqual(['prompt'])
})
test('an ordinary unmapped tool is returned unchanged', () => {
expect(filterSwarmFieldsFromSchema('mcp__server__do_thing', schema)).toBe(
schema,
)
})
+11 -2
View File
@@ -94,11 +94,20 @@ const SWARM_FIELDS_BY_TOOL: Record<string, string[]> = {
* Filter swarm-related fields from a tool's input schema.
* Called at runtime when isAgentSwarmsEnabled() returns false.
*/
function filterSwarmFieldsFromSchema(
export function filterSwarmFieldsFromSchema(
toolName: string,
schema: Anthropic.Tool.InputSchema,
): Anthropic.Tool.InputSchema {
const fieldsToRemove = SWARM_FIELDS_BY_TOOL[toolName]
// Guard with Object.hasOwn: a bare `SWARM_FIELDS_BY_TOOL[toolName]` lookup
// resolves inherited Object.prototype members for names like `constructor` or
// `hasOwnProperty` to their functions. Those are truthy with `.length === 1`,
// so the guard below is bypassed and the `for...of` on line ~111 throws
// "is not iterable", breaking schema construction for the whole request.
// Mirrors the own-key guard already used for provider-supplied tool names in
// services/api/toolArgumentNormalization.ts.
const fieldsToRemove = Object.hasOwn(SWARM_FIELDS_BY_TOOL, toolName)
? SWARM_FIELDS_BY_TOOL[toolName]
: undefined
if (!fieldsToRemove || fieldsToRemove.length === 0) {
return schema
}