# V2 工具与权限

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

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.net/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.net/manual/opencode/tools-v2
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

V1 的工具契约与权限系统分别在[工具契约与注册表](https://daiw.net/manual/opencode/tool-contract)和[权限系统](https://daiw.net/manual/opencode/permission)讲过。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 编码 → 投影成模型内容。输入不合法根本不会调用工具，输出不合法也不会产生“成功”。每次调用拿到同一个上下文：

```ts
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 强调这只是目录可见性，不是执行授权），再为每个名字记下当时注册的身份。结算时核对：

```ts
    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` 三元组，求值只有几行：

```ts
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 与事件投影](https://daiw.net/manual/opencode/storage)——这些结果最终落在哪、以什么顺序落。
