Hooks 事件驱动
在 Codex 的关键时刻自动出手——无人值守的约束与自动化
10 种生命周期事件 × command 处理器 = 确定性自动化总在事后才发现问题
那些"记得做"的事情,总被忘记
密钥又泄露了
Codex 生成的代码里不小心硬编码了 API Key。PR 发出去后才被发现——已经推送到了公开仓库。
格式又被破坏了
每次 Codex 改完文件都要手动跑 prettier。一忙起来就忘了,CI 直接红灯。
审查日志空空如也
出了问题想回溯:Codex 在那个会话里到底改了哪些文件?没有记录,没有审计。
约束全靠嘴说
你在 prompt 里写了"不要修改 .env",Codex 有时候还是改了。口头约定不如钩子可靠。
开始之前
适合谁
- 已经熟悉 Codex CLI 基本交互的开发者
- 希望自动化代码审查与格式化的团队
- 需要审计 AI 操作的安全敏感项目
- 想在 AI 工作流中注入自定义约束的工程师
读完后你应该已经能
- 理解 10 种生命周期事件的触发时机
- 配置 command 处理器并掌握 stdin/stdout 协议
- 用 matcher 正则精确控制 hook 作用范围
- 用 Trust 模型保障 hook 安全
这些内容暂时先不碰
- Plugin 提供的 hook 源(plugin hooks)
- Hook 引擎的 SDK 级扩展
- 跨平台 command 适配的深层细节
- 多租户 hook 调度策略
Hooks 是什么
在 Codex 生命周期关键节点注入自定义逻辑
Hooks 让你可以在 Codex 执行过程中的特定事件触发时,运行自己的脚本或 AI 代理。它就像是一个事件监听器系统——你告诉 Codex:"当 X 发生时,先执行 Y,再决定是否继续。"
事件驱动
Hooks 不是轮询,而是被动响应。当特定生命周期事件发生时(比如工具被调用、用户提交 prompt),系统自动触发你配置的处理器。
可拦截
PreToolUse 和 PermissionRequest 这类前置 hook 可以中止后续操作。你的脚本返回退出码 2,Codex 就会停止当前动作。
可注入
SessionStart 的 hook 输出可以作为额外上下文注入到模型的对话中,让每次会话开始都带上自定义信息。
10 种生命周期事件
从会话开始到结束,全覆盖
Codex 定义了 10 种生命周期事件,覆盖了一个完整会话的所有关键时刻。每个事件对应 config.toml 中 [hooks] 下的一个字段。
| 事件 | 触发时机 | 可拦截 |
|---|---|---|
SessionStart | 会话开始,模型加载前 | 输出注入为上下文 |
UserPromptSubmit | 用户提交 prompt 后 | 可拦截 |
PermissionRequest | 请求权限时 | 可拦截(允许/拒绝) |
PreToolUse | 工具调用前 | 可拦截 |
PostToolUse | 工具调用后 | 只记录/后处理 |
SubagentStart | 子代理启动时 | 可注入上下文 |
SubagentStop | 子代理结束时 | 可拦截(拒绝结果) |
PreCompact | 对话压缩前 | 可干预压缩 |
PostCompact | 对话压缩后 | 只记录 |
Stop | 会话结束(退出) | 可清理/记录 |
事件的三种角色
不是所有事件都一样——理解它们的差异
10 种事件虽然触发时机不同,但从"能做什么"的角度可以分为三大类:
🔴 拦截型(Gatekeeper)
PreToolUse、PermissionRequest、UserPromptSubmit
这些事件的 hook 可以中止后续操作。脚本返回非零退出码,Codex 就会停止当前动作。适合做安全检查、敏感信息过滤。
🟡 注入型(Context Injector)
SessionStart、SubagentStart
这些事件的 hook 标准输出会被作为额外上下文注入到模型对话中。适合自动收集 git 状态、环境信息等。
🟢 观察型(Observer)
PostToolUse、PostCompact、Stop、SubagentStop
主要用于记录与后处理。退出码不会拦截已执行的工具调用,但 PostToolUse / Stop 可通过 stdout JSON 返回 continue: false 来终止整个会话。适合审计日志、自动格式化。
command 处理器
Codex Hooks 唯一的处理器类型——运行可执行脚本
config.toml 中声明 [features] 段并设置 hooks = true(旧版键名 codex_hooks 仍作弃用别名可用)。
输入:stdin JSON
事件上下文通过 stdin 以 JSON 传入脚本。共享字段包括 session_id、cwd、hook_event_name、model;turn 级事件还带 turn_id。
输出:退出码决定结果
0— 成功放行2— 拦截,stderr 作为拒绝理由- 其他非零 — hook 故障,记录后跳过(fail-open,不拦截)
输出:stdout JSON(可选)
部分事件支持 stdout 返回结构化 JSON:SessionStart / UserPromptSubmit 可注入额外上下文;PreToolUse 可返回 permissionDecision: "deny"。
处理器可用字段
command:要执行的命令(必填)timeout:超时秒数,默认 600(可选)statusMessage:执行时显示的状态文本(可选)
exit 1 试图拦截是无效的——Codex 只把它当作脚本出错。拦截必须用 exit 2 或返回 deny JSON。
TOML 配置基础
在 config.toml 中声明你的 hook 规则
Hook 配置可放在 ~/.codex/config.toml、~/.codex/hooks.json,或项目级 .codex/config.toml、.codex/hooks.json。多个来源的匹配 hook 会全部加载(叠加,非覆盖)。每个事件字段是一个数组,数组元素称为 MatcherGroup。
# 一个完整的 MatcherGroup 结构
[[hooks.PreToolUse]]
matcher = "^(Write|Edit)$" # 可选:正则匹配工具名
[[hooks.PreToolUse.hooks]]
type = "command" # 处理器类型
command = "python .codex/hooks/write_check.py" # 执行的命令
timeout = 10 # 超时秒数
statusMessage = "检查写入..." # 状态栏显示文本
matcher 的作用
matcher 是一个正则表达式。匹配对象因事件而异:
PreToolUse/PostToolUse/PermissionRequest— 匹配工具名(如"Bash"、"Edit|Write"、"mcp__.*")SessionStart— 匹配启动来源("startup|resume|clear")UserPromptSubmit/Stop— 不支持 matcher,配置了也会被忽略"*"或省略 — 匹配所有
配置示例:多规则组合
同一个事件,多个规则叠加
下面是一个完整的 Hooks 配置示例,展示两个 PreToolUse 规则叠加使用:
# 规则 1:写入/编辑前安全检查
[[hooks.PreToolUse]]
matcher = "^(Write|Edit)$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python .codex/hooks/write_check.py"
timeout = 10
statusMessage = "检查写入操作..."
# 规则 2:所有工具调用都记录审计日志
[[hooks.PreToolUse]]
matcher = "*"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo $(date) >> .codex/audit.log"
timeout = 5
statusMessage = "记录审计日志..."
执行方式
当用户让 Codex 执行 Write 操作时:
- 规则 1 和规则 2 同时并发触发
- 规则 1 —
write_check.py检查是否允许写入 - 规则 2 — 记录审计日志(不受规则 1 结果影响)
- 若规则 1 拦截(exit 2)→ Write 操作被中止
2 或 deny JSON 决定,与 hook 执行顺序无关。
实战 1:PreToolUse 拦截危险命令
不让 rm -rf 或危险 git push 偷偷溜过去
最常见的用途——在工具执行前检查命令是否安全。下面是一个拦截器的实现:
#!/usr/bin/env python3
"""PreToolUse hook:检查 Bash 命令是否安全"""
import json
import sys
import re
# 从 stdin 读取事件上下文(JSON 格式)
context = json.loads(sys.stdin.read())
# 命令嵌套在 tool_input.command 中(Bash / apply_patch 工具)
command = context.get("tool_input", {}).get("command", "")
# 定义危险模式
DANGEROUS_PATTERNS = [
r"rm\s+-rf\s+/",
r"git\s+push\s+--force",
r"DROP\s+TABLE",
r"DELETE\s+FROM\s+\w+\s*$",
]
for pattern in DANGEROUS_PATTERNS:
if re.search(pattern, command):
# 输出拒绝原因到 stderr
print(f"🚫 危险操作已拦截:{pattern}", file=sys.stderr)
sys.exit(2) # 退出码 2 = 拦截操作
sys.exit(0) # 零 = 放行
# config.toml
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "python .codex/hooks/safety_check.py"
timeout = 5
statusMessage = "安全检查中..."
tool_input.command,检查后返回 0(放行)或 2(拦截)。
实战 2:PostToolUse 自动格式化
每次写完代码自动跑 prettier / eslint
Codex 写完文件后,自动触发格式化工具——你再也不需要手动 cleanup。
# config.toml:写入后自动格式化
[[hooks.PostToolUse]]
matcher = "apply_patch|Edit|Write"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "python3 .codex/hooks/auto_format.py"
timeout = 15
statusMessage = "自动格式化中..."
#!/usr/bin/env python3
"""auto_format.py:PostToolUse 写入后自动格式化"""
import json, sys, re, subprocess
ctx = json.loads(sys.stdin.read())
# apply_patch 的 tool_input.command 包含 patch 内容
patch = ctx.get("tool_input", {}).get("command", "")
# 从 patch 头部提取被修改的文件路径(+++ b/路径)
files = re.findall(r"^\+\+\+ b/(.+)$", patch, re.MULTILINE)
for f in files:
subprocess.run(["npx", "prettier", "--write", f], timeout=10)
为什么用 PostToolUse 而不是 PreToolUse?
格式化是事后操作——文件已经写好了,跑 prettier 不会影响 Codex 的决策。如果用 PreToolUse,修改了待写内容,反而会让 Codex 困惑。
退出码不拦截
PostToolUse 的退出码不会拦截已执行的工具调用——它只通知你工具已执行完。若需终止会话,要通过 stdout JSON 返回 { "continue": false }。
实战 3:SessionStart 注入 git 上下文
每次会话自动告诉 Codex 当前分支和状态
SessionStart hook 的标准输出会作为额外上下文注入到模型对话中。这是"自动给 Codex 提供环境信息"的最佳方式。
#!/usr/bin/env python3
"""SessionStart hook:收集 git 上下文"""
import subprocess
branch = subprocess.getoutput("git branch --show-current")
status = subprocess.getoutput("git status --short")
diff_stat = subprocess.getoutput("git diff --stat HEAD")
print(f"## 当前 Git 状态\n")
print(f"- 分支: {branch}")
print(f"- 变更文件:\n{status}")
print(f"- diff 统计:\n{diff_stat}")
# config.toml
[[hooks.SessionStart]]
[[hooks.SessionStart.hooks]]
type = "command"
command = "python .codex/hooks/git_context.py"
timeout = 10
statusMessage = "收集 Git 上下文..."
实战 4:SubagentStop 审计日志
记录每个子代理做了什么
当子代理完成任务返回时,SubagentStop 事件触发。你可以在这里记录子代理的执行结果,构建完整的审计追踪。
// hook 脚本从 stdin 收到的 JSON(通用字段):
{
"hook_event_name": "SubagentStop",
"session_id": "sess-abc123",
"turn_id": "turn-xyz",
"cwd": "/path/to/repo",
"model": "gpt-5-codex"
}
# config.toml:审计所有子代理结束事件
[[hooks.SubagentStop]]
matcher = "*"
[[hooks.SubagentStop.hooks]]
type = "command"
command = "python .codex/hooks/audit_subagent.py"
timeout = 5
statusMessage = "审计日志记录..."
SubagentStop 能拦截吗?
可以。如果 hook 返回退出码 2,子代理的结果会被标记为被拒绝——主代理会看到这个子代理"失败"了,可能重新调度。
实战 5:UserPromptSubmit 拦截敏感信息
防止把密码、密钥发给 AI
用户可能在 prompt 里粘贴包含密钥的内容。UserPromptSubmit hook 可以在 prompt 到达模型之前检测并拦截。
#!/usr/bin/env python3
"""UserPromptSubmit hook:检测敏感信息"""
import json
import re
import sys
context = json.loads(sys.stdin.read())
prompt = context.get("prompt", "")
SENSITIVE_PATTERNS = {
"AWS Key": r"AKIA[0-9A-Z]{16}",
"OpenAI Key": r"sk-[a-zA-Z0-9]{48}",
"Private Key": r"-----BEGIN.*PRIVATE KEY-----",
"Password in URL": r"://[^:]+:[^@]+@",
}
for name, pattern in SENSITIVE_PATTERNS.items():
if re.search(pattern, prompt):
print(f"🚫 检测到敏感信息:{name}", file=sys.stderr)
print("请移除后重新提交。", file=sys.stderr)
sys.exit(1)
sys.exit(0)
Trust 模型
第一次运行要让 Codex 信任你的 hook
Codex 不会无条件执行项目中的 hook 脚本——它需要你先信任这个项目。这是为了防止恶意仓库通过 hook 执行危险代码。
首次遇到项目 hook
当你在一个新项目中运行 Codex 并且该项目配置了 hook 时,Codex 会提示你确认信任。你需要手动批准后,hook 才会生效。
信任状态保存在你的用户配置中(~/.codex/config.toml),下次进入同一项目不再询问。
信任状态查询
Codex 会显示每个 hook 的 trustStatus 字段:
"trusted"— 已信任,正常执行"untrusted"— 未信任,不会执行
⚠️ 自动化环境的处理
用户级 hook(~/.codex/)不受项目信任限制,始终加载。CI 中推荐把 hook 放在用户级配置中,避免依赖项目信任。
config.toml 中偷偷插入 hook 来执行任意代码——你必须显式信任该项目。
安全注意事项
Hook 脚本本身也是攻击面
Hooks 给了你巨大的权力,但也引入了新的风险。以下是必须注意的安全事项:
供应链安全
如果你的 hook 脚本依赖 npm/pip 包,这些包可能被投毒。锁定依赖版本,定期审计,不要随意引入新依赖。
命令注入
在 shell 脚本中直接拼接用户输入是危险的——Codex 可能通过 prompt 注入恶意内容。始终对输入做转义或校验。
权限最小化
hook 脚本应该只拥有完成任务所需的最小权限。不要给 hook 脚本 root 访问权,不要让它能访问无关数据。
超时保护
永远为 hook 设置 timeout。一个卡住的 hook 脚本会阻塞整个 Codex 会话——设置了超时,Codex 会在超时后继续或中止。
调试技巧
让 Hook 不再"黑盒"
Hook 脚本不像普通命令那样有直接的终端输出。以下是几个实用的调试方法:
写入日志文件
在 hook 脚本中打开日志文件,记录入参和中间状态。这是最简单有效的调试方式:
with open(".codex/hook_debug.log", "a") as f:
f.write(f"stdin: {sys.stdin.read()}\n")
stderr 输出可见
hook 脚本写到 stderr 会显示在 Codex 的终端输出中。用 print(..., file=sys.stderr) 做实时调试。
检查 trust 状态
如果 hook 没生效,第一步检查信任状态。在 Codex TUI 中运行 /hooks 查看当前加载的 hook 列表,确认项目是否已被信任、hook 是否被加载。
先手动测试脚本
不要在 Codex 里"盲测" hook 脚本。先在命令行手动运行:echo '{"command":"test"}' | python hook.py,确保脚本本身没问题。
小结
你已经掌握了 Codex Hooks 的核心
10 种生命周期事件
从 SessionStart 到 Stop,覆盖了会话的完整生命周期。分为拦截型、注入型、观察型三类。
command 处理器
Codex Hooks 唯一的处理器类型:运行可执行脚本,通过 stdin 接收 JSON 上下文,用退出码(0 放行 / 2 拦截)和 stdout JSON 返回决策。
Matcher 正则精确控制
用正则表达式过滤 hook 的作用范围,让规则精确匹配特定工具或场景。
Trust 模型保障安全
首次运行需要确认信任,防止恶意仓库偷偷执行 hook。--dangerously-bypass-hook-trust 仅限自动化。
5 个实战场景
PreToolUse 拦截危险命令、PostToolUse 自动格式化、SessionStart 注入 git 上下文、SubagentStop 审计日志、UserPromptSubmit 拦截敏感信息。