# Hooks · 在关键节点插入你的脚本

> Codex 的 hooks 是一个名为 ClaudeHooksEngine 的引擎：12 个事件、按哈希记账的信任机制、会话 shell 里起进程、stdin 进 JSON、退出码与 stdout 出决定。本篇讲它从哪里收集钩子、怎样匹配和并发执行，以及拦截、改写、续写这些决定如何回到 agent 循环。

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

# Hooks · 在关键节点插入你的脚本

Hooks 让用户在 agent 生命周期的固定节点跑自己的命令，把“每次都必须做”的事从“指望模型记得”变成确定执行。Codex 的实现在 `codex-hooks` crate，核心类型直接叫 `ClaudeHooksEngine`——事件名、JSON 字段、退出码约定都沿用 Claude Code 的格式。

## 怎么用

钩子写在 `hooks.json` 或 `config.toml` 的 `[hooks]` 表里，三层结构：事件 → 匹配组（`matcher`）→ 处理器（`type = "command"` 或 `"mcp_tool"`）。命令从 stdin 读到事件 JSON，用退出码与 stdout 表态；新增或改动的钩子要先在 `/hooks` 里审阅信任才会运行。事件清单、字段与示例见手册 [Hooks](https://daiw.net/manual/codex/hooks)。

## 12 个事件挂在哪里

`HOOK_EVENT_NAMES` 列出 12 个事件（`codex-rs/hooks/src/lib.rs:23`），引擎本身不知道 agent 循环长什么样，由 `codex-core` 的 `hook_runtime.rs` 在各处调用：

| 事件 | 触发位置 |
| --- | --- |
| `SessionStart`、`SubagentStart` | 启动、恢复、分叉、清空、压缩时只记一笔待办，下一轮开始时由 `run_pending_session_start_hooks` 执行，注入的上下文直接进这一轮（`codex-rs/core/src/hook_runtime.rs:128`） |
| `UserPromptSubmit` | `inspect_pending_input` 逐条检查排队的用户输入 |
| `PreToolUse`、`PostToolUse` | `ToolRegistry` 分派工具的前后（`codex-rs/core/src/tools/registry.rs:603`） |
| `PermissionRequest` | 审批流程里，排在自动审查与用户之前 |
| `PreCompact`、`PostCompact` | 本地压缩、远程压缩 v2 与 token budget 模式的压缩，三处实现各自调用 |
| `Stop`、`SubagentStop` | `run_turn` 发现模型不再需要继续时 |
| `Interrupt` | 任务被中断时（`codex-rs/core/src/tasks/mod.rs:815`） |
| `SessionEnd` | 会话关闭时，先把对话记录刷盘，此时 MCP 连接已经关闭（`codex-rs/core/src/session/handlers.rs:321`） |

`SessionEnd` 也是唯一不接受 `mcp_tool` 处理器、也不能异步运行的事件；它和 `Interrupt` 的超时默认 1 秒、最多 3 秒，其余事件默认 600 秒（`codex-rs/hooks/src/engine/discovery.rs:742`）。以 `PreToolUse` 为例，一次调用的路径是：

```mermaid
sequenceDiagram
  participant R as ToolRegistry
  participant H as hook_runtime
  participant E as ClaudeHooksEngine
  participant P as 钩子进程
  R->>H: run_pre_tool_use_hooks
  H->>E: run_pre_tool_use
  E->>E: 按 matcher 选出处理器
  E->>P: 会话 shell 启动进程<br/>stdin 写入事件 JSON
  P-->>E: 退出码 stdout stderr
  E->>E: 解析输出并汇总多个结果
  E-->>H: PreToolUseOutcome
  alt 被拦截
    H-->>R: Blocked<br/>理由作为工具结果交给模型
  else 放行或改写参数
    H-->>R: Continue<br/>可能带 updated_input
  end
```

## 发现：从哪里收集钩子

`discover_handlers`（`codex-rs/hooks/src/engine/discovery.rs:94`）按固定顺序收集：先是 requirements 里托管的钩子，它们标记为 `Required`，加载失败会让会话直接启动失败；再按优先级从低到高遍历生效的配置层，每层读所在目录的 `hooks.json` 和该层 `config.toml` 的 `[hooks]`，两者都有就一起加载并警告；最后是插件带来的钩子，命令里的 `${PLUGIN_ROOT}`、`${PLUGIN_DATA}` 会被替换，同时注入同名环境变量，另有 `CLAUDE_PLUGIN_ROOT`、`CLAUDE_PLUGIN_DATA` 兼容现成插件。

遍历用的是 `layers_low_to_high()`，它会跳过带 `disabled_reason` 的层，所以未受信任项目的 `.codex/hooks.json` 根本不会被读到（见[配置系统](https://daiw.net/manual/codex-source/config-system)）。`type = "prompt"` 与 `"agent"` 能解析，但一律跳过并告警。requirements 设了 `allow_managed_hooks_only` 时，非托管来源整层跳过。

## 信任：按内容哈希记账

每个处理器都有一个哈希：把事件名、matcher 和规范化后的处理器配置序列化成 TOML，再用配置层同款的 SHA-256 指纹计算，所以同一个钩子写在 `hooks.json` 还是 `config.toml` 里，信任身份相同。信任状态按下面的规则判定：

```rust
fn hook_trust_status(
    is_managed: bool,
    is_builtin: bool,
    current_hash: &str,
    trusted_hash: Option<&str>,
) -> HookTrustStatus {
    if is_builtin {
        HookTrustStatus::Trusted
    } else if is_managed {
        HookTrustStatus::Managed
    } else {
        match trusted_hash {
            Some(trusted_hash) if trusted_hash == current_hash => HookTrustStatus::Trusted,
            Some(_) => HookTrustStatus::Modified,
            None => HookTrustStatus::Untrusted,
        }
    }
}
```

（`codex-rs/hooks/src/engine/discovery.rs:794`）

只有启用、并且状态为 `Trusted` 或 `Managed` 的处理器才进入执行列表，`--dangerously-bypass-hook-trust` 会跳过这一步。你在 `/hooks` 里点“信任”，就是把当前哈希写进 `hooks.state` 里对应条目的 `trusted_hash`；钩子内容一变，哈希对不上，状态变成 `Modified`，重新等待审阅。`hook_states_from_stack` 只从用户层和会话层读这张表（`codex-rs/hooks/src/config_rules.rs:15`），仓库里的配置没法替自己的钩子签字。托管来源包括 requirements、MDM、云端配置和 system 层——`/etc/codex/config.toml` 里的钩子也不需要逐条信任。

## 匹配与并发

`matcher` 有两种解释：只由字母、数字、`_`、`|` 组成时，按 `|` 拆开做精确比较，`Bash` 不会误中别的名字；含其他字符才当正则。`*`、空串或省略表示全部匹配，`UserPromptSubmit`、`Stop`、`Interrupt` 忽略 matcher（`codex-rs/hooks/src/events/common.rs:137`）。工具名由 `HookToolName` 提供：stdin 里写的是 Codex 的真名，匹配时另加 Claude Code 风格的别名，`apply_patch` 还能被 `Write`、`Edit` 选中，`spawn_agent` 能被 `Agent` 选中，shell 类工具统一叫 `Bash`（`codex-rs/core/src/tools/hook_names.rs:34`）。

选中的同步处理器用 `FuturesUnordered` 并发启动，彼此看不到对方的结果，全部完成后按配置顺序整理（`codex-rs/hooks/src/engine/dispatcher.rs:115`）。`async: true` 的处理器交给后台任务集，每个会话最多同时跑 8 个（`codex-rs/hooks/src/engine/command_runner.rs:49`），结果在每次采样及其工具调用结束后、以及下一轮用户输入之前取回，只能补充上下文，不能拦截或改写。

## 进程协议

命令用会话所用的 shell 执行（bash、zsh 用 `-c`，PowerShell 加 `-NoProfile -Command`），拿不到时退回 `$SHELL -lc` 或 `%COMSPEC% /C`。工作目录是会话 cwd；Unix 上进程开在新会话里、没有控制终端；环境变量是会话启动时的快照加上钩子自己的变量，再剔除 `NON_INHERITABLE_ENV_VARS` 里的几个 Codex 内部凭据变量。写 stdin 和读输出同时进行，并且都算在超时里：

```rust
    let timeout_duration = Duration::from_secs(handler.timeout_sec);
    // Drain output while sending input so neither pipe can block the other, and
    // include stdin writes in the deadline even when the hook never reads them.
    match timeout(timeout_duration, try_join(write_stdin, wait_with_output)).await {
```

（`codex-rs/hooks/src/engine/command_runner.rs:280`）

超时后，守卫对象在析构时杀掉整个进程组（Windows 上用 Job Object 或 `taskkill /T`）。输入 JSON 的结构用 Rust 类型定义，例如 `PreToolUse`：

```rust
pub(crate) struct PreToolUseCommandInput {
    pub session_id: String,
    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
    pub turn_id: String,
    // ...
    pub tool_name: String,
    pub tool_input: Value,
    pub tool_use_id: String,
}
```

（`codex-rs/hooks/src/schema.rs:278`）

省略的字段是子代理才有的 `agent_id`、`agent_type`，以及 `transcript_path`、`cwd`、`hook_event_name`、`model`、`permission_mode`；最后一个由审批策略换算，`never` 报 `bypassPermissions`，其余都报 `default`。每个事件的输入与输出都有对应的 JSON Schema，生成在 `codex-rs/hooks/schema/generated/`，共 23 份。输出按三种情况解读：退出码 0 时，stdout 为空表示没意见，是 JSON 就按该事件的结构解析；退出码 2 时，对能拦截的事件而言 stderr 就是拦截或续写的理由，为空则记为失败；其他退出码、超时、启动失败都只记为失败，原操作照常进行。`additionalContext` 超过约 2500 token 时，全文写进 `<临时目录>/hook_outputs/<thread_id>/`，模型只看到头尾预览和文件路径（`codex-rs/hooks/src/output_spill.rs:12`）。

## 决定怎样回到循环

**PreToolUse** 任一处理器拦截即拦截，理由取配置顺序里的第一个；被拦下时 `ToolRegistry` 返回 `FunctionCallError::RespondToModel`，形如 `Command blocked by PreToolUse hook: ...` 的文字作为工具结果交还模型。多个处理器都改写参数时，取最后完成的那个，再交给各工具的 `with_updated_hook_input` 重新构造调用，转换失败就按失败处理：

```rust
/// Chooses the rewrite from the hook that actually finished last.
///
/// Hook results stay in configured order for stable reporting, but the
/// `PreToolUse` contract resolves competing rewrites by completion order.
fn latest_updated_input(
```

（`codex-rs/hooks/src/events/pre_tool_use.rs:149`）

**PermissionRequest** 在审批流程的最前面：

```rust
        // Approval precedence is:
        // 1. Hooks
        // 2. If StrictAutoReview || Guardian enabled, then Guardian. Else, user.
```

（`codex-rs/core/src/tools/approvals.rs:505`）

多个处理器里任一 `deny` 立即生效，否则有 `allow` 就放行，都不表态才转给自动审查或用户。

**Stop** 里任一处理器返回 `continue: false` 就结束本轮，优先于续写；否则只要有处理器拦截，所有拦截理由拼成一条提示消息记入历史，`run_turn` 带着 `stop_hook_active = true` 再转一圈（`codex-rs/core/src/session/turn.rs:659`）。循环里没有续写次数上限，防死循环要靠钩子自己检查 `stop_hook_active`。**PostToolUse** 拦截时，反馈文字替换模型看到的工具结果；**UserPromptSubmit** 能拦下这条输入。

旧配置项 `notify` 也跑在这个 crate 里：它被包装成一个 `AfterAgent` 钩子，每轮结束把 JSON 作为最后一个命令行参数传给外部程序，不等待结果（`codex-rs/hooks/src/legacy_notify.rs:45`）。

## 和《从 LLM 到 Coding Agent》对照

那本书的 [Hooks](https://daiw.net/manual/llm-to-agent/hooks) 把 PreToolUse 看作“用户可编程的权限闸门”，Codex 的代码把这句话写实了：`PermissionRequest` 钩子就排在审批优先级的第一位。书里提醒 Stop 钩子要配最大重试次数；Codex 没有设硬上限，而是把 `stop_hook_active` 传给钩子，由钩子自己刹车。对比 [Grok Build](https://daiw.net/manual/grok-build/hooks)：它的 Stop 续写每轮最多 8 次、第一个 Deny 就短路，还支持 HTTP 回调；Codex 让所有同步处理器并发跑完再汇总，并用内容哈希把“谁写的钩子能运行”交给用户逐条确认。

---

上一篇：[Skills · 发现、选择与注入](https://daiw.net/manual/codex-source/skills) · 下一篇：[插件与 marketplace · 打包分发扩展](https://daiw.net/manual/codex-source/plugins)
