V2 工具与权限
只有一个执行体的 Tool.make 契约、进程级与 Location 级两层注册表、结算边界上的统一输出限界,以及无规则匹配时兜底为 ask、可保存项目级批准的 PermissionV2。
V1 的工具契约与权限系统分别在工具契约与注册表和权限系统讲过。V2 把它们搬进 packages/core,并借机做了减法。packages/core/src/tool/AGENTS.md 的禁令写得很直接:
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
Tool.make:不透明的单一执行体
一个 V2 工具由 description、input、output、execute 与可选的 toModelOutput 组成(packages/core/src/tool/tool.ts)。input、output 都是不依赖任何服务的 Effect Schema 编解码器;工具需要的服务在构造时取得,由 execute 闭包捕获。Tool.make 返回的只是一个冻结的空对象,定义生成与结算逻辑存在模块私有的 WeakMap 里,调用方既拿不到 schema,也拿不到执行函数。
结算顺序固定:解码输入 → execute → 按输出 schema 编码 → 投影成模型内容。输入不合法根本不会调用工具,输出不合法也不会产生“成功”。每次调用拿到同一个上下文:
export interface Context {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
}packages/core/src/tool/tool.ts:9。和 V1 的 Tool.define 相比,V1 的上下文里带着 abort 信号、ask() 权限助手和整段消息历史;V2 只给身份,取消靠 Effect 中断,权限由工具自己去问。
两层注册表
| 层 | 服务 | 生命周期 |
|---|---|---|
| 进程级 | ApplicationTools(公开为 opencode.tools.register) | 所有 Location 共享 |
| Location 级 | ToolRegistry,外加只暴露注册能力的 Tools | 每个 Location 一份 |
名字不属于工具本身,而是注册时的 record key,必须匹配 ^[A-Za-z][A-Za-z0-9_-]{0,63}$。两层规则一致:同名时最新的活跃注册胜出;注册绑定在调用方的 Scope 上,作用域关闭只撤销自己,并露出上一个注册;Location 注册优先于进程级注册。
每个 provider 轮次开始时,runner 调 materialize(agent 权限):先把整个工具都被 deny 的定义从目录里剔除(AGENTS.md 强调这只是目录可见性,不是执行授权),再为每个名字记下当时注册的身份。结算时核对:
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
const registration =
local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name)
if (!registration)
return {
result: {
type: "error" as const,
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
},
}
if (advertised && registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }packages/core/src/tool/registry.ts:50。本轮开始后被替换或撤销的注册只会得到 Stale tool call,不会悄悄执行另一个实现;核对通过后才捕获处理函数,此后再改注册也不影响进行中的调用。
输出限界:只在一个地方截断
工具返回完整的领域输出,从不自己截断。ToolOutputStore.bound(packages/core/src/tool-output-store.ts)是唯一的限界边界:只统计文本内容(没有内容时统计结构化输出),默认上限 2000 行、50 KB,可用配置项 tool_output 的 max_lines、max_bytes 调整。超限时全文写进数据目录下的 tool-output/,模型收到头尾预览和一行 ... output truncated; full content saved to ...;媒体原样保留;落盘失败则结算失败,而不是“有损的成功”。托管文件保留 7 天,由一个全局任务每小时清理一次。
生产者的内存限额是另一回事:bash 的输出捕获上限 1 MiB,那是进程边界的保护,不冒充模型输出截断。
12 个内置工具
builtins.ts 把内置工具组合成一个 location 节点,每个工具自己申请权限:
| 工具 | 权限 action | 要点 |
|---|---|---|
read | read | 文本按行分页(2000 行、50 KB);jpeg、png、gif、webp 图片先归一化;目录分页 |
write | edit | 覆盖或新建,保留 BOM |
edit | edit | 精确替换,多处命中须 replaceAll;批准后文件若被改动则拒绝写入 |
apply_patch | edit | 先解析、定位、批准全部目标再顺序提交;中途失败报告已应用部分,不支持移动与原子回滚 |
bash | bash | 资源就是命令原文;默认 2 分钟、最长 10 分钟 |
glob、grep | 同名 | 基于 ripgrep,根目录默认为当前 Location |
webfetch | webfetch | 最大 5 MB,默认 30 秒,输出 markdown、text 或 html |
websearch | websearch | 本地调用 Exa 或 Parallel,与 provider 托管的搜索无关 |
skill | skill | 注入 SKILL.md 与最多 10 个附属文件路径 |
todowrite、question | 同名 | 写会话待办;向用户提问 |
读写文件的工具都先经 LocationMutation.resolve:相对路径不许逃出 Location,符号链接逃逸也会被拦下;绝对的外部路径要额外通过 external_directory 批准。注释里列着仍待移植的:edit 的模糊匹配、task、LSP、repo_clone、repo_overview、plan_exit 与 Code Mode;MCP 与插件工具也还没有规范的注册方式。
PermissionV2:兜底为 ask
规则是 action、resource、effect 三元组,求值只有几行:
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
action,
resource: "*",
effect: "ask",
}
)
}packages/core/src/permission.ts:76。多组规则拉平后最后一个匹配者胜出,谁都不匹配就是 ask。不过这只是兜底:内置 agent 的默认规则(packages/core/src/plugin/agent.ts:108)与 V1 一样先放一条 { action: "*", resource: "*", effect: "allow" },再把越界目录与 .env 读取改成 ask。决策顺序在 evaluateInput 里:先只看 agent 配置的规则,只要有一个资源被 deny 就直接 BlockedError,保存的批准推翻不了配置里的 deny;否则把项目级的“已保存批准”(都是 allow)接在后面一起求值,多个资源里有 deny 即 deny、有 ask 即 ask。解析不到 agent 权限时,兜底规则集是全部拒绝。
需要询问时,服务发布 Asked 事件并挂起一个 Deferred,等客户端答复:
reject:附带留言变成CorrectedError,模型能看到反馈;不留言则是DeclinedError,runner 据此停下整个循环。同一会话里其他挂起的请求一并拒绝。always:把请求的save模式写进permission表(按项目、action、resource 去重),再自动放行其他因此变为 allow 的挂起请求。once:只放行这一次。
规格里有一句必须照录:
Bash is not sandboxed: the spawned shell runs with the host user's filesystem, process, and network authority.
外部 workdir 仍要 external_directory 批准;命令参数里绝对路径的扫描只产生警告,既不是沙箱,也不会触发批准。
与 V1 对照
| V1 | V2 | |
|---|---|---|
| 定义 | Tool.define(id, init),id 随定义 | Tool.make,名字由注册决定 |
| 输出 | 字符串加 metadata | 有 schema 的结构化输出,另行投影 |
| 权限 | 上下文注入 ask() | 工具捕获 PermissionV2 自行断言 |
| 截断 | Truncate 服务 | 结算边界统一限界 |
| 规则字段 | permission、pattern | action、resource |
下一篇:存储:SQLite 与事件投影——这些结果最终落在哪、以什么顺序落。