返回文章
NO.
024
DATE
更新于 2026-08-11
READ
~14 min
KIND
教程
STATUS
原文

TAGS: AI 协作 架构

给编码代理装 hook:从软约束到可验证的闸

把 CLAUDE.md 里的一句软约束变成可验证的闸:判据出处、时点选择、观察边界、四层验证与对抗性用例。Claude Code 与 Codex 实现状态核验于 2026-08-11。

装 hook 最常见的结果不是装错,是装了等于没装。

它不报错,不告警,只是在该拦的时候安静地放行。规矩看着已经上了保险,保险丝其实是断的。这篇讲的是怎么把文档里的一句软约束,变成一道可验证、可迁移、可维护的确定性闸——以及怎么证明它是活的。文中的 Claude Code 行为都在本机实测过,Codex 的部分只核对了官方文档,没有实测。

一句软约束,和三层执行

第一个问题不是怎么写,是这条规矩到底归谁管。放错层的闸做得再精致,效果也只是让人误以为有保险,所以这一步排在写正则之前。

CLAUDE.md / AGENTS.md        意图、原则、软约束

lifecycle hook               代理侧即时反馈与行为 guardrail

Git hook / 测试 / CI          真正的 repository-level enforcement

中间那层是本文的主角,它的定位只有一句:hook 是 guardrail,不是仓库的安全边界。 这句话两家官方各自写过。

Claude Code 的 hooks 文档在讲 if 过滤器时明说它 best-effort,命令解析不了就 fail open,并建议「use the permission system rather than a hook to enforce a hard allow or deny」。Codex 的 hooks 文档更直接:「Some specialized tool paths can opt out of the default hook path. Treat tool hooks as a useful guardrail, not a complete enforcement boundary.」

所以「每一个提交都必须带对应文档」这种硬要求,最终要落在 Git hook 或 CI 上。hook 的价值在别处:它在代理还来得及改的那一刻说话,而不是等 CI 红了、上下文已经翻篇。

那 hook 该管什么?管那类「忘记一次就白干」的确定性判断:提交前必须跑格式化、改了运行时必须更文档、不许碰 .env。硬约束用确定性的 command hook;需要判断力的主观质量检查,Claude Code 现在有 promptagent 两种 handler 可用(agent 官方标为 experimental),但模型判断承担不了硬边界——它能提醒,做不了那道锁。

判据从哪来

有一条前置条件容易被跳过,而它决定这个 hook 半年后还在不在:判据来自仓库已经写下来的规则,不是 hook 作者当场发明的。

我给这个博客仓库装的第一个 hook,管的是「改了代码却忘了更文档」。它的判据长这样:

const WORK = /^(src\/|scripts\/|public\/|外部\/|\.github\/|package\.json|.*\.config\.(json|mjs|ts)$|wrangler.*\.toml)/;
const DOC  = /^(docs\/|AGENTS\.md|CONTEXT\.md|README\.md)/;

这两行里没有一条是新规矩。外部/AGENTS.md 里写着「Commit a screenshot in the same change that references it」;docs/roadmap.md 写着所有修改、决策和翻案都要进;其余每一项都能指回具体哪句话。

没有出处的判据迟早会被当成噪音绕过去,绕它的人往往就是写它的人。一道说不清依据的闸,第一次挡路时会被加上豁免,然后豁免一直开着。

选时点:生命周期上的四个候选

规矩定了,下一个问题是在生命周期的哪一点落闸。这个仓库里的四个候选,和最后的取舍:

每次写文件后(PostToolUse)——否决。 一个正经的工作会话会触发四五十次。更根本的问题是,单次编辑的那一刻,文档该写什么往往还没定型。上一批做字重收敛,改到第三个文件才发现漏了 18 处内联样式;如果每次编辑就催我写文档,结果是写三遍推翻两遍。

提交前(PreToolUse 匹配 git commit)——主闸。 提交是持久化单元,一个工作单元只触发一次,而且这时候还来得及补文档再提交。

推送前(PreToolUse 匹配 git push)——补闸。 推送是「变成公开事实」的时刻。它和提交闸看到的东西不一样,下一节展开。

会话结束(SessionEnd)——否决。 什么都补不了了。

另外挂了一个 Stop 上的提醒:一个任务跨很多轮,所以它只提醒,而且要累积到 4 个文件才开口。

那个时点上能看见什么

这是选完时点之后最容易想错的一步,也是这个 hook 里唯一一个结构性的限制——不是代码没写好,是那个时点物理上看不到。

直觉写法是查 git diff --cached,看看这次要提交什么。这条路走不通。

PreToolUse 在整条命令执行之前触发,而 git add -A && git commit 是一条命令。触发的那一刻还没 git add,暂存区是空的,--cached 返回空,hook 永远放行。

改成查 git status --porcelain 全量工作区就对了。但这个修法带来一个新洞:工作区里文档恰好是脏的、而这次只 git add src/foo.js 选择性暂存时,hook 看到「文档有改动」于是放行,真正提交进去的那个 commit 里一个文档都没有。

两句话要分开写:

  • 提交闸看的是 working tree,它不等价于 Git 的 pre-commit hook。
  • 推送闸看的是 outgoing commit range@{u}..HEAD),即将公开的那批提交实际动了什么。

推送闸堵住了选择性暂存那个洞——这是两道闸不冗余的真正理由。但它自己也有边界:它是聚合的。一个文档提交加四个代码提交,整体算过。规矩要是「每一个 commit 都要带对应文档」,range 级的闸证明不了这件事,只有 Git hook 或 CI 能。

这两条限制各写成了一条自检用例,标签是「边界」。这类用例的作用是让限制一直可见,不是让它变绿。

把判定写成确定性策略

判定逻辑抽成一个纯函数,输入是 (mode, command, paths),所有碰 git 的部分都留在外面。纯函数的用处是自检可以不依赖工作区当前状态就证明逻辑——这一点在下面第 1 层验证里是关键。

两条取舍:

fail-open 与 fail-closed 是一个要明写的选择。 这个闸选 fail-open:整段逻辑包在 try 里,catch 直接 exit 0;上游解析不出来时也放行而不是报错。理由是它是 guardrail 不是安全边界——一个因为 git 抽风就卡住提交的 hook,比没有 hook 更糟,而它挡的事真出问题也不会毁掉仓库。换成「不许 rm -rf」那种闸,选择就该反过来;而那种闸本来更适合放在 Git hook 或 CI 层。

判定的是文本,不是语义。 用正则判断一条 shell 命令在做什么,判断的永远是文本。所以只在命令位置匹配:字符串开头,或者 &&||;|、换行之后,允许前面挂环境变量赋值。

function invocation(command, verb) {
  const pattern = new RegExp(String.raw`^\s*((?:\w+=\S*\s+)*)git\s+${verb}(?![\w-])(.*)$`);
  for (const segment of String(command).split(/[;&|]+|\n/)) {
    const match = pattern.exec(segment);
    if (match) return { env: match[1] ?? "", args: match[2] ?? "" };
  }
  return null;
}

它返回的不是一个布尔值,而是这次调用自己的环境变量前缀和自己的参数。这个区分挡住了什么,下面「逃生开关」那节会看到。

漏判和误判的代价不对称:漏判少拦一次,误判会让人把整个 hook 关掉,所以这里偏向漏判。最初这个正则写的是 /\bgit\s+commit\b/,它拦下了一条 Python 命令,只因为那段 Python 的字符串列表里恰好含有这两个词。它拦的是一篇讲它自己的文章。

两个宿主,一个策略核心

事件名和 JSON 思路高度相似,所以策略核心可以共用;但 handler 类型、matcher 行为、路径解析、信任模型和部分事件语义都不一样,宿主配置必须分别写、分别验。下表是截至 2026-08-11 的实现状态,出处是两家官方文档

Claude CodeCodex
配置文件settings.json 三层作用域hooks.jsonconfig.toml,用户级与仓库级都有
多来源合并合并;高优先级层不覆盖低优先级层
事件数量约 30 个11 个
handler 类型command · http · mcp_tool · prompt · agent(agent experimental)只有 command 真跑;prompt 与 agent 会被解析后跳过
工具事件预过滤matcher + if(permission-rule 语法)只有 matcher,正则,只匹配工具名
PreToolUse 决定allow · deny · ask · deferallow · deny(ask 解析但不支持)
Stop 输出additionalContext 建议 / decision:"block" 阻止decision:"block",但语义是让它继续跑
路径解析${CLAUDE_PROJECT_DIR} + exec form会话 cwd;官方建议用 $(git rev-parse --show-toplevel)
hook 定义级信任无(有首次 codebase 信任)有,按定义哈希

几处值得展开。

Claude 的 if 现在能看懂复合命令了

这一条和不少 hook 教程里的常见做法相反。if 用的是 permission-rule 语法,官方文档给了一张 Bash 匹配表,明说复合命令会逐个 subcommand 检查、前置环境变量赋值会被剥离后再匹配——官方例子就是 Bash(git *)npm test && git push 匹配成功。

所以不必再「让所有 Bash 都起一次脚本进程、然后自己在脚本里找 git commit」。分工变成:

{
  "type": "command",
  "command": "node",
  "args": ["${CLAUDE_PROJECT_DIR}/scripts/hook-doc-freshness.mjs", "commit"],
  "if": "Bash(git commit*)",
  "timeout": 10
}

if 做低成本预过滤,脚本做业务判定和防御性检查。原生 parser 变强不等于防线可以撤:同一张表里写着,比 command name 写得更细的 pattern,遇到 $()、反引号或 $VAR 会照跑,解析不了整条命令时也 fail open。我在本机验的时候,一条只是包含 $(wc -l < $S) 的无关命令照样把 hook 起了起来——而代理写的命令里 $() 遍地都是。所以 if 省下的进程比看上去少,脚本里的命令位置匹配仍然要留着。

${CLAUDE_PROJECT_DIR} 那一段同样是修正:相对路径 node scripts/... 依赖 hook 触发时的工作目录,代理 cd 一下就没了。args 一出现就是 exec form,Claude Code 直接 spawn 不经 shell,参数原样传。

另有一条:if 只在工具类事件上求值,写在 Stop 上这条 hook 永远不跑。

Stop 的返回形状两家不通用

Claude Code 的 Stop 有两种输出。阻止它停下来用 decision: "block"reason;只给建议、让对话继续,用:

{
  "hookSpecificOutput": {
    "hookEventName": "Stop",
    "additionalContext": "文档滞后:4 个文件已改动,docs/ 未动。"
  }
}

区别是后者在 transcript 里标成 Stop hook feedback,不显示 hook 错误,模型看得到并有机会处理。另一处容易踩的地方:systemMessage 的官方定义是「Warning message shown to the user」——它是给人看的通道,不是喂给模型的反馈通道。目的要是让代理去补文档,只返回 systemMessage 等于什么都没做。两个一起返回才完整:一个给人,一个给模型。

两种都要读输入里的 stop_hook_active,为 true 时不再开口。Claude Code 有 8 次连续续跑的上限兜底,但依赖上限意味着白烧 8 轮。

Codex 这边形状完全不同:它的 Stop 用 {"decision":"block","reason":"..."},而且语义是反过来的——它不拒绝这个 turn,而是让 Codex 继续,把 reason 变成一条新的续跑提示。两边不能互抄;在 Codex 上做停点提醒要按它的 schema 单独实现、单独测。

这份 Codex adapter 因此不接 Stop,原因写在 README 里。

Codex 的配置与路径

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^Bash$",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$(git rev-parse --show-toplevel)/scripts/hook-doc-freshness.mjs\" commit",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

Codex 的 command hook 以会话 cwd 为工作目录,而 Codex 可以从仓库子目录启动,所以官方建议 repo-local hook 从 git root 解析——上面这个写法就是官方示例里的写法。另外它没有 if,每条 Bash 调用都会起进程,脚本第一行的快速返回在这里更重要。

好消息是 PreToolUse 的 deny 形状恰好通用:Codex 同样接受 hookSpecificOutput 里的 permissionDecision: "deny"permissionDecisionReason。坏消息是 ask 在 Codex 上会被解析、不被支持,hook run 记为失败并继续执行那次工具调用。把 Claude 的四值决定原样搬过去,得到的是一个静默失效的闸。

四层验证

每一层抓的是不同类的问题,少一层就有一类能溜过去。

第 1 层:逻辑对不对。 合成 hook 会收到的输入,直接喂给判定函数。起作用的不是跑这条命令,是跑之前把输入摆成「该拦」的状态。在一个干净仓库上测,任何 hook 都会「通过」,因为它本来就该放行。一个恒放行的 hook 在干净仓库上的表现,和一个完全正确的 hook 一模一样。

这一层压成一条命令:

node scripts/hook-doc-freshness.mjs --self-test

44 条用例逐条打印 PASS / FAIL,全过退出码为 0,末行打印总数,已经挂进 npm run check。它的意义不只是省事:不写代码的人也能判断这个 hook 是死是活,不用读正则,看有没有 FAIL 就行。

第 2 层:配置本身合法。 jq -e '.hooks.PreToolUse[] | .hooks[] | .command' .claude/settings.json,退出 0 且打印出命令才算数。这一层抓的是 JSON 写坏了——一个语法错误的 settings 文件会让该文件里的所有设置静默失效,不只是 hook。

第 3 层:宿主真的注册了它。 前两层全过,宿主仍可能根本没加载它。两家都提供了直接的检查方式:Claude Code 的 /hooks 是只读浏览器,逐事件列出已配置的 hook 和每个 handler 的完整定义;Codex 的 /hooks 除了检查还能审阅、信任、禁用——在 Codex 上没信任就等于没装,它启动时会提示。

要看宿主到底怎么执行的,Claude Code 用 claude --debug-file <path>,或 claude --debug 之后读 ~/.claude/debug/<session-id>.txt:匹配了哪些 hook、退出码、完整 stdout 和 stderr 都在里面(--debug 不往终端打印)。

第 4 层:该拦时真拦,不该拦时真不拦。 前三层只证明它会响,第 4 层证明它响得对。构造失败态真跑一次那个动作,看它是否拦住;再构造正常态,确认它放行。只构造失败态而不构造正常态,验出来的是一个见谁拦谁的闸;那种闸最后会被加上逃生开关,然后一直开着。

第 3 层和第 4 层可以合并成一个更省事的做法:临时在命令前挂一句哨兵(echo fired >> /tmp/hook-check.txt),跑一条不该触发的命令,再跑一条该触发的,读那个文件对比。这次改 if 就是这么验的:echo hello 没起进程,echo build && git commit --dry-run -m probe 起了并被拦。哨兵验完就拆掉。

对抗性用例:把旁路写进测试

正常输入全过,只说明它在正常输入上工作。该进测试的是能想到的每一种绕过写法

v1.0.0 的自检有 21 条,全过。重审的时候在里面找出三处可复现的旁路——都不是逻辑写错,是判定的作用域太宽:

输入v1.0.0为什么错
echo SKIP_DOC_CHECK=1 && git commit -m x放行对整条命令做子串搜索,前一条命令打印了开关就算数
git add src/foo.js && git commit --amend --no-edit放行见到 --amend 就豁免
git push --tags origin main放行见到 --tags 就豁免

后两条的依据都在 git 自己的文档里。git commit --amend 是「replace the tip of the current branch by creating a new commit. The recorded tree is prepared as usual」——它可以带上全新的代码,不只是改提交信息。git push --tags 是「in addition to refspecs explicitly listed on the command line」,所以同一条命令里的分支照推。这两个豁免的名字都在描述意图,而不是在描述这条命令实际会做什么。

修法不是加更多正则,是把作用域收紧到「这次调用自己的环境变量前缀」和「这次调用自己的参数」。现在的用例表里每一条都标了类别:

  • 保证:设计上的语义保证,坏了就是 bug。
  • 尽力:文本启发式,能被构造出来的输入绕过。
  • 边界:结构上判不了,写成用例是为了让它一直可见。

标成「边界」的两条是 command git commitgit -C . commit,两个都漏判。前者不修是因为前缀形式没有尽头(envbuiltin、绝对路径……);后者不修是因为 -C 可能指向别的仓库,那时工作区证据根本不是那个仓库的,拦下来的代价比漏掉更大

另外几条容易漏掉的用例:非 ASCII 路径、重命名、整目录未跟踪、上游解析不出来、以及「只暂存代码但工作区文档是脏的」——最后这条是在临时仓库里真造出来跑的,它断言的是提交闸会放行,而不是它不会。

顺带一条读代码读不出来的:git status --porcelain 不加 -z 时会把含非 ASCII 字节的路径转义成 C 风格八进制,于是 ^外部/ 这个正则永远匹配不上——而 外部/ 恰恰是这个仓库存放证据截图、且明写必须随改动同批提交的目录。也就是说,hook 在它最该起作用的目录上恒放行。加 -z 之后路径原样输出,代价是要自己消费重命名的来源路径字段(-z 模式下字段顺序是反的,先目标后来源)。再加 -uall,否则整个未跟踪的目录会被折叠成 dir/

逃生开关本身就是一个策略

逃生开关是必须的。纯格式化、依赖升级、回滚,这些确实不需要文档。但它和闸本身一样需要被严格定义和测试,否则堵住的洞会从它这里重新打开。

两条约束:

一、形式受限于宿主能看见什么。 这个仓库用 git commit -F - 提交,提交信息压根不出现在 hook 能看到的命令字符串里,所以开关只能是命令前缀 SKIP_DOC_CHECK=1,不能是提交信息里的标记。

二、作用域要绑到目标调用上。 不是整条 Bash 输入的任意子串。下面两种写法现在都不豁免,各有一条用例守着:

echo SKIP_DOC_CHECK=1 && git commit -m x    # 开关在另一条命令里
git commit -m "SKIP_DOC_CHECK=1"            # 开关在提交信息里

README 里的解析规则也要和实现一致:说明书写得比实现宽的逃生开关,等于把旁路写进了文档。

信任模型:两家都有,粒度不同

项目级配置会跟着 clone 一起落到本地机器。.claude/settings.json 是可以提交进仓库的,.codex/hooks.json 也是。一个陌生仓库里写着 SessionStart 命令的 hook,如果没有信任门,它在项目被打开的那一刻就执行了。

两家都有项目级信任:Claude Code 对首次打开的 codebase 和新 MCP server 有 trust verification-p 非交互模式下禁用);Codex 的项目级 hook 只在 .codex/ 层被信任后才加载。

差别在粒度。Codex 还额外要求:非托管的 command hook 必须先逐条审阅并信任这个确切的 hook 定义,信任按定义的当前哈希记录,定义一改就重新进入 review,/hooks 里可以逐条信任或禁用。--dangerously-bypass-hook-trust 是给已经在 Codex 之外验过来源的自动化场景用的,名字里那个 dangerously 是有意的。

所以「Codex 有信任机制,Claude 没有」这个说法不准确。准确的是:两家都有项目级信任,Codex 在此之上还做到了逐 hook 定义的哈希审阅。

拿去用

这套东西打包放在资料库里了:文档滞后闸 v1.1.0,CC BY 4.0。含脚本本体、Claude Code 与 Codex 两套 adapter 配置、44 条自检用例、CHANGELOG,以及一段可以直接贴给 AI 代理让它自己装的提示词。

那段提示词有两步是重点。一步是让代理报告它把 WORKDOC 定成了什么,以及每一条的依据是仓库里哪个文件的哪句话——判据的出处是唯一能核对的东西,它有没有自己编规矩只能从这里看出来。另一步是要求它构造「该拦」和「不该拦」两种状态各真跑一次。

参考

版本敏感的事实都在正文对应段落直接链了官方来源,这里只列全集。文档在变,这篇不会跟着变。

边界

  • 平台行为核验于 2026-08-11(Claude Code 2.1.227)。事件列表、字段名、handler 支持情况都在变,以当前官方文档为准。
  • Claude Code 上实测:exec form 与 ${CLAUDE_PROJECT_DIR} 生效;if: "Bash(git commit*)"echo build && git commit ... 命中、对 echo hello 不起进程、对含 $()$VAR 的命令 fail open 照跑;拦截理由完整回喂给代理;逃生开关的两种旁路写法确实被拦。
  • Codex 未实测。 本机没装 Codex,表格里 Codex 那一列和上面所有 Codex 结论全部来自官方文档,不是实测。有出入以官方为准。
  • 方法论部分来自这一个仓库的一次实践,不是行业规范。仓库是 Node + Astro + git;换技术栈时第 1、3、4 层通用,第 2 层和具体配置格式绑定。
  • 这个闸只查「有没有碰文档」,查不了「写得对不对」。它防的是彻底忘记,不是敷衍。
评论 →

CC BY-NC-SA 4.0

本文采用 CC BY-NC-SA 4.0 进行许可。

评论

评论由 GitHub Discussions 提供。登录 GitHub 后即可评论。 前往对应 Discussion