V2 工具与权限

只有一个执行体的 Tool.make 契约、进程级与 Location 级两层注册表、结算边界上的统一输出限界,以及无规则匹配时兜底为 ask、可保存项目级批准的 PermissionV2。

作者 David更新于 第 35 篇(共 38 篇)

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要点
readread文本按行分页(2000 行、50 KB);jpeg、png、gif、webp 图片先归一化;目录分页
writeedit覆盖或新建,保留 BOM
editedit精确替换,多处命中须 replaceAll;批准后文件若被改动则拒绝写入
apply_patchedit先解析、定位、批准全部目标再顺序提交;中途失败报告已应用部分,不支持移动与原子回滚
bashbash资源就是命令原文;默认 2 分钟、最长 10 分钟
glob、grep同名基于 ripgrep,根目录默认为当前 Location
webfetchwebfetch最大 5 MB,默认 30 秒,输出 markdown、text 或 html
websearchwebsearch本地调用 Exa 或 Parallel,与 provider 托管的搜索无关
skillskill注入 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 对照

V1V2
定义Tool.define(id, init),id 随定义Tool.make,名字由注册决定
输出字符串加 metadata有 schema 的结构化输出,另行投影
权限上下文注入 ask()工具捕获 PermissionV2 自行断言
截断Truncate 服务结算边界统一限界
规则字段permission、patternaction、resource

下一篇:存储:SQLite 与事件投影——这些结果最终落在哪、以什么顺序落。

本页目录