你在 ~/.codex/hooks.json 里写了几个钩子:PreToolUse 拦住 rm -rf,拦住读 .env,PostToolUse 跑一遍格式化,Stop 要求测试通过才准收工。头两天日志里能看到钩子输出,你觉得这事就算配好了。

三个月后复盘,你回头翻记录,发现那几行输出最后一次出现是一个半月前。

hooks 麻烦的地方不在能不能配,而在它坏掉的样子。它坏的时候不会报错:配置在、信任哈希在、别的插件的钩子还在打印日志,所有表面信号都是对的。下面这些是我在 openai/codex 的 issue 里翻到的案例,都能复现,共同点是安静。

先记住 hooks 的几个设定#

官方文档里值得先划出来的几条:

位置说明
~/.codex/hooks.json、~/.codex/config.toml用户级
<repo>/.codex/hooks.json、<repo>/.codex/config.toml项目级,只在该项目的 .codex/ 层被信任时加载
插件自带的 hooks/hooks.json随插件启用而加载,走同样的信任流程
requirements.toml托管钩子,按策略视为已信任,用户没法在钩子浏览器里禁用

事件分三组:回合内的有 PreToolUse、PermissionRequest、PostToolUse、PreCompact、PostCompact、UserPromptSubmit、Stop、SubagentStop;会话或子 agent 启动时有 SessionStart、SubagentStart;主线程结束时是 SessionEnd。

几条运行时规则:

  • 多个文件里匹配上的钩子都会跑。高优先级的层不替换低优先级的层,只做叠加。同一层里同时写了 hooks.json 和内联 [hooks],Codex 会合并并在启动时给一条警告,所以一层里挑一种写法。
  • 非托管的命令钩子要先审阅并信任才会跑。信任记在钩子定义的哈希上,新增或改动过的钩子会被标成待审阅然后跳过。用 /hooks 看来源、审阅、信任或禁用。
  • 同一个事件上多个匹配的命令钩子并发启动,一个钩子拦不住另一个。

还有一条最重要的:钩子崩了、超时了、返回了不合法的 JSON,操作照常进行,钩子只被标成 Failed。这个设计叫 fail-open,好处是钩子不会把 agent 卡死,代价是护栏失灵的时候没有任何东西会喊出来。

官方文档自己也是这个调子:工具钩子是有用的护栏,不是完整的执行边界,有些特殊工具路径不走默认的钩子。

改完配置没重启,这次会话的钩子全灭#

openai/codex#21160,2026 年 5 月 5 日开的,报的是 codex-cli 0.128.0,Ubuntu 24.04 on WSL2。两个场景:

  • 会话撞到限流停下时,本该跑的那条 stop 钩子不跑。
  • 会话还活着的时候改 hooks.json,只要文件本身是合法 JSON(jq 验证通过),这个会话后面所有钩子都不再触发,重启会话才恢复。

报告者的诉求很朴素:要么在限流停下时也跑钩子,要么明说这种情况会跳过;改配置可以继续用旧的那份,也可以热加载,或者至少提示一句需要重启。不能接受的是整个会话静默失去钩子。

靠钩子做清理、审计、续跑的长跑工作流,全都受这条影响。

项目级配置,在交互式和 CI 里各有各的不生效#

三份报告,方向一致。

openai/codex#17532,2026 年 4 月 12 日,0.120.0。仓库里的 .codex/config.toml 写了 hooks = "/绝对路径/hooks.json" 和 [features] codex_hooks = true,交互式会话起来之后 SessionStart 和 Stop 都不触发,rollout 里看不到注入的 additionalContext。同一批脚本手工执行完全正常,所以问题不在脚本。顺带一个坑:hooks 必须放顶层,塞进 [features] 会直接报 TOML 类型错误。

openai/codex#48715,2026 年 9 月 27 日,0.157.1。默认的 codex exec 下,项目级 .codex/hooks.json 不执行,而且没有任何提示。带一个 SessionStart 钩子写哨兵文件,不加 --dangerously-bypass-hook-trust 时文件不出现,加上就出现。关键在最后一句:从用户这边看,钩子没加载和钩子被信任门拦下是两件完全一样的事。这条 10 月 12 日关了。

同类的还有 openai/codex#49333:codex exec --ignore-user-config 会把项目 .codex 层一起跳过,钩子和 MCP server 都没了,和帮助文本写的不一致。

写 CI 的时候,钩子生效这件事本身要拿断言去测。加一行 sentinel 写入,跑完检查文件在不在,别把"我配过"当前提。

一个插件多放一个 manifest,全部钩子安静死掉#

openai/codex#39895,2026 年 8 月 21 日,Codex 0.149.0。

插件目录里同时有 .codex-plugin/plugin.json(里面声明了 hooks 字段)和根目录的 plugin.json(Agent Plugins 1.0.0 manifest)。Codex 走的是 Agent Plugins 加载器,而那个加载器不支持钩子,.codex-plugin/plugin.json 里的 hooks 字段从来没被读过。结果是这个插件声明的每个钩子都停跑,没有警告、没有报错、没有日志行。

报告者列了为什么难发现:

  • TUI 还在打印 hook: SessionStart、hook: SessionStart Completed,不过是别的插件的,条数看起来合理。
  • ~/.codex/config.toml 里这个插件的 [hooks.state] 条目还在,trusted_hash 有效,看起来注册正常。
  • codex doctor 没有 hooks 这一节。
  • 什么都没记。

他们的对照实验做得挺干净:给 session-start.mjs 的正文加 tracer(改命令字符串会让信任哈希失效并弹提示,所以只能改正文),跑两次 codex exec。根 manifest 在的时候,SessionStart 触发 2 次,都来自无关插件,tracer 从没跑过;把根 manifest 挪开,触发 3 次,tracer 跑了。换第二个插件复现一致,第三个不带根 manifest 的插件全程正常,这就把 manifest 摘出来了。

代价是:这两个插件死了一个星期才被发现,定位靠对着本地安装缓存做二分。他们只要一句加载时的警告,比如某插件在 .codex-plugin/plugin.json 里声明了钩子,但带了根 plugin.json,钩子已禁用。

想同时支持 Codex 和 Cursor 的插件作者会直接撞上这个:两个 manifest 各服务一方,今天只能二选一,选一个的代价是另一个静默作废。

Windows 上,exit 2 会被改写成 1#

openai/codex#54072,2026 年 10 月 14 日,codex-cli 0.161.0 和 0.162.1,Windows 11 + PowerShell 5.1。

Codex 在 Windows 上通过 powershell.exe -NoProfile -Command "<command>" 跑每个命令钩子。PowerShell 5.1 会把原生命令的失败退出码统一报成 1,钩子 exit 2 出来变成 exit 1。而解析逻辑只在两种情况拦住:exit 2 且 stderr 有原因,或者 exit 0 且 stdout 是 JSON deny。于是按文档写的 exit-2 拒绝变成 hook exited with code 1,状态 Failed,工具照跑。stderr 里的原因还在,丢的只有退出码。

报告里的对照:

钩子codex exec 显示工具
stderr + exit 2hook: PreToolUse Failed没拦住,继续执行
JSON deny + exit 0hook: PreToolUse Blocked被拒

他们拿真的 git reset --hard 试过,沙箱绕开的情况下 reset 执行了。跨平台的团队要留神:同一个策略写两种返回形式,并且在目标平台上亲眼验证一次它拦得住。

字段名写错不算错#

openai/codex#50303,2026 年 10 月 2 日,0.159.3。

hooks.json 里 handler 写了不认识的字段会被静默接受。报告者把超时写成 timeout_sec,实际字段是 timeout,值被丢掉,没有警告,所有 PreCompact、PostCompact 钩子按 600 秒默认跑了好几个月。

这条他们追到了源码:HookHandlerConfig::Command 用 #[serde(default, rename = "timeout")] timeout_sec 反序列化,没有 deny_unknown_fields,后面接 unwrap_or(600)。任何拼错或遗留的键都被丢掉,不留诊断。这还顺手解释了一个反向结论:另一条 issue 说 timeout_sec 能用而 timeout 不行,原因是未知键被忽略,写错的键看起来能用,因为钩子照样会跑。

看得到调用,看不到结果#

openai/codex#34289 是这批里面最长的一条,2026 年 7 月 20 日以 0.144.5、0.144.6 起报,一路被补到 0.157.1,11 条评论,四个人独立复现。

  • PostToolUse 对成功和失败的工具调用都会触发,但载荷里没有失败信号:没有 is_error,没有 exit_code,没有 error 字段。事件词表里存在的 PostToolUseFailure 从来没触发过。
  • 关键对照是安静失败那一种。cat /nonexistent-file(exit 1)的 tool_response 是报错文本;sh -c 'exit 3'(exit 3)的 tool_response 是空字符串,和一个安静的成功字节相同;echo ok 是 "ok\n"。
  • 后续补充里,沙箱拦下 apply_patch 或者补丁格式错误时,PreToolUse 之后一个事件都没有,失败的调用看起来像从没结束。多文件补丁还会出现只写成功一半的情况:一个文件写进去了,另一个只读文件写失败,钩子没有任何办法知道第一个文件已经变了。
  • 有人把一条文档里没写的性质当契约记了下来:同一次调用的 PreToolUse 和 PostToolUse 事件里 tool_use_id 相同,不同调用之间不同。审计要靠它把授权和结果配对,现在只能靠实测。

做硬件状态灯那位说得最直接:没有可靠失败信号,集成分不清安静失败和成功但没输出。这条不修,任何按失败触发的钩子都建不起来,只能退回去猜输出文本,而文本猜测会在正常的测试日志和 grep 输出上误触发。

插入一条钩子,后面所有钩子的信任被重置#

openai/codex#49399,2026 年 9 月 29 日,0.159.1,10 月 11 日关闭。

信任状态存在 hooks.state."<source>:<event>:<group_index>:<handler_index>" 里,键是位置。往前面插一个新 group,后面每个 handler 都换了键,旧键上的哈希属于另一个钩子,状态变成 Modified,新键上没有记录,定义一个字没改的老钩子全部停跑,直到重新审阅。在 codex exec 里这个过程同样是静默的。手工改配置,或者好几个工具往同一个 hooks.json 里写条目,都容易撞上。改钩子配置的时候往后追加,别往前面插。

两条边界#

PreToolUse 对 spawn_agent 不触发(openai/codex#49736,0.159.2)。同一个 hooks.json 里的 UserPromptSubmit 和 PostToolUse 都正常,只有针对 spawn_agent 的 PreToolUse 不跑,所以一条 permissionDecision: "deny" 拦不住子 agent 创建。同类还有 Code Mode 的 exec(#23411)和 apply_patch(#18391)。

钩子载荷里没有会话标识,app-server 程序化创建的 thread 也不发 SessionStart(openai/codex#54353,2026 年 10 月 15 日)。按会话做记忆注入或者遥测的集成拿不到键。

这里还要放一条反面例子。openai/codex#18607 说 codex exec 里只有 UserPromptSubmit 触发,其他钩子全都不跑,官方关掉它的理由是报告建立在错误假设上:钩子实现在核心 agent loop 里,在 exec、TUI 和其它客户端层之下,几种模式行为一致;而测试用的提示词是 Say hi,本来就不会产生工具调用,PreToolUse 和 PostToolUse 当然不触发。排查钩子没跑之前,先确认自己那条路径真的触发到了对应事件,能省掉一半功夫。

怎么用才不至于被骗#

  1. 每个钩子都留一个可见副作用。往一个 JSONL 追加一行,用真实场景触发,然后去看那行在不在。配置里写了不等于跑过。
  2. 改配置就重启会话。这是 #21160 目前唯一的可靠解法,把它写进流程里。
  3. 非交互路径单独验一遍。codex exec、CI、app-server 是三条不同的路,每条都要有一次真实的触发记录。
  4. 拒绝策略写两种返回形式,并且在目标平台验证。exit 2 和 JSON deny 都写上,Windows 上先跑一遍证明真的拦住。
  5. 定期看信任状态。用 /hooks,留意 Modified 和 Untrusted,改配置尽量往后追加。
  6. 一个插件只留一个 manifest。
  7. 分清护栏和安全边界。官方文档说工具钩子是护栏,不是完整的执行边界。真正兜底的是沙箱、审批、命令规则和服务端权限。
  8. 把钩子本身也监控起来。一个定时任务检查心跳文件的 mtime,比事后翻日志便宜得多。

最后#

钩子坏掉之所以危险,在于它和没配长得一模一样,而人会相信自己配过。上面这些 issue 里没有一条是钩子报错了,全都是钩子没动静。所以别拿配置文件当证据:找一个只有钩子跑过才会留下的痕迹,定期看它还在不在。

参考来源: