# 生命周期 Hooks 与工作区信任

> 七个钩子事件的触发时机、matcher 与能力，配置格式与默认值，进程钩子的 stdin 与 stdout 协议、退出码 2 与失败语义，Stop 续跑上限，钩子输出怎样进模型上下文，与 Claude Code 的兼容程度，以及项目目录里的钩子为什么要按声明摘要逐条信任。

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

钩子让外部命令在 Agent 生命周期的固定时机插一手：补充上下文、改写工具输入、参与权限决策、拦下提示词，或者让即将结束的回合再跑一步。执行核心在 `apps/zcode-cli/packages/core/src/hooks`：`runner.ts` 按顺序执行并合并结果，`configured-runner*.ts` 把配置翻译成子进程调用，`output.ts` 解释钩子的 JSON 输出；事件与输出协议的类型定义在 `apps/zcode-cli/packages/contracts/src/hooks/index.ts`。工具调用上的四个事件由 `apps/zcode-cli/packages/core/src/tool/executor/hook-flow.ts` 发起，会话级的三个由 `apps/zcode-cli/packages/core/src/runtime/methods/hooks.ts` 发起。

钩子有四种来源：用户配置、插件、工作区（项目目录里的配置文件）和内部实现。前两种装配后就能执行；工作区钩子来自你刚克隆下来的仓库，必须按声明摘要逐条信任，相关代码分布在 `packages/shared/src/workspace-hook-*.ts`、`apps/zcode-cli/packages/core/src/hooks/workspace-hook-*.ts`、adapters 的信任存储和 `zcode hooks trust` 子命令里。钩子怎样叠加到权限判定上，上一篇[权限模式与规则](https://daiw.net/manual/zcode/permission)已经讲过。

## 配置格式与默认值

用户钩子写在主配置文件的 `hooks` 段，通常是 `~/.zcode/cli/config.json`（`apps/zcode-cli/README.md:184`）。README 的示例（`apps/zcode-cli/README.md:199`，节选）：

```json
{
  "hooks": {
    "enabled": true,
    "timeoutMs": 60000,
    "maxOutputBytes": 32768,
    "events": {
      // ...
      "PreToolUse": [
        {
          "matcher": "^(Bash|Write|Edit)$",
          "hooks": [
            {
              "type": "process",
              "command": "node",
              "args": ["./scripts/pre-tool-hook.mjs"],
              "timeoutMs": 5000
            }
          ]
        }
      ],
      // ...
    }
  }
}
```

| 键 | 默认值 | 说明 |
| --- | --- | --- |
| `hooks.enabled` | `false`（`apps/zcode-cli/packages/contracts/src/config/index.ts:350`） | 总开关。多层配置里任一层为 true 即开启；写了 `false` 的那一层，自己的事件不并入（`apps/zcode-cli/packages/adapters/src/config/config-merger.ts:178`） |
| `hooks.timeoutMs` | `60000`（`apps/zcode-cli/packages/contracts/src/hooks/index.ts:429`） | 每个钩子的默认超时 |
| `hooks.maxOutputBytes` | `32768`（`apps/zcode-cli/packages/contracts/src/hooks/index.ts:428`） | stdout、stderr 的捕获上限 |
| `hooks.events.<事件>` | 无 | matcher 分组数组，按配置顺序执行 |
| `matcher` | 匹配全部 | 规则见下 |
| `type` | 必填 | `process` 或 `command` |
| `timeoutMs`、`timeout` | 继承根 | 优先 `timeoutMs`；`command` 型还认以秒计的 `timeout` |
| `enabled` | `true` | 单条钩子开关，写 `false` 不注册（`apps/zcode-cli/packages/core/src/hooks/configured-runner.ts:54`） |

两种类型的区别在 `apps/zcode-cli/packages/core/src/hooks/configured-runner-callback.ts:26`：`process` 把 `command` 当可执行文件、`args` 当参数数组，不经 shell；`command` 把整串交给 shell 执行，`shell` 可以写 `true` 或指定一个 shell，`async: true` 让它在后台跑，输出不参与任何决策（`apps/zcode-cli/packages/core/src/hooks/runner.ts:125`）。README 说目前只支持 `process`（`apps/zcode-cli/README.md:255`），实际两种都支持（`apps/zcode-cli/packages/contracts/src/hooks/index.ts:386`）。超时的取值顺序见 `packages/shared/src/workspace-hook-config.ts:113`。

`matcher` 的规则（`apps/zcode-cli/packages/core/src/hooks/output.ts:98`）：省略或写 `*` 匹配全部；只由字母、数字、下划线和竖线组成时，按竖线拆成名单逐个精确比较；否则当 JavaScript 正则去 `test`，正则写错则匹配不上。工具事件的匹配值是工具名，另加别名：`Agent` 与 `Task` 互为别名，`ApplyPatch` 同时能被 `Write`、`Edit` 匹配（`apps/zcode-cli/packages/core/src/tool/compat.ts:6`）。

同一事件的钩子按注册顺序逐个执行，前一个拦截了，后一个照样跑，结果合并：权限意见取 deny 优先于 ask 优先于 allow（`output.ts:145`），改写的输入以最后一个为准（`output.ts:81`），补充上下文全部累加。来源之间的次序是用户配置在前，工作区钩子插在第一个非用户来源之前，插件与内部钩子在后（`configured-runner.ts:174`）。

## 七个事件

```mermaid
flowchart TD
  S["首个回合"] --> SS["SessionStart"]
  SS --> UP["UserPromptSubmit"]
  UP -->|"拦截"| E1["回合以拦截理由结束"]
  UP --> M["模型请求"]
  M -->|"有工具调用"| V["校验与归一化输入"]
  V --> PRE["PreToolUse"]
  PRE -->|"deny"| ERR["错误结果回给模型"]
  PRE --> PERM["权限判定"]
  PERM -->|"ask"| PR["PermissionRequest 与 broker 竞速"]
  PERM -->|"allow"| H["handler"]
  PERM -->|"deny"| ERR
  PR -->|"允许"| H
  PR -->|"拒绝"| ERR
  H -->|"成功"| POST["PostToolUse"]
  H -->|"抛错"| FAIL["PostToolUseFailure"]
  POST --> M
  FAIL --> M
  ERR --> M
  M -->|"没有工具调用"| ST["Stop"]
  ST -->|"续跑，每回合至多 3 次"| M
  ST --> D["回合结束"]
```

| 事件 | 何时触发 | matcher 看到 | 能做什么 |
| --- | --- | --- | --- |
| `SessionStart` | 首个回合初始化上下文之后（`startup`），或恢复会话时（`resume`）；每个运行时实例只跑一次 | 来源 `startup`、`resume` | 补充上下文 |
| `UserPromptSubmit` | 用户输入写进消息历史之前 | 不参与匹配，所有分组都跑 | 拦下提示词，补充上下文 |
| `PreToolUse` | 工具输入校验、归一化之后，权限判定之前 | 工具名及别名 | deny、ask、allow，改写输入，补充上下文 |
| `PermissionRequest` | 权限判为询问、审批已挂出时，与 broker 竞速 | 工具名及别名 | 允许、拒绝、改写输入后允许、写入权限规则 |
| `PostToolUse` | handler 成功、结果序列化之后 | 工具名及别名 | 补充上下文 |
| `PostToolUseFailure` | handler 执行中抛错之后，含超时与取消 | 工具名及别名 | 补充恢复用的上下文 |
| `Stop` | 一次模型请求没有工具调用、也没有待注入的引导，回合将结束时 | 不参与匹配 | 带上下文续跑 |

对应的调用点：`SessionStart` 在 `apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:228` 与 `apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:254`，只跑一次靠 `hooks.ts:27` 的标记；`UserPromptSubmit` 在 `turn.ts:363`，子 Agent 的回报和后台任务通知这类只给模型看的输入会跳过它（`apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-queue.ts:277`、`runtime-command-queue.ts:364`）；工具上的四个在 `apps/zcode-cli/packages/core/src/tool/executor/call-runner.ts:232`、`apps/zcode-cli/packages/core/src/tool/executor/permission-flow.ts:224`、`call-runner.ts:471`、`call-runner.ts:561`；`Stop` 在 `apps/zcode-cli/packages/core/src/runtime/methods/turn-stop.ts:201`。

几处与直觉不同的地方。`SessionStart` 的类型里还有 `clear`、`compact` 两种来源（`apps/zcode-cli/packages/contracts/src/hooks/index.ts:130`），但代码只以 `startup` 和 `resume` 调用它。README 说 `UserPromptSubmit` 的 matcher 看到原始提示词（`apps/zcode-cli/README.md:189`），实际调用时没有传匹配值，而匹配值为空时任何 matcher 都算命中（`apps/zcode-cli/packages/core/src/hooks/runner-helpers.ts:46`），`Stop` 同理，写了 matcher 也每次都跑。`PostToolUseFailure` 只覆盖 handler 开跑之后的失败；输入校验失败、被 PreToolUse 或权限拒绝的调用在此之前就返回了，不触发它。

## 进程钩子的协议

**输入**。每个钩子从 stdin 读到一行 JSON，由 `createCompatibleHookStdin` 生成（`apps/zcode-cli/packages/core/src/hooks/configured-runner-input.ts:14`）：

```ts
export async function createCompatibleHookStdin(input: HookInput): Promise<{
  cleanup: () => Promise<void>;
  value: string;
}> {
  const compatible: Record<string, unknown> = {
    ...input,
    agent_type: input.agentName,
    hook_event_name: input.hookEventName,
    permission_mode: input.mode,
    session_id: input.sessionId,
  };
  const tempDir = await mkdtemp(join(tmpdir(), "zcode-hook-"));
  const transcriptPath = join(tempDir, "transcript.jsonl");
  await writeFile(transcriptPath, formatTranscript(input), "utf8");
  compatible.transcript_path = transcriptPath;
  compatible.transcriptPath = transcriptPath;

  if ("toolName" in input) {

    // 这里只补无损 alias，继续保留 ZCode camelCase 字段作为内部主契约。
    compatible.tool_name = input.toolName;
    compatible.tool_input = input.toolInput;
    compatible.tool_use_id = input.toolCallId;
  }
```

camelCase 字段是 ZCode 自己的契约：公共部分有 `cwd`、`hookEventName`、`mode`、`sessionId`、`timestamp`、`traceId`、`turnId`、`agentName`（`apps/zcode-cli/packages/contracts/src/hooks/index.ts:67`），各事件再加自己的字段，如 PreToolUse 的 `toolInput`、`riskLevel`，UserPromptSubmit 的 `prompt` 与附件摘要，Stop 的 `responseText`、截到 4000 字符的 `responsePreview`、`toolCallCount` 与 `stopHookActive`（`hooks.ts:16`、`hooks.ts:85`）。snake_case 字段是给 Claude Code 风格脚本的别名，按事件还有 `tool_response`、`error`、`error_details`、`is_interrupt`、`last_assistant_message`、`stop_hook_active`（`configured-runner-input.ts:39`）。`permission_suggestions` 虽有映射，但调用方从不填 `permissionSuggestions`（`hook-flow.ts:60`），所以它总是缺席。

子进程的工作目录是会话工作目录，环境里额外设置 `ZCODE_PROJECT_DIR`、`ZCODE_SESSION_ID` 与对应的 `CLAUDE_PROJECT_DIR`、`CLAUDE_SESSION_ID`、`CLAUDE_CODE_SESSION_ID`，插件钩子另有插件目录与数据目录（`configured-runner-input.ts:74`）；`command` 与 `args` 里的 `${ZCODE_PROJECT_DIR}` 这类写法在启动前展开，`${ZCODE_SKILL_DIR}` 在钩子里没有意义，直接报配置错误（`configured-runner-input.ts:100`）。

**输出**。stdout 可以打印一个 JSON 对象，由 `processHookOutput` 解释（`output.ts:11`，schema 见 `apps/zcode-cli/packages/contracts/src/hooks/index.ts:282`）：

| 字段 | 效果 |
| --- | --- |
| `continue: false` | 在 UserPromptSubmit、PreToolUse、PermissionRequest 上是拦截；其他事件只把本次运行记为 blocked，没有实际作用；Stop 上忽略 |
| `continue: true` | 只对 Stop 有意义：请求续跑 |
| `decision: "block"` | 同拦截；在 Stop 上是续跑，`reason` 与 `systemMessage` 作为续跑的上下文 |
| `decision: "approve"` | PreToolUse、PermissionRequest 上等于 allow |
| `reason`、`stopReason` | 拦截理由 |
| `additionalContext`、`additional_context` | 顶层补充上下文，除 PermissionRequest 外都会用上 |
| `hookSpecificOutput` | `hookEventName` 必须等于当前事件，否则算钩子失败；PreToolUse 可带 `permissionDecision`、`permissionDecisionReason`、`updatedInput`；PermissionRequest 带 `decision`，其中 `behavior` 为 allow 时可附 `updatedInput` 与 `updatedPermissions`（也认 `permissionUpdates`），为 deny 时可附 `message`；其余事件带 `additionalContext` |
| `suppressOutput` | schema 接受，没有任何效果 |

**退出码与失败**（`configured-runner-callback.ts:118`）：

| 进程结果 | 处理 |
| --- | --- |
| 退出码 0，stdout 为空或不以 `{` 开头 | 当作没有输出 |
| 退出码 0，以 `{` 开头但解析不了 | 同上，当作诊断文本（`configured-runner-callback.ts:171`） |
| 合法 JSON 但不符合 schema | 钩子失败 |
| 退出码 2 | 拦截，理由取 stderr，没有再取 stdout，都空就是 `Hook blocked execution`，按事件转成相应输出（`configured-runner-callback.ts:195`） |
| 其他退出码、超时、取消 | 钩子失败 |

“钩子失败”只会发一条 `hook_run_failed` 事件、记一条警告，这个钩子等于没说话，工具调用和回合照常继续（`runner.ts:211`）；反过来，准入闸门自己抛错时一律不执行（`runner-helpers.ts:21`）。README 把“非 JSON 的 stdout”也算作失败（`apps/zcode-cli/README.md:261`），代码里并不是。超时由运行器计时（`runner.ts:340`），同一个值也传给执行端口；stdout 与 stderr 的捕获上限都是 `maxOutputBytes`（`configured-runner-callback.ts:47`）。

## Stop 与续跑上限

Stop 钩子要让回合再跑一步，必须同时满足三个条件（`hooks.ts:120`）：有钩子请求续跑（`continue: true`、`decision: "block"` 或退出码 2），合并后有非空的补充上下文，本回合的续跑次数还没到常量 `MAX_STOP_HOOK_CONTINUATIONS` 规定的 3 次（`hooks.ts:10`）。计数每个回合从 0 开始（`turn.ts:591`）。续跑时补充上下文作为一条历史记录追加，回合机回到下一次模型请求（`turn-stop.ts:208`）；从第二次起，钩子输入里的 `stopHookActive` 为 true（`turn-stop.ts:206`），脚本可以据此避免反复拦截。只写 `continue: true` 而没有上下文的输出会被忽略，README 对此的描述与代码一致。

## 钩子输出怎样进模型上下文

- **工具事件**：PreToolUse、PostToolUse、PostToolUseFailure 的补充上下文以 `[Hook additional context]` 开头、按 `#1`、`#2` 编号，附在工具结果后面（`hook-flow.ts:234`），受该工具的模型字节预算约束（`apps/zcode-cli/packages/core/src/tool/executor/result-serialization.ts:220`）；PreToolUse 拒绝或权限拒绝这类提前返回的错误结果也会附上（`call-runner.ts:688`）。
- **会话事件**：SessionStart、UserPromptSubmit、Stop 的补充上下文变成一条 `hook_context` 类型的 system reminder 附件进入消息历史，开头是事件名加 `hook additional context:`，整体截到 24000 字符（`hooks.ts:15`、`hooks.ts:106`）。提醒怎样投影给模型见[系统提示词、上下文与提醒](https://daiw.net/manual/zcode/context-builder)。
- **诊断信息**：stderr、stdout 预览只进 `hook_run_*` 生命周期事件，并先脱敏（`apps/zcode-cli/packages/core/src/hooks/display-metadata.ts:35`），不会给模型看（`apps/zcode-cli/packages/core/src/hooks/types.ts:23`）。内部钩子的事件对客户端不可见（`display-metadata.ts:20`）。

## 与 Claude Code 的兼容程度

ZCode 明显在向 Claude Code 的钩子看齐：这七个事件名都取自 Claude Code，stdin 补上 `tool_name`、`tool_input`、`hook_event_name`、`transcript_path` 等 snake_case 别名，环境里设置 `CLAUDE_PROJECT_DIR`，`hookSpecificOutput` 与 `permissionDecision` 的写法、“退出码 2 表示拦截”的约定都一样；UserPromptSubmit 与 Stop 不看 matcher，这一点也与 Claude Code 相同。插件沿用 `hooks/hooks.json` 的位置（`apps/zcode-cli/packages/adapters/src/plugins/hook-sources.ts:8`）和“事件名直接挂在 `hooks` 下”的形状（`apps/zcode-cli/packages/adapters/src/plugins/index.ts:497`）。对照 Claude Code 的[公开文档](https://code.claude.com/docs/en/hooks)，差异主要有这些：

- 用户与项目配置多了一层 `enabled` 与 `events`，Claude Code 的设置文件里事件直接挂在 `hooks` 下；
- Claude Code 的事件远不止七个，ZCode 只认这七个，插件里的其他事件会被跳过并记一条 `plugin_hook_unsupported_event` 警告（`apps/zcode-cli/packages/adapters/src/plugins/index.ts:518`）；
- Claude Code 的 `transcript_path` 指向会话记录，这里只是一个装着当前这条提示词或回答的临时文件（`configured-runner-input.ts:142`），钩子结束即删除（`configured-runner-callback.ts:67`）；
- `permission_mode` 取 ZCode 的档位名，如 `build`、`yolo`，不是 Claude Code 的 `default`、`acceptEdits`、`bypassPermissions` 那一套；
- 超时默认 60 秒，比 Claude Code 命令钩子的默认值短得多，耗时的脚本要自己写 `timeout` 或 `timeoutMs`；
- PostToolUse 上的退出码 2，Claude Code 会把 stderr 交给模型，这里只把本次运行记为 blocked，理由不进上下文，SessionStart 与 PostToolUseFailure 也是如此；deny 决定里的 `interrupt` 不起作用（`hook-flow.ts:104`）。

## 工作区钩子为什么要信任

项目配置文件同样可以写 `hooks` 段。发现规则是从最近一个含 `.git` 的祖先目录一路到工作目录，每层看 `zcode.json` 和 `.zcode/config.json`，找不到 `.git` 就只看工作目录，另加显式指定的项目配置（`workspace-hook-config.ts:174`、`workspace-hook-config.ts:335`）。这些声明不会进入可执行的配置：加载时就被剥掉，只留一条 `config_project_hooks_pending_trust` 诊断（`apps/zcode-cli/packages/adapters/src/config/project-config.adapter.ts:63`、`project-config.adapter.ts:122`），另存为一份冻结的快照（`apps/zcode-cli/packages/adapters/src/config/config-factory.ts:220`）。理由很直接：克隆一个仓库，不应该让仓库作者的命令在你的账号下自动运行。NOTICE 的表述是（`NOTICE.md:18`）：

> 工作区 Hook 按工作区身份和声明摘要审查；声明变化会使原信任失效。该机制不等于对插件 Hook、MCP 配置或其他项目脚本的统一审核。

**按什么摘要信任**。每条声明各有一个 SHA-256 摘要，输入是下面这个数组的 JSON（`packages/shared/src/workspace-hook-digest.ts:222`）：

```ts
  const execution =
    input.hook.type === "process"
      ? ["process", input.hook.command, [...(input.hook.args ?? [])]]
      : [
          "command",
          input.hook.command,
          input.hook.async === true,
          input.hook.shell === undefined
            ? ["unset"]
            : input.hook.shell === true
              ? ["true"]
              : ["string", input.hook.shell],
        ];
  return [
    "workspace-hook-declaration",
    WORKSPACE_HOOK_DIGEST_SCHEMA_VERSION,
    input.sourceRelativePath,
    input.sourceDiscoveryOrder,
    input.event,
    input.matcher,
    input.matcherIndex,
    input.hookIndex,
    execution,
    input.resolvedTimeoutMs,
    input.resolvedMaxOutputBytes,
  ];
```

命令、参数、shell、事件、matcher、在文件里的位置、来源文件，以及解析后的超时与输出上限，任何一项变化都会得到新摘要，旧的信任随之失效；`statusMessage` 与 `enabled` 不在其中，改显示文字或开关不需要重新审查。信任记录以“工作区身份加声明摘要”为键（`apps/zcode-cli/packages/core/src/hooks/workspace-hook-trust-evaluation.ts:66`），工作区身份默认就是工作目录路径，宿主可以另给（`config-factory.ts:221`）。另有一个覆盖全部来源文件与声明的 bundle 摘要（`workspace-hook-digest.ts:160`），用于“信任当前全部”这类操作的校验。

**信任状态**。每次评估按固定次序给每条声明定一个状态（`workspace-hook-trust-evaluation.ts:28`）：

```ts
  if (policy.mode === "deny") {
    trustState = "blocked_policy";
  } else if (input.storeStatus === "corrupt") {
    trustState = "blocked_untrusted";
  } else if (policy.mode === "allow_trusted_only") {
    trustState = persistent ? "trusted_persistent" : "blocked_policy";
  } else if (persistent) {
    trustState = "trusted_persistent";
  } else if (input.revokedKeys.has(key)) {
    trustState = "revoked";
  } else if (hasStaleSlotRecord(snapshot, entry, input.persistentRecords.values())) {
    trustState = "stale_digest";
  } else {
    trustState = "pending_trust";
  }
```

```mermaid
stateDiagram-v2
  [*] --> pending_trust: 发现新声明
  pending_trust --> trusted_persistent: 审查通过或命令行授予
  trusted_persistent --> revoked: 本会话内撤销
  revoked --> pending_trust: 新会话重新评估
  trusted_persistent --> stale_digest: 同一位置的声明被改
  stale_digest --> trusted_persistent: 对新摘要授予
  pending_trust --> blocked_policy: 策略禁止
  pending_trust --> blocked_untrusted: 信任文件损坏
```

`stale_digest` 指同一个位置（事件、来源文件、matcher、序号都相同）有旧摘要的信任记录，说明声明被改过（`workspace-hook-trust-evaluation.ts:70`）；`revoked` 只是本会话内存里的标记，记录删掉后，新会话看到的就是待信任。策略有三种：默认的 `user_decides`、全部禁止的 `deny`、只允许已信任的 `allow_trusted_only`（`apps/zcode-cli/packages/core/src/hooks/workspace-hook-policy.ts:4`）；策略只能由宿主注入，项目文件填不了（`apps/zcode-cli/packages/bootstrap/src/app/types.ts:186`），读取策略出错时按 `deny` 处理（`apps/zcode-cli/packages/core/src/hooks/workspace-hook-trust-coordinator.ts:155`）。

最终能不能执行还要过三道配置开关：来源文件的 `hooks.enabled` 不是 false、这条声明的 `enabled` 不是 false、运行时根开关为 true（`workspace-hook-config.ts:154`）。根开关由默认、用户、各项目文件、环境与命令行各层的 `hooks.enabled` 取“任一为真”（`workspace-hook-config.ts:130`），所以项目文件可以自己打开开关，但打开了仍要信任。准入不在装配时一次定死：运行器在每个钩子真正执行前重新询问准入（`runner.ts:87`），期间有人撤销信任、收紧策略或重载信任文件，都会让安全版本号变化，下一次评估立即生效（`apps/zcode-cli/packages/core/src/hooks/workspace-hook-runtime-admission.ts:148`）。

## 信任存在哪、怎样审查

信任记录存在 `~/.zcode/security/workspace-hook-trust-v1.json`；用户配置里设了 `storage.dir` 时换成它下面的 `security` 目录，相对路径按家目录解析，不随工作目录漂移，也不读项目配置（`apps/zcode-cli/packages/adapters/src/storage/workspace-hook-trust-store.ts:130`、`workspace-hook-trust-store.ts:543`）。目录权限 0700、文件 0600（`workspace-hook-trust-store.ts:438`、`workspace-hook-trust-store.ts:496`）；多个进程靠一个 `.lock` 文件互斥，锁里记着进程号、进程启动时间与随机令牌，拿锁最多等 5 秒，超过 30 秒且持有者确已退出的锁才回收（`workspace-hook-trust-store.ts:16`）；写入走临时文件、`fsync` 再改名。文件读不懂时改名为 `.corrupt-<时间戳>` 备查，所有工作区钩子按不可信处理（`workspace-hook-trust-store.ts:442`）。每条记录存摘要、授予时间与授予时的事件、命令、来源等快照（`packages/shared/src/workspace-hook-trust-store-file.ts:37`）。

信任只在支持它的宿主里生效。ZCode Protocol 服务端创建会话时显式打开这个开关（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3382`），桌面与 Web 都用 `app-server --stdio` 拉起它（`packages/services/src/zcode-agent/zcodeAgentProcessManager.ts:369`）；不打开时，工作区钩子一律以 `workspace_hooks_feature_disabled` 拦下，连信任文件都不读（`apps/zcode-cli/packages/bootstrap/src/app/workspace-hook-trust.ts:100`、`workspace-hook-runtime-admission.ts:135`）。独立 CLI 的 TUI 与 `-p` 创建会话时都没有传这个开关，所以从代码看，在独立 CLI 里工作区钩子不会执行；有工作区钩子被拦下时，`-p` 结束前会在 stderr 打印 `Workspace Hooks skipped` 和待信任的摘要，并提示去跑 `zcode hooks trust review`（`apps/zcode-cli/packages/cli/src/prompt-command.ts:624`），但即使信任了，这两个入口也照样拦截。

桌面上是“软门禁”：准入层把待信任的数量随 `workspace_hook_admission_updated` 事件报给界面（`app/workspace-hook-trust.ts:119`），用户点开审查面板后开启一次审查流程，时限 10 分钟（`apps/zcode-cli/packages/contracts/src/hooks/workspace-hook-trust.ts:4`），选中的条目以 `trust_selected` 提交后写入信任文件；关闭某条声明会把 `enabled: false` 写回 `<工作目录>/.zcode/config.json`，写之前先核对磁盘上的声明与审查时一致（`packages/shared/src/workspace-hook-mutation.ts:61`、`workspace-hook-mutation.ts:134`）。界面细节见[桌面应用：Electron 的三层](https://daiw.net/manual/zcode/desktop)。

命令行审查用 `zcode hooks trust`，它在全局参数解析之前就被分流（`apps/zcode-cli/packages/cli/src/run.ts:306`）；`zcode hooks` 下目前只有 `trust` 这一组，不写动作时等于 `status`（`apps/zcode-cli/packages/cli/src/hooks-trust-command.ts:37`、`hooks-trust-command.ts:48`），用法见 `hooks-trust-command.ts:13`：

| 子命令 | 作用 |
| --- | --- |
| `status`、`review` | 两者相同：列出每条声明的信任状态、开关、事件与 matcher、命令、来源和摘要，并给出预信任命令 |
| `grant --hook-digest <sha256>` | 按摘要信任，可重复；摘要不在当前快照里就报 `workspace_hooks_snapshot_mismatch` |
| `grant --all-current --bundle-digest <sha256>` | 信任当前全部已启用的声明，bundle 摘要必须与当前一致，否则报 `workspace_hooks_bundle_changed` |
| `revoke --hook-digest <sha256>` 或 `revoke --all` | 撤销，两者必选其一 |

`--workspace` 接受路径，或以 `local:`、`remote:`、`ssh:`、`container:`、`wsl:` 开头的工作区身份（`hooks-trust-command.ts:127`），`--json` 输出机器可读结果。实现在 `apps/zcode-cli/packages/bootstrap/src/workspace-hook-trust-cli.ts`：信任文件损坏时 `grant` 直接拒绝，注释说不能把恢复的副作用伪装成一次成功授权（`workspace-hook-trust-cli.ts:158`）；它只区分“已信任”和“待信任”两种状态（`workspace-hook-trust-cli.ts:33`），计算根开关时只看默认、用户和项目配置（`workspace-hook-trust-cli.ts:203`）。

## 插件钩子与工作区钩子：两条准入路径

NOTICE 也专门提醒，插件钩子与工作区钩子走不同的准入路径（`NOTICE.md:19`）。四种来源对比：

| 来源 | 从哪来 | 准入条件 | 受 `hooks.enabled` 控制 |
| --- | --- | --- | --- |
| 用户 | `~/.zcode/cli/config.json` | 写了就算 | 是 |
| 工作区 | 项目里的 `zcode.json`、`.zcode/config.json` | 逐条按摘要信任，且宿主支持信任 | 不看合并后的开关，看上面的三道门 |
| 插件 | 已启用插件的 `hooks/hooks.json` 或清单里的 `hooks` | 插件启用即准入，没有逐条审查，三方市场插件与官方同等放行 | 不受控 |
| 内部 | 会话信箱，`ZCODE_MESSAGE_ENABLED` 打开时 | 内置 | 不受控 |

插件钩子是否可执行由 `canRunPluginHooks` 决定，它目前恒为 true，注释承认这等于放弃了“只有官方插件能执行钩子”的信任边界，将来需要逐插件信任时再把判断收回到这里（`apps/zcode-cli/packages/adapters/src/plugins/index.ts:366`）。插件钩子在装配时并进运行时配置，只要有一个启用的插件带了钩子，合并结果的 `enabled` 就被直接置为 true（`apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:264`）。从代码看这有一个副作用：用户配置里写了事件却没写 `enabled` 的钩子（默认视为关闭，但事件已并入）会跟着一起执行；显式写了 `enabled: false` 的文件，其事件在合并时就被排除（`config-merger.ts:181`），不受影响。还有一处不对称：项目配置里的 `hooks` 会被剥掉，同一个文件里的 `plugins.dirs` 却照常参与合并（`config-merger.ts:89`），列在这里的本地插件默认启用（`apps/zcode-cli/packages/adapters/src/plugins/index.ts:243`）。从代码看，仓库可以借一个本地插件，让自带的钩子绕开工作区信任。插件的安装、启用与变量展开见[插件与官方市场](https://daiw.net/manual/zcode/plugins)。内部的信箱钩子挂在 UserPromptSubmit、PostToolUse、Stop 上，每次最多取 20 条未读消息（`apps/zcode-cli/packages/core/src/hooks/session-mailbox.ts:9`、`session-mailbox.ts:44`）。

下一篇：[Provider 规则、模型目录与选项映射](https://daiw.net/manual/zcode/provider-config)——内置 Provider 配置从哪来、账号型 Provider 与 BYOK 怎样并存，以及“推理强度”这类选项怎样翻译成各家的请求参数。
