
工具名来自一条响应,调度器拿它去对象上取函数。代码只有一行,看起来很顺手:
const handler = tools[call.name];
问题在于普通对象不只装着显式登记的字段。tools.constructor、tools.toString 都能从原型上取到函数;tools.__proto__ 甚至会取到一个对象。若后面只判断「取到的值是不是函数」,继承来的函数也会被放进执行分支。这里并不要求名字奇怪到带控制字符,常见的原型属性名就够了。
直接取属性,结果多出一层
在本机写了两个固定工具:lookup 和 count。程序分别用直接属性读取与自有属性检查查一组固定名称,没有请求模型,也没有连接任何服务。输出来自 tools/verify-agent-tool-registry.mjs,环境是 Node v26.8.1:
lookup: unsafe=function(lookup); safe=function
count: unsafe=function(count); safe=function
missing: unsafe=undefined; safe=undefined
constructor: unsafe=function(Object); safe=undefined
toString: unsafe=function(toString); safe=undefined
__proto__: unsafe=object(anonymous); safe=undefined
known result: found:k1
unknown result: UNKNOWN_TOOL:constructor
handlers called: lookup
Object.freeze(tools) 也不能修掉这件事。冻结会阻止对象自身的属性被改写,却不会把原型链拆掉;上面的测试对象就是冻结的,constructor 仍然能读出来。错误不在某个模型会不会发出这个名字,而在分发器把属性查找结果直接当成登记表。
先确认登记,再允许调用
在普通对象上,最小修正是只接受自有属性:
function resolveTool(tools, name) {
if (!Object.hasOwn(tools, name)) {
throw new Error(`UNKNOWN_TOOL:${name}`);
}
return tools[name];
}
调用前还应确认取到的值确实是函数;工具定义若来自配置,也要在启动时检查重复名称和非函数值。另一个做法是用 Map 保存登记项,再用 map.has(name) 判断是否存在。两种写法都把「注册过」变成明确条件,不让 JavaScript 的继承规则替工具表补内容。
实测桩中的安全分支遇到 constructor 时抛出 UNKNOWN_TOOL:constructor,调用记录仍只有 lookup。这说明拒绝发生在执行之前。若分发器先调用再检查返回值,边界就晚了;被误调的函数可能已经产生副作用。固定桩能验证调用门槛,但没有覆盖具体工具副作用。
这里管的是名称能否映射到已登记处理器,并不等于调用权限已经通过。比如 delete_record 确实在表里,也不能因此推定当前任务可以删除任意记录。授权仍要看调用者和参数;参数的类型、范围也应单独校验。把几个判断塞进「函数存在」这一问,边界很容易越写越含糊。
登记项也不该在请求到来时临时从响应内容里拼出来。工具表属于程序自己的配置,外部只提供名称和参数;名字查不到就停在分发层,别猜一个相似工具继续执行。报错里保留原始名称,排查时能看出是拼错、版本不一致,还是请求里出现了未登记项。这里说的是执行器的输入处理,不是模型一定会犯错。
登记表在启动时检查,比每次请求里静默过滤更容易定位配置错误。日志可以记传入的工具名和拒绝原因,参数不要原样打出来;里面可能带文件路径或用户文本。拒绝记录能提示调用方和工具版本不一致,但不能因此放宽白名单。
我会优先用 Map 做动态注册表,读起来就是查登记项;如果已有代码是对象,Object.hasOwn 也足够直白。不要依赖把输入名称列进黑名单,原型属性不止一个,之后还可能出现新增工具名与旧名称冲突。真正需要约束的是注册表的键空间。
这次实验只测了 Node 普通对象的属性读取,以及安全分发函数有没有调用已登记处理器。模型是否会产出这些名字、某个 SDK 会怎样封装工具调用,都没实测,也不能从这个输出推断。Agent 实现里可先把名称当不可信输入:查自有登记项,校验处理器,再进入执行;未知名称就明确报错。若调度器使用 Map,仍要做相同的存在检查,不能把它当作参数校验的替代品。