Codex hooks 的静默失效:你配的护栏可能一次都没跑过

你在 ~/.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 2 | hook: PreToolUse Failed | 没拦住,继续执行 |
| JSON deny + exit 0 | hook: 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 当然不触发。排查钩子没跑之前,先确认自己那条路径真的触发到了对应事件,能省掉一半功夫。
怎么用才不至于被骗#
- 每个钩子都留一个可见副作用。往一个 JSONL 追加一行,用真实场景触发,然后去看那行在不在。配置里写了不等于跑过。
- 改配置就重启会话。这是 #21160 目前唯一的可靠解法,把它写进流程里。
- 非交互路径单独验一遍。
codex exec、CI、app-server是三条不同的路,每条都要有一次真实的触发记录。 - 拒绝策略写两种返回形式,并且在目标平台验证。exit 2 和 JSON deny 都写上,Windows 上先跑一遍证明真的拦住。
- 定期看信任状态。用
/hooks,留意Modified和Untrusted,改配置尽量往后追加。 - 一个插件只留一个 manifest。
- 分清护栏和安全边界。官方文档说工具钩子是护栏,不是完整的执行边界。真正兜底的是沙箱、审批、命令规则和服务端权限。
- 把钩子本身也监控起来。一个定时任务检查心跳文件的 mtime,比事后翻日志便宜得多。
最后#
钩子坏掉之所以危险,在于它和没配长得一模一样,而人会相信自己配过。上面这些 issue 里没有一条是钩子报错了,全都是钩子没动静。所以别拿配置文件当证据:找一个只有钩子跑过才会留下的痕迹,定期看它还在不在。
参考来源:
- Hooks 官方文档(OpenAI Developers)
- openai/codex#21160 Hooks stop firing after rate-limit stops or live hooks.json edits
- openai/codex#39895 A root plugin.json silently disables all of a plugin’s hooks
- openai/codex#34289 PostToolUse payload carries no failure signal, and PostToolUseFailure never fires
- openai/codex#54072 Windows: PreToolUse exit-2 deny never blocks
- openai/codex#50303 hooks.json silently ignores unknown handler fields
- openai/codex#17532 codex_hooks do not fire in interactive sessions via repo-local config
- openai/codex#48715 Project-level .codex/hooks.json silently not executed under default codex exec
- openai/codex#49399 Inserting a hook before existing ones un-trusts unchanged hooks
- openai/codex#49736 PreToolUse hooks are not invoked for spawn_agent
- openai/codex#54353 hooks: no session identifier in hook payloads
- openai/codex#18607 Codex exec does not fire Stop / SessionStop / PostToolUse hooks(已关闭)