# 权限模式与规则

> 四种权限模式在各入口的默认值与真实语义，PermissionService 的判定顺序，规则的结构与匹配算法，审批请求怎样经 broker 交给终端或桌面并与钩子竞速，“始终允许”与 Full access 各存在哪一层，以及 Plan 与 yolo 的例外。

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

模型每发起一次工具调用，执行器都要先回答一个问题：放行、拒绝，还是停下来问人。ZCode 把这件事拆成三块。`apps/zcode-cli/packages/core/src/permission/service.ts` 里的 `PermissionService` 是同步的纯判定，只给出 `allow`、`ask`、`deny` 三种结论（`service.ts:59`）；`apps/zcode-cli/packages/core/src/tool/executor/permission-*.ts` 把结论落地：加载项目规则、叠加钩子的意见、把询问交给 broker、保存“始终允许”；broker 则由宿主提供，终端 TUI、无头 `-p` 和桌面与 Web 背后的 ZCode Protocol 服务端各有一份。

权限在工具调用流水线里的位置（输入校验与 PreToolUse 钩子之后、handler 之前）见[执行器：调度、审批、超时与结果](https://daiw.net/manual/zcode/tool-executor)，Bash 命令怎样被判成“只读”见 [Bash：解析、只读判定与后台任务](https://daiw.net/manual/zcode/bash)。

## 四种模式与各入口的默认值

`CollaborationMode` 有五个值（`apps/zcode-cli/packages/contracts/src/interfaces/session.port.ts:32`），但运行时真正保存的执行状态只有两个字段：权限档位 `mode`（`build`、`edit`、`yolo`、`auto`）和独立的 `planEnabled` 开关，`plan` 只在读取旧格式的边界上被接受（`packages/shared/src/execution-state.ts:3`）。传入 `mode: "plan"` 时档位保持原值，只把 `planEnabled` 置为 true（`src/execution-state.ts:21`），AgentRuntime 构造时就按这条规则归一配置（`apps/zcode-cli/packages/core/src/runtime/agent-runtime.ts:237`）。所以 Plan 其实是叠在某个档位上的只读约束，TUI 只是在 `planEnabled` 为真时把它显示成 Plan（`apps/zcode-cli/packages/cli/src/tui-command-state.ts:48`）；Plan 与 Goal 也不能同时开启（`apps/zcode-cli/packages/core/src/runtime/execution-state.ts:58`）。

| 模式 | TUI 里的说明 | 代码里的语义 |
| --- | --- | --- |
| `build` | Ask before each file changes. | 只读工具放行，有副作用的询问 |
| `edit` | Edit selected files or relevant workspace files automatically. | 在 build 之上，工作区文件编辑直接放行 |
| `plan` | Inspect the code and present a plan before editing. | 当前档位加 `planEnabled`：只读、非破坏性的放行，其余拒绝 |
| `yolo` | Edit and run commands with fewer confirmations. | 普通工具一律放行，交互类与 `alwaysAsk` 工具除外 |
| `auto` | 不在 TUI 里出现 | 预留未实现，普通工具一律拒绝（`service.ts:140`） |

说明文字出自 `apps/zcode-cli/packages/tui/src/app-mode-command.ts:10`。TUI 里 `Shift+Tab` 按 plan、build、edit、yolo 的顺序轮换（`apps/zcode-cli/packages/tui/src/app-mode.ts:5`、`apps/zcode-cli/packages/tui/src/app-keyboard-helpers.ts:73`），`/mode` 可以直接选。命令行的 `--mode` 只接受这四个值，大小写不敏感，传 `auto` 会报错退出（`apps/zcode-cli/packages/cli/src/run.ts:133`）。根目录 NOTICE 对默认值的说明是（`NOTICE.md:11`）：

> 共享运行配置默认采用 `build` 权限模式；独立 CLI 通过 `--prompt` 执行非交互任务时，未指定 `--mode` 会采用 `yolo`。

代码与之一致，但入口之间的差别更细：

| 入口 | 没有显式指定模式时 | 出处 |
| --- | --- | --- |
| `zcode -p "..."` | 固定补 `yolo` | `run.ts:42`、`run.ts:494` |
| `zcode --target "..."` | 不补默认，走下面的回落链 | `run.ts:510` |
| `zcode`、`zcode tui` | 回落链 | `apps/zcode-cli/packages/cli/src/tui-prompt-handler.ts:160` |
| 桌面、Web（经 `app-server`） | 取 `session/create` 参数里的 `mode`，没有再走回落链 | `apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3328` |

回落链是：显式模式、本项目上次选过的模式、配置项 `permission.mode`（默认 `build`，`apps/zcode-cli/packages/contracts/src/config/index.ts:295`），见 `apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:241` 与 `apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:123`。“上次选过的模式”是每次切换模式时写下的项目级偏好（`apps/zcode-cli/packages/bootstrap/src/app/session-facade.ts:428`）。恢复旧会话时，会话自己保存的执行状态又优先于项目偏好（`apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:213`）。

配置文件的 `permission` 段有五个键（`apps/zcode-cli/packages/adapters/src/config/schema.ts:13`）：`mode` 就是上面说的默认档位；`allowedTools`、`disallowedTools` 按精确工具名比较；`autoApproveHighRisk` 只跳过“高风险要问”那一格，Bash 这类声明了 `needsApproval` 的工具随后仍会在“有副作用”一格被问到（`service.ts:474`、`service.ts:497`）；`allowMediumRiskInAuto` 会传进 `PermissionService`（`create-app.ts:346`），但判定逻辑里没有任何地方读它。

## 各模式下放行、询问还是拒绝

按各工具声明的 `readOnly`、`destructive`、`sideEffectScope`、`riskLevel`、`needsApproval` 走一遍判定，常用工具的结论如下：

| 工具 | build | edit | plan | yolo |
| --- | --- | --- | --- | --- |
| 只读：`Read`、`Glob`、`Grep`、`WebSearch`、`TodoWrite`、`Agent`、`Skill` | 放行 | 放行 | 放行 | 放行 |
| 写：`Write`、`Edit` | 询问 | 放行 | 拒绝 | 放行 |
| `Bash`，命令被判为只读 | 放行 | 放行 | 放行 | 放行 |
| `Bash`，其他命令 | 询问 | 询问 | 拒绝 | 放行 |
| 网络：`WebFetch` | 询问，预批准域名放行 | 同 build | 放行 | 放行 |
| MCP 工具 | 询问 | 询问 | 未声明 `destructiveHint` 的放行 | 放行 |
| 交互：`AskUserQuestion` | 询问 | 询问 | 询问 | 询问 |
| `ExitPlanMode` | 拒绝 | 拒绝 | 询问（即计划审批） | 拒绝 |
| `CreateWorkflow`、`AmendWorkflow`、`SaveWorkflow` | 询问 | 询问 | 询问 | 询问 |

几处需要解释：`WebFetch` 声明为只读但 `needsApproval` 为真（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch.ts:201`），build 下要问，Plan 只看只读与否，所以反而放行；Bash 的静态声明是 `high`、`system`（`apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:456`），只有只读判定通过时才在运行时改成低风险只读（`bash.ts:77`）；MCP 工具一律 `needsApproval: true`，只读与破坏性取自服务端给的注解（`apps/zcode-cli/packages/core/src/mcp/index.ts:108`）。`EnterPlanMode` 在任何模式下都直接放行（`apps/zcode-cli/packages/core/src/permission/plan-mode-policy.ts:23`）。

## PermissionService 的判定顺序

`checkPermission`（`service.ts:97`）从上到下，先命中者定案：

```mermaid
flowchart TD
  A["checkPermission"] --> B{"Plan 切换工具"}
  B -->|"EnterPlanMode"| AL["allow"]
  B -->|"未开 Plan 却调 ExitPlanMode"| DN["deny"]
  B -->|"其他工具"| C{"requiresUserInteraction"}
  C -->|"是"| C2["在禁用名单则 deny，否则 ask"]
  C -->|"否"| D{"alwaysAsk"}
  D -->|"是"| E["auto、禁用名单、项目 deny 则 deny<br/>会话规则或本会话 run 的修订则 allow<br/>其余 ask"]
  D -->|"否"| F{"yolo 且未开 Plan"}
  F -->|"是"| AL
  F -->|"否"| G["auto 则 deny<br/>禁用名单、项目 deny 则 deny<br/>项目 ask 则 ask"]
  G --> H{"planEnabled"}
  H -->|"是"| P["只读非破坏、非破坏性 MCP 等则 allow<br/>其余 deny"]
  H -->|"否"| I["项目 allow、WebFetch 预批准<br/>草稿目录写入、allowedTools 则 allow"]
  I --> J["edit 下工作区编辑则 allow<br/>否则按 build 规则"]
```

中间最关键的一段是 `service.ts:130`：

```ts
    // 声明 alwaysAsk 的工具必须经过用户确认，不能被权限模式的放行分支绕过。
    if (capability.alwaysAsk) {
      return this.checkAlwaysAsk(context, capability, projectRules, rulePolicy);
    }

    const planEnabled = context.planEnabled ?? context.mode === "plan";
    if (context.mode === "yolo" && !planEnabled) {
      return this.allow(context, capability, "mode.yolo", "Yolo mode bypasses permission prompts");
    }

    if (context.mode === "auto") {
      return this.deny(
        context,
        capability,
        "mode.auto.unimplemented",
        "Auto mode is reserved but not implemented yet",
      );
    }

    if (this.config.disallowedTools.has(context.toolName)) {
      return this.deny(
        context,
        capability,
        "rule.disallowedTools",
        `Tool ${context.toolName} is explicitly disallowed`,
      );
    }
```

yolo 排在禁用名单和项目规则之前，这是有意为之：`checkAlwaysAsk` 的注释写明它只为 `alwaysAsk` 工具补一遍硬阻断，不改其他工具的既有优先级，“尤其 yolo 目前先于 disallowedTools 放行这一点”（`service.ts:331`）。build 规则（`service.ts:452`）依次是：只读、非破坏且不需审批的放行；`critical` 询问；`high` 在没开 `autoApproveHighRisk` 时询问；作用域为 `session` 的低风险操作放行；需要审批、有破坏性或副作用作用域不是 `none` 的询问；剩下的放行。edit 只多一条：权限名是 `edit` 且作用在工作区的直接放行（`service.ts:518`）。

判定结果还要经过两道叠加（`apps/zcode-cli/packages/core/src/tool/executor/permission-flow.ts:92`）。一是 PreToolUse 钩子：钩子说 `allow` 能把询问变成放行，但抹不掉 `alwaysAsk` 的询问；钩子说 `ask` 能把放行变成询问；拒绝则原样保留（`apps/zcode-cli/packages/core/src/tool/executor/hook-flow.ts:195`）。二是记忆文件：`Write`、`Edit` 写进记忆目录的 `.md` 文件一律放行，连 Plan 的拒绝也会被改写，只有其他拒绝、`alwaysAsk`、项目 ask 规则和钩子的 ask 保留（`apps/zcode-cli/packages/core/src/tool/executor/memory-file-permission.ts:74`），记忆本身见[项目记忆](https://daiw.net/manual/zcode/memory)。

## 规则的结构与匹配

规则不是 `Bash(git status:*)` 这样的字符串，而是两个字段的结构：`toolName` 加可选的 `ruleContent`（`apps/zcode-cli/packages/contracts/src/interfaces/permission.port.ts:19`），一套规则集按 `allow`、`deny`、`ask` 分组（`permission.port.ts:24`）。Claude Code 把工具名和内容写进同一个 `Tool(specifier)` 字符串（见[配置文件](https://daiw.net/manual/claude-code/configuration-files)），ZCode 则分开存，一条前缀规则写作 `toolName` 为 `Bash`、`ruleContent` 为 `git status:*`。括号写法只出现在工具禁用名单里（命令行的 `--disallowedTools` 与协议会话参数），而且括号里的内容会被丢掉，后面细说。

匹配分三步（`service.ts:233`）：

1. **工具名**：精确相等；`Edit` 规则同时覆盖 `Write`（`service.ts:264`）；保留键 `zcode:permission-capability:official_cua`（`packages/shared/src/zcode-protocol-legacy-types.ts:45`）只匹配宿主验证过的官方 Computer Use 工具，同名第三方 MCP 冒充不了（`service.ts:274`）。
2. **取主体**：没有 `ruleContent` 的规则匹配整个工具；否则从输入里取第一个字符串字段，顺序是 `command`、`url`、`file_path`、`path`、`pattern`、`patch_text`（`service.ts:292`）。`WebFetch` 例外，主体是 `domain:` 加小写主机名（`apps/zcode-cli/packages/core/src/permission/rule-matching.ts:19`）。
3. **比内容**（`service.ts:307`）：

```ts
  private matchesRuleContent(subject: string, ruleContent: string): boolean {
    if (ruleContent.endsWith(":*")) {
      const prefix = ruleContent.slice(0, -2);
      return (
        subject === prefix || subject.startsWith(`${prefix} `) || subject.startsWith(`${prefix}\t`)
      );
    }

    if (ruleContent.includes("*")) {
      return wildcardToRegExp(ruleContent).test(subject);
    }

    return subject === ruleContent;
  }
```

`:*` 结尾是“前缀加词边界”，`git status:*` 能匹配 `git status` 和 `git status -s`，匹配不了 `git statusx`；其余的 `*` 转成首尾锚定的通配正则（`rule-matching.ts:1`）；都没有就逐字相等。

Bash 不走这里，而是由工具自带的规则策略判定（`apps/zcode-cli/packages/core/src/tool/handlers/bash-command-permission-policy.ts:94`）：整条命令逐字相等的规则总能命中；命令能被安全解析（没有重定向、没有动态词）时，按 `&&`、`|` 等拆出的子命令逐个比，deny 与 ask 规则命中任意一个子命令即成立，allow 规则则要求每个非只读子命令都被覆盖，只读子命令免检；解析不安全时只认逐字相等（`apps/zcode-cli/packages/core/src/tool/handlers/bash-command-rule-evaluator.ts:13`）。拆分与只读判定的细节归 [Bash](https://daiw.net/manual/zcode/bash)。

项目规则的位次也要记住：deny、ask 在 Plan 分支之前，allow 在其后，所以“始终允许”解不开 Plan 的只读约束；yolo 在它们之前，所以 yolo 下项目规则都不看。

## 审批：从 broker 到终端与桌面

判定为 `ask` 后，执行器先调用工具自带的 `prepareApproval`（只有工作流一类工具声明了），它可以因为“脚本根本编不过”之类的理由不弹窗、直接交给 handler 回诊断（`apps/zcode-cli/packages/core/src/tool/handlers/create-workflow.ts:13`），但生成预览出错时一律照问（`apps/zcode-cli/packages/core/src/tool/executor/approval-gate.ts:36`）。接着生成 `perm_` 加 UUID 的请求 ID（`permission-flow.ts:160`），发出 `PermissionRequested` 事件，事件里带着建议规则，以及会话存储是否支持 Full access（`apps/zcode-cli/packages/core/src/tool/executor/events.ts:136`）。然后，PermissionRequest 钩子链与 broker 并发竞速：

```mermaid
sequenceDiagram
  participant X as 执行器
  participant K as PermissionRequest 钩子
  participant B as Broker
  participant U as TUI 或桌面
  X->>X: 判定为 ask，发出 PermissionRequested
  par 竞速
    X->>B: requestPermission
    B->>U: 弹出审批
    U-->>B: 允许一次、始终允许、拒绝或 Full access
    B-->>X: 应答
  and
    X->>K: 运行钩子链
    K-->>X: allow、deny、modify 或无决定
  end
  X->>X: 先到者生效，败者被 abort
  X->>X: 保存规则后执行工具
```

注释交代了为什么要竞速：过去钩子链先串行跑完才启动 broker，同步阻塞的钩子挂着时确认窗已经显示，但点击没有归宿，“确认窗永久死亡”（`permission-flow.ts:182`）。竞速本身（`apps/zcode-cli/packages/core/src/tool/executor/permission-responder-race.ts:59`）：

```ts
      // broker 先启动：requestPermission 内同步注册应答 deferred，保证确认窗一可见
      // 用户点击就有归宿，不给 hook 留任何独占窗口期。
      input
        .requestBroker(brokerController.signal, () => {
          if (settled || brokerController.signal.aborted) return false;
          brokerClaimed = true;
          hookController.abort();
          return true;
        })
        .then(
          (result) => settle(() => resolve({ result, source: "broker" }), hookController),
      // ...
      input.runHooks(hookController.signal).then(
        (decision) => {
          // 无决定 = 退赛：不迁移竞速状态，broker 继续单边等待。
          if (decision === undefined || brokerClaimed) return;
          settle(() => resolve({ result: decision, source: "hook" }), brokerController);
        },
```

传给 broker 的 `claimResponse` 回调用于带副作用的应答：Full access 要先“认领”胜者、中止钩子，再去提交事务。钩子链出错只算退赛，不替用户做拒绝决定（`permission-flow.ts:185`）。等待没有默认超时：`permissionTimeoutMs` 不配就不传（`apps/zcode-cli/packages/core/src/runtime/types.ts:132`）。

broker 有几种实现：

| 宿主 | broker | 行为 |
| --- | --- | --- |
| 没有配置应答方 | `DenyPermissionBroker` | 运行时的缺省值（`agent-runtime.ts:249`），一律拒绝，理由 `No permission client configured`（`apps/zcode-cli/packages/core/src/permission/broker.ts:24`） |
| TUI | 进程内转发 | 转给审批面板，面板不在时拒绝（`tui-prompt-handler.ts:78`） |
| `-p` 无头 | `createHeadlessPermissionBroker` | 只放行 `CreateWorkflow`、`AmendWorkflow`，其余委托给拒绝 broker（`apps/zcode-cli/packages/cli/src/headless-workflow.ts:31`） |
| 桌面、Web | 协议 broker | `AskUserQuestion` 转问卷，`ExitPlanMode` 转计划审批，其余转审批卡片（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-broker.ts:43`） |

协议侧还有第二层竞速：同一请求同时以旧协议的反向请求 `interaction/requestPermission` 和 V4 的待处理交互挂出，谁先应答算谁（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-response-race.ts:20`）；等待期间按同一个请求 ID 重发，间隔从 1 秒起每次翻倍、封顶 10 秒，让从快照恢复的界面重新登记（`interaction-broker.ts:41`、`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server.ts:121`、`zcode-protocol/server.ts:882`）；多个客户端同时点，先到先得，晚到的应答按幂等成功收口（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/interaction-background.ts:41`）；认不出的 optionId 一律按拒绝处理（`interaction-broker.ts:191`）。

审批选项由 `buildProtocolPermissionOptions` 生成（`apps/zcode-cli/packages/bootstrap/src/permission-options.ts:37`）：Allow once、Always allow in this project、Deny；工具声明 `askOptions.allowAlways: "session"` 时第二项换成 Always allow in this session，声明 `false` 时去掉第二项。在桌面与 Web 上拒绝时，模型收到的工具结果写明工具没有执行、请停下等待指示，填写的反馈接在后面（`permission-options.ts:8`、`permission-options.ts:11`）。TUI 的面板只有 Allow once、Always allow in this project、Deny 三项，`Esc` 等于拒绝（`apps/zcode-cli/packages/tui/src/app-approval.ts:49`），拒绝理由只是一句 `Denied in TUI`（`app-approval.ts:130`）；它不处理会话选项，也没有 Full access；`CreateWorkflow` 与 `AmendWorkflow` 的确认在 TUI 里被自动放行，注释称之为“记录在案的 CLI 例外”（`apps/zcode-cli/packages/tui/src/app-permission.ts:16`）。

## “始终允许”存在哪一层

| 应答 | 结果里的字段 | 存到哪里 | 何时失效 |
| --- | --- | --- | --- |
| Always allow in this project | `permissionUpdates` | 会话库 `local_setting` 表，`scope` 为 project、`namespace` 为 permission、`key` 为 ruleset | 仓库里没有找到查看或删除它的命令与界面 |
| Always allow in this session | `sessionPermissionUpdates` | `PermissionService` 实例的内存 | 重启、冷恢复、`/new` |
| PermissionRequest 钩子返回的规则 | `permissionUpdates` | 同项目规则 | 同上 |
| Full access | 独立命令 | 会话的执行状态与授权回执 | 把模式切回去 |

项目规则的读写在 `apps/zcode-cli/packages/core/src/tool/executor/permission-rules-persistence.ts:14`，合并时按工具名加内容去重（`apps/zcode-cli/packages/core/src/tool/executor/permission-rules.ts:32`），落进 `local_setting` 表（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/local-settings.ts:27`），读取时旧的 `permission` 表仍作兜底（`local-settings.ts:21`）。会话库默认在 `~/.zcode/cli/db/db.sqlite`（`apps/zcode-cli/packages/contracts/src/config/index.ts:303`）。“项目”指会话的 `projectID`：工作目录路径转小写，连续的非 `[a-z0-9._-]` 字符折成一个 `-`，去掉首尾的 `-`，只取前 80 个字符（`apps/zcode-cli/packages/bootstrap/src/app/paths.ts:29`），所以从代码看，前 80 个字符相同的两个深层目录会共用一套规则。

会话规则只服务 `alwaysAsk` 那道门，普通工具的判定不看它（`service.ts:84`、`service.ts:365`）；会话选项授予的是整个工具、不带内容，注释给的理由是脚本每次都不同，授权对象只能是工具本身（`permission-options.ts:105`）。

## 权限建议怎样生成

确认窗里“始终允许”要保存的规则，就是 `PermissionRequested` 携带的建议。非 Bash 工具由 `buildDefaultPermissionUpdates` 生成：工具名，加输入里第一个非空的 `command`、`url`、`file_path`、`path`、`pattern` 作为内容（`apps/zcode-cli/packages/core/src/tool/executor/permission-suggestions.ts:6`），官方 Computer Use 工具则授予上面那个保留键。Bash 的建议来自规则策略（`bash-command-permission-policy.ts:135`）：只为非只读的子命令生成规则，每个子命令求一个稳定前缀再加 `:*`，例如 `npm run build` 得到 `npm run build:*`，`python -m pytest` 得到 `python -m pytest:*`，`make test` 得到 `make test:*`；前缀求不出、根命令属于 `rm`、`chmod`、`sh`、`dd` 等高风险名单（`bash-command-permission-policy.ts:19`）、命令解析不安全或需要超过 5 条规则时，退回逐字保存整条命令，前缀怎样求见 [Bash](https://daiw.net/manual/zcode/bash)。TUI 与桌面会把其中的 Bash 前缀列在选项旁边，最多 5 条（`app-approval.ts:91`）。

从代码看，`WebFetch` 的默认建议把整条 URL 写进 `ruleContent`，而判定时拿来比的是 `domain:` 加主机名，两者对不上，这条“始终允许”不会生效；想按域名放行，得有人写 `domain:example.com` 形式的规则，目前只有 PermissionRequest 钩子能写入。

## 审批之后输入又被改了

PreToolUse 钩子改写输入发生在判定之前，权限看到的已经是改后的输入。麻烦的是 PermissionRequest 钩子：它可以在应答时带 `updatedInput`，边批准边改参数，比如把写入目标换个路径。这时执行器先把新输入归一化、按 schema 校验，再用 `recheckPermissionHookModifiedInput` 以改后的输入重跑一遍 `checkPermission` 与记忆文件规则（`apps/zcode-cli/packages/core/src/tool/executor/permission-input-recheck.ts:24`）。结果分三种（`permission-input-recheck.ts:65`）：被拒绝，整次调用拒绝；是普通的询问或放行，钩子本身就算批准人，沿用它的决定；只有撞上项目 ask 规则或指向记忆文件时，才用同一个请求 ID 再问一次用户（`permission-input-recheck.ts:88`）。用户自己在确认窗里改输入（`modify`）则只重新归一化和校验，不再重判（`permission-flow.ts:415`）。

## Full access

桌面与 Web 的审批卡片上还有第四个按钮 Full access（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/product-projection.ts:3146`）。它出现需要三个条件：会话存储实现了 `commitPermissionFullAccess`，请求不是子 Agent 转上来的，工具也没有声明选项策略（`interaction-broker.ts:102`）。按下后 `grantPermissionFullAccess` 把本会话切到 `yolo`，`planEnabled` 不变，同时把队列里已受理、还没开始的输入也改成 yolo（`apps/zcode-cli/packages/core/src/runtime/permission-full-access.ts:18`），然后当前请求按“允许一次”收口（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/interaction-registry.ts:170`）。这些写入在一个 `begin immediate` 的 SQLite 事务里完成，同一张回执已存在就直接提交返回（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/permission-full-access.ts:17`），改写队列的部分在 `repositories/permission-full-access.ts:34`：

```ts
    for (const id of input.queueItemIds) {
      const row = read.get(id, input.sessionID);
      if (!row || typeof row.payload !== "string")
        throw new Error(`Pending input unavailable: ${id}`);
      const payload = JSON.parse(row.payload) as Record<string, unknown>;
      for (const key of ["intent", "conversationInputIntent"]) {
        const intent = payload[key];
        if (intent && typeof intent === "object" && !Array.isArray(intent)) {
          payload[key] = { ...intent, mode: "yolo" };
        }
      }
      write.run(JSON.stringify(payload), Date.now(), id, input.sessionID);
    }
    saveSessionEntry(db, input.execution);
    saveSessionEntry(db, input.receipt);
    db.exec("commit");
```

回执 ID 由会话 ID 加交互 ID 拼成（`apps/zcode-cli/packages/core/src/runtime/permission-full-access.ts:37`），类型是 `runtime/permission_full_access`（`apps/zcode-cli/packages/contracts/src/interfaces/permission-full-access.ts:3`），重试时按回执里固定的队列范围重放，不会顺手把后来入队的输入也提权（`apps/zcode-cli/packages/core/src/runtime/permission-full-access.ts:17`）。事务已提交而事件没来得及发布时，恢复动作登记在 `unpublishedPermissionGrants` 里，下一次授权或模式切换会先补发（`apps/zcode-cli/packages/core/src/runtime/permission-grant-recovery.ts:3`、`apps/zcode-cli/packages/core/src/runtime/execution-state.ts:50`）。它只改这一个会话，不写项目偏好；恢复会话时读回执行状态，yolo 仍在。代码里没有单独的“撤销完全访问”，把模式切回去就是关闭。老版本客户端若原样回传这个选项的 response，拿到的是一条拒绝（`product-projection.ts:3152`）。

## Plan 的只读约束与草稿例外

Plan 分支（`service.ts:404`）只放三类：只读且非破坏的工具；非破坏的 MCP 工具；声明 `allowedInPlanMode`、作用域为会话、非破坏且不需审批的控制动作，目前只有 `RespondToCoordinator`（`apps/zcode-cli/packages/core/src/tool/handlers/respond-to-coordinator.ts:65`）。其余一律以 `Plan mode only allows read-only, non-destructive tools` 拒绝。`ExitPlanMode` 声明需要用户交互（`apps/zcode-cli/packages/core/src/tool/handlers/plan-mode.ts:155`），在 Plan 里总是询问，协议 broker 把它转成计划审批问卷，这部分交互见 [Todo、提问与 Plan 模式](https://daiw.net/manual/zcode/interaction-tools)。

<Callout type="warn">
  从代码看，Plan 对 MCP 工具只看 `destructiveHint` 注解。浏览器插件启用时由宿主注册的 `mcp__node_repl__js` 能在本机执行任意 Node 代码，`mcp/index.ts` 为它设了 `high` 风险与 `system` 作用域，却没有设破坏性，宿主工具列表里也没有注解（`apps/zcode-cli/packages/node-repl-host/src/server.ts:76`），因此它在 Plan 下会被放行而不询问。内置版本的 `js` 工具权限名是 `node_repl`，不受此影响。
</Callout>

两个“写入例外”容易混淆。记忆文件的放行连 Plan 也覆盖，前面已经讲过。工作流草稿的免确认则不覆盖 Plan：`Write`、`Edit` 的 `file_path` 落在 `<工作目录>/.zcode/workflow-drafts/` 之内时放行（`apps/zcode-cli/packages/core/src/permission/workflow-draft-path.ts:73`，目录常量见 `apps/zcode-cli/packages/contracts/src/tools/saved-workflow.ts:25`），但它排在 Plan 分支之后，注释说 Plan 必须继续拦下一切写入，草稿也是写入（`service.ts:198`）。这条判定只做路径字符串运算，不碰文件系统，拿不到工作目录就不放行；理由是那个目录由工具自己写、自带忽略全部内容的 `.gitignore`，而让脚本真正跑起来另有 `CreateWorkflow` 的确认门（`workflow-draft-path.ts:52`）。

## yolo 下仍要确认的

- 需要用户交互的工具：`AskUserQuestion` 与 `ExitPlanMode`（`service.ts:112`）。
- 声明 `alwaysAsk` 的工具：`CreateWorkflow`、`AmendWorkflow`、`SaveWorkflow`（`apps/zcode-cli/packages/core/src/tool/handlers/create-workflow.ts:352`、`apps/zcode-cli/packages/core/src/tool/handlers/save-workflow.ts:286`）。前两个可以选本会话免确认，修订本会话自己发起、且不是用户亲手停下的 run 也免确认（`service.ts:378`）；`SaveWorkflow` 不给“始终允许”。
- 开着 Plan 的 yolo：`yolo && !planEnabled` 不成立，照 Plan 规则走。
- 钩子：PreToolUse 钩子的 deny 与 ask 在 yolo 下照样生效。

反过来，yolo 会跳过项目的 deny、ask 规则和配置文件里的 `permission.disallowedTools`，所以想在 yolo 下禁掉某个工具，要用 `--disallowedTools` 把它从工具面上拿掉。子 Agent 中，general-purpose 与自定义 Agent 共用父会话的 `PermissionService`，Explore 另起一份（`apps/zcode-cli/packages/core/src/runtime/methods/subagent.ts:315`），见[子 Agent](https://daiw.net/manual/zcode/subagents)。

## `--disallowedTools`

写法由 `extractDisallowedToolsArgs` 解析（`apps/zcode-cli/packages/cli/src/arguments.ts:127`），它在全局参数解析之前先把这个选项摘出来：

- 两种拼写 `--disallowedTools`、`--disallowed-tools`，支持 `--disallowedTools=Bash` 的等号形式（`arguments.ts:135`）；
- 选项后面可以跟多个值，直到下一个以 `-` 开头的参数，一个都没有就报错（`arguments.ts:147`）；
- 值里的逗号和空格都是分隔符，但括号里的不算（`arguments.ts:179`）；`web_search` 会被规范成 `WebSearch`（`arguments.ts:222`）。

它有三处效果：注册内置工具和 MCP 工具时直接跳过（`apps/zcode-cli/packages/core/src/tool/handlers/index.ts:202`、`apps/zcode-cli/packages/core/src/mcp/index.ts:67`），回合里向模型出示工具时再过滤一遍（`apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:110`），并投影进 `PermissionService` 的禁用名单（`apps/zcode-cli/packages/bootstrap/src/app/app-config-options.ts:20`）。注册时只取括号前的工具名（`apps/zcode-cli/packages/core/src/tool/tool-visibility.ts:5`），帮助文本也写明 `"Bash(git *)"` 会移除整个 Bash，不支持按命令内容匹配（`apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:38`）。它只作用于本次 `-p` 或 TUI 进程，不写进任何配置。

下一篇：[生命周期 Hooks 与工作区信任](https://daiw.net/manual/zcode/hooks)——七个事件何时触发、进程钩子的输入输出协议，以及项目目录里的钩子为什么要逐条信任。
