Zero 架构 · 第二课

executeToolCall:工具是「哑的」,循环才是守门人

第一课里,内层循环有一行不起眼的调用: toolResult := executeToolCall(...) (loop.go:482)。 那一行背后是整个 agent 里唯一会产生真实副作用的地方 —— 写文件、跑 shell、发网络请求。所以它也是安全模型的全部重量所在。这一课我们拆开它。

对齐 mission:你的目标之一是「说清一个危险工具调用的权限/沙箱判定路径」。这一课 就交付它 —— 把 executeToolCall 看成一串必须逐层通过的关卡。

核心洞察:工具不自己把关

先看工具长什么样。Tool 接口只有五个方法: types.go:119

type Tool interface {
    Name() string
    Description() string
    Parameters() Schema
    Safety() Safety              // ← 只「声明」危险等级,不「执行」任何检查
    Run(ctx, args) Result        // ← 直接干活,默认自己不做权限判断
}

注意:Run 里没有权限逻辑。工具只通过 Safety() 声明 自己有多危险 —— 一个三值枚举: types.go:32

真正的执行判断全部由 executeToolCall 在调用 Run 之前完成。这是刻意的架构决策:把策略(policy)从机制(mechanism)里 剥出来。工具作者只需诚实声明危险等级,写工具时完全不必操心权限、沙箱、密钥擦除 —— 这些横切关注点在循环里集中处理一次。

一句话记住 Run 是纯机制,executeToolCall 是策略。所有关卡都架在 一次纯粹的工具执行外面 —— 这就是「纵深防御」的形状。

七道关卡(按顺序)

一个工具调用要抵达真正的 Run,得依次穿过这些门。任何一道都可能提前返回一个 ToolResult,让工具根本不执行:

① 参数解码

把模型给的 JSON 参数解成 map[string]any;解析失败直接返回错误结果,让模型 重试。 loop.go:776

② 工具过滤器(启用/禁用名单)

如果本次运行有 EnabledTools 允许名单或 DisabledTools 拒绝名单, 不在名单内的工具在这里就被挡下(tool_search 作为发现网关被特别豁免)。 loop.go:791

③ 工具自己的「预权限」否决 + 特殊拦截

实现了 PrePermissionRejecter 的工具可以在权限流之前先否决非法参数。 loop.go:811 紧接着,ask_userrequest_permissions拦截出来 —— 它们需要路由到交互前端,而不是阻塞在工具内部。 loop.go:822

④ 确定「是否已授权」的基线

三种情况下这道调用天然被授权:处于 unsafe 权限模式;工具对这组参数的 有效权限Allow;或者命中了之前保存的命令前缀授权。 loop.go:829

这里有个精妙点:权限可以随参数变化。工具可选实现 ArgsPermissioner, 于是 effectivePermission 会按具体参数决定 —— 比如 rm -rf / 需要问,而 ls 不用,尽管都是同一个 shell 工具。 loop.go:1665

⑤ 沙箱预评估

若配置了沙箱引擎,Sandbox.Evaluate 先给出一个 Decision (放行 / 提示 / 拒绝),连同原因(如「网络被阻断」)。 loop.go:859

⑥ 向用户请求权限(如果需要)

shouldRequestPermission 综合工具的静态权限 + 沙箱决定,判断要不要弹窗。 loop.go:1672 如果要,就通过 OnPermissionRequest 回调问前端,然后用一个大 switch 处理用户的回答:允许一次 / 本会话允许 / 允许该命令前缀 / 永久允许 / 取消 / 拒绝 —— 每个分支还负责按对应的作用域(turn / session / 持久)去授予网络、文件系统等附加权限, 并登记 cleanup 以便运行结束时回收。 loop.go:865

⑦ beforeTool 钩子

用户配置的 beforeTool 钩子可以在最后一刻否决调用(非零退出即阻断)。 loop.go:1002

穿过所有关卡之后

只有全部通过,才真正执行: registry.RunWithOptions(...),把 permissionGranted、沙箱、 文件版本追踪器等一并传入。 loop.go:1021

执行完还有三个收尾动作,同样在循环里而非工具里:

最后打包成一个 ToolResult 返回给第一课的内层循环,append 进 messages。 loop.go:1082

为什么这么设计 把每一道关卡都放在工具外面,意味着:新增一个工具无法「忘记」做权限检查 —— 它没有能力绕过。安全性不依赖每个工具作者的自觉,而是循环强制施加的。这正是 把策略与机制分离的收益。

动手回忆

Zero 的 Tool 接口对权限检查负什么责任?

为什么同一个 shell 工具,ls 直接放行而 rm -rf 要问用户?

把权限、沙箱、密钥擦除放在工具「外面」的循环里,主要收益是?

接下来该读的一手源码

less +774 ../zero/internal/agent/loop.go   # executeToolCall 全貌
less +119 ../zero/internal/tools/types.go  # Tool 接口 + Safety

executeToolCall 时,别陷进那个巨大的权限 switch(⑥)细节 —— 先把七道关卡的顺序认出来,看清「哪些分支会提前 returnRun 不被执行」。顺序本身就是安全模型。

我是你的老师 —— 随时问我。 适合现在追问: 「⑥ 里那个权限 switch 的每个分支分别对应 TUI 上哪个选项?」、 「沙箱 Decision 是怎么算出来的?」(那会引出沙箱那一课),或者 「命令前缀授权是怎么持久化和匹配的?」