mcpField
C
claude-code-hooks

claude-code-hooks

github.com/daymadeAgent 工作流1.5k268 次安装今天更新
代码仓库

关于 claude-code-hooks

面向 Claude Code 的技能,介绍如何在工具调用或会话边界设计钩子来执行规则。内容涵盖事件行为、退出码约定、匹配方式、循环防护,以及阻止调用或提供反馈的实现模式。

主要功能

  1. 01说明钩子事件与退出码行为
  2. 02指导 PreToolUse 守卫和匹配规则
  3. 03区分阻止调用与注入上下文
  4. 04介绍 Stop 钩子的循环防护
  5. 05提供可运行的钩子模式

适用场景

  1. 01阻止反复出现的不安全工具调用
  2. 02在工具执行后注入已验证上下文
  3. 03在会话开始时运行有界检查

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/daymade/claude-code-skills --skill claude-code-hooks

安装后在 Claude Code 中输入/claude-code-hooks确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
hook_patterns.md
hook_pitfalls.md
scripts/
test_hook.group-name-guard.sh
test_hook.sh

详细介绍

Claude Code 钩子

Claude Code 会在工具调用的边界触发钩子。钩子是一条 shell 命令,它会从 stdin 接收 JSON 事件;对于阻塞型钩子,它会通过退出码决定是否继续执行工具调用。这是唯一能以结构性方式阻止某种行为的机制——CLAUDE.md 中的文字规则只是建议,可能被完成驱动力覆盖;钩子则是一道墙。

什么时候该用钩子(什么时候不该用)

当某条规则明明已经写下来,却仍一再被违反时,就该编写钩子。典型迹象是:你添加了文字规则,表述清楚,行为却还是再次发生——因为行动当下,注意力 100% 都放在“把事情做完”上,提醒便失去了作用。重复发生就是一个信号,表明应该将规则从文字(建议)转为钩子(强制执行)。治理规则的经验法则:Tier-0 不可逆操作 + 只有文字规则、没有钩子 → 应该用钩子。 (这里的 Tier-0 = 无法在会话内撤销其损害的操作:破坏未提交的工作、向远程端推送机密、删除文件、向外发布内容。判断标准是可逆性,而非严重程度。)

不要在以下情况下使用钩子:规则从未实际导致过重复问题(不要为假设中的错误预先构建防护措施——成本没有经过验证的收益),或者“规则”属于没有机械特征可供匹配的判断(钩子只能匹配令牌/模式,不能判断设计是否良好)。

如果症状是“它一直在审查 / 等待 / 重试”,不要想当然地认为再加一个钩子就能解决。先完成规则 7 中的循环约定,再阅读 陷阱 #36。循环可能完全是由 Agent 反复应用文字规则造成的。

钩子类型及退出码的含义

类型触发时机Exit 0Exit 2其他
PreToolUse工具运行前正常权限流程,除非 JSON 提供 allow/deny/ask阻止调用,即使 JSON 写明 allow 也一样;读取有效的 JSON 字段(v2.1.214+)。JSON 阻止原因或 stderr → 模型如果没有符合架构的 JSON,其他非零退出码属于非阻塞错误,调用仍会继续;如果有有效 JSON,则由支持的字段决定处理方式,并忽略该状态。使用 exit 0 进行结构化控制。遵循官方退出码约定
PostToolUse工具运行后静默,除非它在 stdout 上输出 hookSpecificOutput JSON——这是注入上下文的方式,且在 exit 0 时发生向模型提供反馈(无法撤销已运行的工具)—
SessionStart会话开始时继续无法阻止——stderr 会向用户显示钩子错误通知,Claude 看不到该通知,会话仍会启动无论如何都使用 exit 0:不是因为非零退出码会阻止会话(它做不到),而是因为任何非零退出码都会在每次会话启动时,将 <hook> hook error 写入用户的对话记录。matcher 匹配会话的启动方式——startup、resume、clear、compact、fork
Stop(+ SubagentStop)模型即将结束回复时允许其停止阻止停止——迫使模型继续运行(stderr → 作为原因反馈给模型)循环安全性:钩子会检查 stop_hook_active(必要条件,但非充分条件——参见规则 7)。执行框架的连续阻止上限(默认为 8)不是通用的兜底措施——只要任何一次继续执行调用了工具,计数器就会重置,因此如果钩子需要通过工具调用来补救(大多数钩子都如此),就永远不会触及上限(#27)。请自行设置上限。同一事件的所有 Stop 钩子都并行运行——一次阻止轮次可以携带多个钩子的反馈
  • PreToolUse 是拦截工具调用的主力——本表列出的类型只是本文件所讲解的那些,并非可拦截事件的完整集合,而官方 hooks 参考文档(docs.claude.com / code.claude.com,而非本包中的 references/ 文件——那些只涵盖上述类型)现在列出了更多可拦截事件,包括 UserPromptSubmit、PreCompact、TeammateIdle,以及任务和配置事件。如果你要拦截的不是工具调用,先去那里查找,不要硬把它塞到 PreToolUse 上。 matcher 选择工具(Bash、Agent、WebFetch……)——而它的匹配方式取决于你使用的字符:只包含字母、数字、_、-、空格、, 和 | 的 matcher 会按精确字符串比较(或用 |/, 分隔的精确字符串列表);任何其他内容都会被当作非锚定的 JavaScript 正则。两个方向都会悄无声息地咬人——Edit.* 也会匹配 NotebookEdit(应锚定为 ^Edit$),而 mcp__memory 会匹配任何东西都不匹配,因为它全是精确匹配字符,且没有任何工具恰好叫这个名字(你想要的是 mcp__memory__.*)。匹配区分大小写。退出码 2 会拦截,hook 的 stderr 会成为模型看到的消息——所以要把原因和正确的替代方案写在那里,而不只是"blocked"。
  • PostToolUse 无法撤销,但它可以注入权威上下文,让后续的幻觉无法成立(例如在提交后重新读取真实的 git HEAD 并呈现出来——模型无法在注入的真相面前"相信自己已经提交")。
  • SessionStart 用于对护栏本身进行有界部署和存活探测——健康时静默,损坏时告警,始终退出 0。注意为什么:并不是说非零退出会阻止会话(它做不到),而是它会在每次会话启动时打印一条 hook 错误通知,直到有人修复它为止——一个在启动时就喊狼来了的检查,是人们学会直接滚过去的检查。在构建/提交检查中的编辑之后运行完整的回归测试组,而不是在会话启动时。启动时只调用所有者声明的有界离线存活模式;缺失模式会让逻辑处于未知/不完整状态,而不要回退到 --selftest(构建顺序第 4 步;陷阱 #50)。
  • set -euo pipefail 与 set -uo pipefail——按契约选择,并且要知道有两种方式维持始终退出 0 的契约。 一个可能拦截的 hook(PreToolUse)需要 -e:意外失败导致脚本中止是可以存活的,因为调用方会把非 0/2 的退出码视为"继续"。契约是始终退出 0 的 hook(PostToolUse 注入器、SessionStart 检查)有两种诚实形态:(a) 去掉 -e,并对每条有风险的命令用 || 做保护——开着 -e 时,一个合法地什么都没找到的 grep 会让 hook 中途死掉,CLI 会暴露一个光秃秃的 Failed with non-blocking status code(陷阱 #8,Pattern E 的形态);或者 (b) 保留 -e 并添加 trap 'exit 0' ERR,这样任何失败仍会转换为退出 0,同时 -e 继续守护管道逻辑(git-commit-headcheck 的生产形态,Pattern D)。两者都正确;你不能做的是只用 -e 而没有 trap 也没有 || 保护。经验法则:做决定的 hook 用 -e;做报告的 hook,要么去掉 -e,要么 trap 它(陷阱 #8)。
  • Stop 是异类,也是最常被错误使用的那个:它是唯一能对模型自己刚刚生成的内容(它自己的回复文本)做出反应的 hook 类型。其他所有 hook 类型——包括 UserPromptSubmit,它听起来像是个监管"说了什么"的合理位置——都只能看到用户的输入;它在结构上无法看到模型自己当前轮次的输出(这个说法成立——这仍然是此类规则应路由到 Stop 的正确理由)。然而,这个保证并不延伸到证明 .prompt 字段总是源自一次按键:后台任务通知自己的报告文本也可以填充它,队友或另一个会话的消息也可以——stdin JSON 中没有任何字段标记这种差异,只有文本开头的包装标签——#30。像"模型不得为它尚未验证的东西发明一个简写名称"这样的规则属于 Stop;把它放到 UserPromptSubmit 上,它就会 (a) 永远抓不到它为之而建的东西,因为那段文本从不流经那个事件,并且 (b) 每当用户自己无关的输入恰好包含触发模式时就误拦。这是类别错误,不是调参问题——在错误的事件上再怎么改进正则也修不好。完整契约(last_assistant_message 与 transcript_path、防循环检查)见 references/hook_patterns.md 中的 Pattern E。
  • Stop 有两条拦截通道,具有相同的循环保护——按意图选择,并让第一次(也是唯一一次)拦截承载全部内容。 decision: "block" + reason,或纯退出 2 + stderr,会显示为 hook 错误——用于硬性关卡("这不能成立")。hookSpecificOutput.additionalContext 会显示为中性的"Stop hook feedback",没有错误通知——用于模型应当权衡的辅导和提醒,而非关卡。两者都计入上表中同一个连续拦截上限,所以这个选择关乎语气,而非安全性。这对消息设计意味着什么:被拦截的重试轮次(stop_hook_active: true)会被放行并保留所有仍然存在的违规——所以一个 Stop 护栏只有一次知情咬合的机会。(只有当你补救措施是"重写回复"时,上限才会强化这一点;如果它涉及工具调用,计数器会重置,上限就永远不会落地——#27。无论哪种情况,一次咬合的结论都成立,因为它依赖于闩锁,而非上限。)在那一次拦截中报告所有发现(只打印第一条的护栏会永久丢失其余部分——陷阱 #17),并把消息写成一份逃生手册,指明确切可接受的修复方式,而不是一份判决——模型要么在一轮内收敛,要么就烧光配额去猜。v2.1.145+ 的输入 background_tasks / session_crons 让拦截型 hook 能区分"会话已完成"和"会话只是暂停等待后台工作"——拦截一次暂停会迫使毫无意义的继续,并浪费同一个配额。 完整可运行的骨架:references/hook_patterns.md。

骨架(PreToolUse Bash 守卫)

#!/usr/bin/env bash
set -euo pipefail
IFS= read -rd '' INPUT || true                 # builtin; NOT $(cat) — see below
# 0-fork fast path: a builtin `case` on the raw JSON, BEFORE paying for python3.
# Your guard runs on EVERY matching tool call, so the irrelevant path is the one
# that has to be cheap. Keep this filter BROADER than what you actually block and
# never flag-level — it answers "is this even about X", nothing finer (#22).
case "$INPUT" in *TRIGGER*) ;; *) exit 0 ;; esac
TOOL=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null||echo "")
[ "$TOOL" != "Bash" ] && exit 0                # only guard the tool you mean to
CMD=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null||echo "")
[ -z "$CMD" ] && exit 0
printf '%s' "$CMD" | grep -qw 'TRIGGER' || exit 0   # precise relevance check
# ... precise detection here ...
if <command actually does the banned thing>; then
  echo "BLOCKED: ... WHY ... USE INSTEAD: ..." >&2   # stderr = the guidance shown
  exit 2
fi
exit 0

前两行并非风格问题。 INPUT=$(cat) 加上每次 printf … | python3 -c …,都会在此钩子匹配的每次调用中派生子进程, 包括那些无话可说的调用。大约 13 个 Bash 匹配器钩子 × 并行会话 × 亚秒级工具调用频率, 导致纯守卫开销持续达到每秒 40–200 次派生进程,并让 Gatekeeper 在全天 CPU 排名中位居榜首, 却没有任何失控进程——整个钩子组运行正常;问题出在无关路径的单次调用开销上(#22,其中包含 逐个守卫的转换方法和实测下限)。

复制 case 行之前,请先检查以下注意事项——第一条决定了你得到的是快速路径还是绕过:

  • 粗略过滤器必须是你要拦截内容的超集,而原始子字符串测试并不是。 TRIG''GER -x 会运行 TRIGGER——bash 会在执行前拼接掉引号——但原始事件文本中不包含 TRIGGER 子字符串,因此裸用 case "$INPUT" in *TRIGGER*) 会以 0 退出,守卫根本看不到它。实测:将这一行原样放入已发布的 Pattern A,TRIG''GER -x 就会从退出码 2 变为退出码 0,形成彻底绕过——而 scripts/test_hook.sh 仍报告 21 个通过 / 0 个失败,因为没有任何一行包含拼接后的触发词。Pattern A 已包含修复和原因 (“反拼接——去掉引号和反斜杠——然后再次检查;误报为否会导致彻底绕过”);在反拼接之前放置粗略过滤器, 会让这段逻辑无法执行。有两种安全做法,按优先级排序: 过滤拼接无法影响的内容——JSON 键或工具名称 (case "$INPUT" in *'"tool_name":"Bash"'*)),因为引号拼接只发生在命令文本中,无法改写事件本身的结构; 或者在过滤器内部先反拼接,再进行测试(复制输入,去掉 ", ' 和 \,然后进行匹配)。优先选择第一种: 它不需要费力处理转义;而需要正确处理自身引号的过滤器,也可能在无声无息间出错。上面的骨架之所以 按原样安全,只是因为它自身的检测同样是简单的单词匹配;一旦过滤器下方的守卫比过滤器更智能, 起决定作用的就是过滤器。

  • 此骨架在遇到无关内容时会放行(grep -qw … || exit 0),因此在它前面加粗略过滤器改变的是开销, 而非语义。拒绝优先的守卫则不同:裸用子字符串过滤器会悄然将其约定从“未知则拦截”变成“未知则放行” (实测——'not json' 轻松通过了该修复的第一版),而单独使用 *tool_name* 标记,则会从另一侧重新打开同一个漏洞。 在为原本应在输入格式错误时拦截的守卫添加快速路径之前,请先阅读 #22 中的门控要求。

  • 脚本之上还有更省开销的一层。 钩子处理程序的注册信息中可以带有 if 字段——例如权限规则语法 "Bash(git *)"——当不匹配时,钩子命令根本不会运行:零派生进程,因为没有启动进程。此功能按设计仅尽力而为 (文档说明,当 Bash 命令无法解析时,它会放行并照常运行钩子),因此应将其视为开销优化,绝不能用作门控—— 仍由脚本内的检查作出决定。请留意其限制:它恰好只接受一条规则(不支持 &&/||),仅在工具事件上求值, 而在非工具事件上设置 if 的钩子根本不会运行。

区分有效守卫与污染会话的规则

这不是风格偏好——每一条都对应我们曾发布、并追溯到具体原因的问题。

1. 使用 shlex 在词元级别匹配,绝不要用 awk 拆分原始字符串

一个误拦截正常命令的防护措施,比漏掉威胁更糟——人们不得不绕过的防护措施,最终会被习惯性绕过,随后便形同虚设(核心原则:误杀健康输入比漏报更糟)。误拦截屡屡发生的原因,是对原始命令字符串进行匹配。

  • 错误做法:用 awk '{gsub(/&&|\|\||;|\|/,"\n")}' 将命令拆分为若干段——awk 不理解 shell 引号,因此 grep -E "a|TRIGGER|b" 会在引号内的正则表达式中的 | 处分割,TRIGGER 会变成一个虚假的命令,导致防护拦截普通的 grep。(于 2026-07-21 发布;防护首次实际使用时,就误拦了我自己的 grep。)
  • 正确做法:用 shlex.shlex 类(而不是 shlex.split() 函数)解析整个命令——split() 只有在 | ; & < > 两侧有空格时才会将它们视为分隔符,所以 ls|TRIGGER x 会被解析为 ['ls|TRIGGER', 'x'],命令位置检查完全看不到 TRIGGER(实测;启用 punctuation_chars=True 的类会生成 ['ls', '|', 'TRIGGER', 'x'])。请原样复制已发布的遍历器,不要使用单行写法——但要复制通过 scripts/test_hook.sh 的那个,也就是 Pattern A 的版本。遍历器章节给出的是一种精简写法,文中也明确说明了这一点:它省略了各包装命令的带值标志表,因此会漏掉经由带值标志的包装命令传入的目标——实测中,对于 timeout 5 TRIGGER、sudo -u root TRIGGER 和 nice -n 10 TRIGGER,它都返回“非命令位置”,而 Pattern A 的版本能捕获全部三种情况。(两种写法都能处理裸写的 sudo TRIGGER——包装命令自身的标志需要带参数,而精简表不知道要跳过该参数。)已发布的测试脚手架只覆盖了其中一种情况,因此实际看到的测试结果是:该版本 20 通过 / 1 失败,失败项为 wrapper-timeout;而 Pattern A 的版本是 21/0。另两种情况会静默失败,因为没有对应的测试用例。带引号的 "a|TRIGGER|b" 会保留为一个词元,因此正则参数绝不会被误判为命令。然后检查目标是否处于命令位置(token[0],或 ;/&&/||/| 分隔符之后,并跳过 VAR=val 环境变量赋值前缀)。命令位置遍历器见 references/hook_patterns.md。
  • 推论:echo "…TRIGGER…"、grep TRIGGER、# TRIGGER、man TRIGGER 都必须放行。测试集必须包含这些仅提及、不执行的情况。
  • **推论——在 Git 写入片段到达遍历器之前将其排除。**提交消息是任意数据,整段消息文本会作为伪命令文本传入命令位置遍历——如果 git commit -F - <<EOF 的正文中引用了 foo|TRIGGER,TRIGGER 就会落在命令位置,防护会拦截它自己的修复提交(陷阱 #7 正是这种情况,且已发布)。任何检查命令字符串的 Bash 防护,都必须跳过命令开头为 git + commit/rebase/tag/am/cherry-pick 的片段——并且要在拆分任何行之前,从整个命令层面执行此操作(Pattern A 展示了顺序;生产版本使用的是 lib-git-commit-detect 的相邻性检查)。
  • 推论——遍历器采用两阶段处理是有原因的。 whitespace_split=True 会将换行符视为普通空白,因此多行代码块(cd /x\ngit add\nTRIGGER -y)会折叠为以 cd 开头的一个片段,触发词永远不会处于命令位置——在真实记录中回放时,触发率为 0(陷阱 #11)。先以了解 shell 语法的方式拆分成行(处理引号状态和反斜杠续行,因此带引号的多行字符串不会被拆碎),再用 shlex 遍历每一行——Pattern A 和遍历器章节都包含该拆分器(split_shell_lines,已在 qlmanage-guard 中通过生产验证)。它仍然无法解析 heredoc 正文(这不属于引号语法);是否接受这部分残余风险由 #11 决定。
  • 但 shlex 并非万灵药,检测的内容决定了 fail-open 是否安全。 shlex.split() 本身在引号不匹配时会抛出 ValueError——多行 git commit -m "… 消息中包含 # 或未闭合引号,就是典型触发场景。接着使用 except ValueError: cmd.split() 回退会放行:如果检测的是被禁止的修饰符(是否带有 --no-verify?——漏检更安全,符合规则 1 的方向),这就是正确做法;但如果检测的是命令本身是否为目标命令(是否为 git commit?),就会很危险(此时的 ValueError 会导致防护无法识别该提交,因而悄然不触发;曾有一次真实的跨领域提交因此在没有确认对话框的情况下发布)。对于是否为目标命令的判断,优先使用范围明确的正则表达式(将 git 和 commit 识别为两个独立单词,中间允许有任意标志词元),以免被多行引号破坏;将 shlex 遍历器留给命令位置 / 修饰符检查,此时 fail-open 的方向更安全。(边界是:当谓词为“这是否为某个常见特定命令”时用正则——例如 git commit、git push,因为该命令自身的消息/参数可能导致分词失败;当谓词为“命令位置上是否有被禁止的命令或修饰符”时用遍历器——此时被禁止的内容很少见,ValueError 时 fail-open 更安全,符合规则 1 的方向。)
2. 注册前,使用 bash -n + 真实 JSON 事件进行端到端测试

损坏或逻辑错误的 PreToolUse 钩子会污染整个会话 — 之后每次 Bash 调用都会被截断/重复/错误地报错/看起来像是已执行但实际上是幻觉,你会怪罪“环境”,殊不知问题出在你刚安装的钩子上。(2026-07-05:一次编辑导致 [^;&|] 正则出错,;& 变成了 bash 的 case 穿透 token,污染了半个会话,直到 bash -n 才发现问题。) “我部署时测试通过了”还不够 — 文件可能在之后的编辑中损坏。

注册任何钩子前,必须通过以下检查:

bash -n hook.sh                                # syntax
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"<trigger case>"}}'    | ./hook.sh; echo "exit=$?"  # want 2
printf '%s' '{"tool_name":"Bash","tool_input":{"command":"<healthy lookalike>"}}'| ./hook.sh; echo "exit=$?"  # want 0

必须使用注册时将采用的原样形式运行端到端用例 — 如果 settings.json 中写的是 $HOME/.claude/hooks/x.sh,就必须原样执行该字符串。 任何显式指定解释器的形式(bash hook.sh、python3 hook.py,以及会通过 bash 重新调用自身的钩子 --selftest)都会绕过执行位,因此从结构上就无法捕获注册后立即失效的问题。症状索引:安装后反复出现非阻断性的 PreToolUse hook error: … Permission denied,意味着以裸路径注册的钩子丢失了执行位 — 从安装起,这道检查就一直是失效的;自测通过并不能证明没有此问题,因为自测从未使用注册时的形式进行测试。 将测试脚本一并提交:scripts/test_hook.sh 会运行一整组触发/放行用例。自行阻断陷阱:钩子在当前会话中生效后,不能在自己的 Bash 命令中放入触发字符串来测试它 — 生效中的钩子会阻断测试命令。将用例放入脚本文件,然后运行 bash test_hook.sh;外层命令不包含触发字符串,因此不会触发自行阻断。

钩子造成一次真实事故后(误阻断或漏拦)就不能只靠自己重读代码 — 某个 Stop 钩子保护逻辑在当天重写后,作者在修复首个错误时又两次引入了错误(Python 注释中的引号在重读时不易察觉,只有运行实际失败的 JSON 用例才暴露出来)。此时应升级为多视角 Agent 团队评审,且每项发现都必须通过将真实负载执行到运行中的脚本来复现,而不是只阅读代码并表示认同 — 这是通用的 Counter Review 方法论(skill-creator 的 skill-development-methodology 参考资料,第 6 阶段),应用于钩子而非技能。在一次此类评审中,3 个视角(匹配逻辑/Shell 嵌入安全性/事件契约稳健性)发现了 13 个已确认且各自独立复现的错误,还有 1 项发现引用的证据竟是臆造的文档原文 — 只有要求验证者 curl 原始来源并 grep 精确字符串,而不是轻信引用,才发现了这一点。

3. 由安装器管理源文件、安装和恢复

将入口点及其规则模块保存在版本控制的源文件中。安装和恢复时,使用所属安装器当前的入口映射;规则模块无需单独链接或注册。当该安装器使用符号链接时,目录布局如下:

~/scripts/claude-hooks/<name>.sh      # SSOT (this setup: a private git repo)
~/.claude/hooks/<name>.sh             # symlink → SSOT

重新安装后,只恢复当前安装器声明为启用的条目;不要根据旧文件名重建注册项,也不要重新安装已弃用的别名。迁移条目时,保留独立的规则状态和授权写入器。验证实际使用的入口点、模块和生效设置,然后在全新的原生会话中测试一个真实匹配事件。失效链接可能会悄无声息地解除保护;使用有界的部署/存活检查(规则 4;references/hook_patterns.md 中的 Pattern C)。

4. 注册按配置文件分别进行 — 让所有配置文件保持一致,通过 HUMAN 门禁发布
  • 存在两个注册入口,健康检查可能只监视其中一个。 除了下面的配置文件设置,仓库自身的 .claude/settings.json(以及 settings.local.json)也会为该仓库中的会话注册钩子。这些文件位于 ~/.claude/hooks/ 之外,因此遍历该目录的防护检查无法覆盖它们——语法、路径、--selftest,无一例外。有一个这样的文件中有两处静默故障上线后,在旨在防止此类问题的 SessionStart 健康检查下存留了两个月(#38、#39)。使用绝对路径注册项目钩子——相对路径会基于会话 cwd 解析,有人从子目录启动 claude 时就会失效——并扩展健康检查,使其从事件的 cwd 向上遍历,查找项目设置。
  • 如果当前配置文件的 settings.json 没有调用钩子,那么 ~/.claude/hooks/ 中的钩子就起不到任何保护作用。 多配置文件用户在所有配置文件完成统一之前,都处于零防护状态。使用所属安装器更新主配置文件的设置(本环境中为 ~/.claude/settings.json;具体 jsonc 格式见 references/hook_patterns.md 的 Registration 一节)。只有当覆盖范围发生变化时才更新现有引擎的匹配器;保留每条规则内部更窄的选择器。本环境中的统一器作为不带参数的 SessionStart 钩子注册(sync-profile-settings.py,由 claude-switch-models-setup 技能负责),因此下一个启动会话的配置文件会将注册同步到所有配置文件。--all 是显式统一模式;需要立即同步配置文件时运行它,但不要将这次写入视为已激活的证明。SessionStart 健康检查会通过各配置文件的所属组件验证其生效的注册;文本搜索仅用于定位候选项。对于新注册或已更改的注册,应在全新的原生会话中验证实际匹配事件及调试证据。只有观察到该会话调用了已更改的注册,才能声称当前会话已热重载;成功写入设置或仅有一条启动健康检查日志,都不足以证明这一点。当前的 ConfigChange 契约允许将设置更改应用到正在运行的会话;但它不能证明此处的特定注册已经触发。
  • 如果防护需要一个释放阀,应设置为人工门控,而不是环境变量。 静态的 GUARD_OK=1 逃生口不算门控——模型可以自行设置该环境变量。使用原生 macOS 对话框(osascript——模型无法点击);拒绝、取消或超时都应判定为硬性 NO;将每次提示和绕过都记录到审计文件。模式见 references/hook_patterns.md。
    • ⚠️ /dev/tty 并不是第二个通道——文档说明钩子无法打开它。 这个文件以前曾建议在对话框之外,再通过 /dev/tty 要求输入 YES。 官方参考文档明确指出:钩子“在自己的会话中运行,没有控制终端”,并且“钩子进程及其任何子进程都无法打开 /dev/tty”(terminalSequence 是文档推荐的替代方式,用于向其写入)。因此,以这种方式构建的“双通道”门控实际上是一个通道加上无效代码;在没有 GUI 会话的设备上,也没有人能批准该门控。 这与本地观察结果一致,不过要仔细看清适用范围:在某个环境的共享审计日志中,共有 1,801 条记录,多个防护程序向其中写入,其中三个实现了 tty 通道——360 行带有通道标签(236 条对话框确认,124 条拒绝或超时),没有任何一行提到 tty 通道。 这意味着 tty 分支从未进入;而在 macOS 上,这正是你预期的结果,因为对话框会先返回并短路后续流程。因此,日志表明这里没有任何东西依赖 tty;是文档而非这项测量证明了 tty 根本无法工作。保留对话框;如果需要非 macOS 门控,就需要使用本文件尚未验证可行的通道。
    • ⚠️ 存续时间超过钩子超时的人工门控会默认放行。 钩子 command 的超时默认为 600s(UserPromptSubmit 为 30s),而超时的钩子不会阻止工具调用——因此,未得到答复的对话框不会被视为“拒绝”,而会变成允许。将等待时间限制在远低于超时的范围内,并在框架替你处理之前,主动将无答复判定为阻止。
    • ⚠️ 只有当对话框能够承载决策所需信息时,才值得弹出人工门控。 用户只能看到钩子放进对话框里的内容,别无其他——因此,如果钩子自身的证据为空(它在命令运行前读取状态,而该命令会创建该状态),对话框也会是空的,最终得到的只会是下意识点击,而非判断。在这些路径上应通过机械方式阻止,并告知模型如何重构命令,使状态变得可观测;将对话框留给能够明确指出目标、命令和对象的路径。无论对话框中显示什么由模型生成的文本(命令、路径),都要先折叠空白字符。症状、修复方法及录制器存根校准见: references/hook_pitfalls.md #44。
    • ⚠️ 你打印出来的补救指令必须可执行——通过实际运行来证明。 在钩子以机械方式阻止操作的情况下,stderr 指令就是这次拦截的全部产物,而经过折叠以供显示的字符串(如上所述)并不能让模型直接复制粘贴。验证每条打印出的补救指令的唯一有效方法是:按原样组装字节、运行它、再次通过钩子触发同一事件,并断言这次能够通过。其他解析器能够解码该字符串并不算证据——检查点是门控自身的分词器。references/hook_pitfalls.md #45。
    • 文档还介绍了一个 UI 内通道——PreToolUse hookSpecificOutput permissionDecision: "ask",它会通过 Claude Code 自身的界面发起提示。了解这一点很有价值,但在这里尚未验证它在 bypassPermissions / 自动接受模式下是否有效,而这恰恰是 Tier-0 门控必须能够应对的模式;之所以规定使用对话框,是因为它不依赖权限模式。
    • 在 Tier-0 以下,允许模型自行处理的逃生方式时,应让它成为正确用法,而不是绕过标记。 上述规则对 Tier-0 绝对适用,不会在这里放宽——这里讨论的是低于该标准的正确性防护;由于检测器无法区分某些合法情况,这些防护仍需要提供退出方式。关键在于你把这种退出方式设计成什么。使用 SKIP=1 / --force 这类环境变量逃逸,会强化规则 1 警告的那种条件反射;在截止期限压力下,它与绕过无异。更好的做法是让逃逸方式本身就是你原本希望他们采用的做法,这样使用它会改进命令,而不只是解除防护:pipe-fallback-guard 会在命令提到 pipefail / PIPESTATUS / pipestatus 时立即以 0 退出,因为写出其中任意一项的作者已经表明自己理解管道退出码——该防护没有什么需要再教他们的。可以用以下问题检验:如果有人使用我的逃生口,最终得到的命令会更好吗,还是仅仅不再受阻? 如果诚实的答案是“仅仅不再受阻”,那你设置的就是一个换了好听名字的绕过标记。Tier-0 情况下的对应原则——其中设置覆盖项只是为了测试门控:逃生口只能让门控变得更严格——参见 references/hook_patterns.md 中关于 GIT_GUARD_TEST 的讨论。
5. 决定失败的方向,并测试这个方向,而不只是理想路径

规则 1 对检测调优错误进行了排序:在守卫已运行的前提下,误拦健康命令比漏掉罕见的坏命令更糟,因为人们一旦必须绕过守卫,就会习惯性地绕过它。本规则讨论的是另一个维度——守卫机制完全没有运行,所以这里并没有颠倒“哪个更糟”的排序;这两种排序并不冲突。调优失误会让你漏掉一个案例;这种失误则会让守卫在所有这类输入上悄无声息地失效。

问题在于:守卫无法获取它要判断的对象——解析抛出异常、路径无法解析、依赖缺失、子进程超时——而阻止钩子崩溃的 2>/dev/null || true,恰恰会悄悄把“我无法检查”变成“没有问题”。钩子以 0 退出。这个输出与真正通过的情况完全相同,这就是为什么这个问题会持续数周而不被发现。

因此,在钩子获取某项内容的每个环节(解析命令、读取暂存文件、查询服务)都要明确决定:如果结果为空,是允许还是阻止?——并把答案写在对应分支旁边。对于修饰符检查,故障时放行通常是正确的(是否包含 --no-verify?漏掉它只会让你漏掉一个案例)。对于这到底是不是目标对象的检查,故障时阻止通常是正确的(这是不是跨域提交?结果为空意味着守卫根本没有触发)。

接着测试这个方向,而不是理想路径:故意传入无法解析的路径或无法解析的命令,并断言它仍会按你的决定行事。如果测试套件中的每一项都通过,只是因为钩子悄悄放行了一切,那么它和真正通过的测试套件毫无区别。

仔细阅读这些结果——对于不同的守卫类别,同一个输入的正确答案可能恰好相反。 以 cd ~/no-such-dir && TRIGGER 为例:

守卫类别判断依据正确退出码原因
Token 匹配器(这是不是被禁止的命令形式?)仅命令文本2,阻止TRIGGER 就在文本中;cd 无法解析,并不会让它不再是触发条件。如果守卫在这里悄无声息,它也会在 cd ~/real-dir && TRIGGER 时悄无声息
状态推导器(仓库的暂存集合是否跨越多个域?)从磁盘读取的状态0,允许cd 失败,&& 短路,不会发生提交——没有需要守卫的操作
终止状态读取器(补救措施是否已经完成?)回执/计数器文件(规则 7)0,允许——当状态文件本身就是终止条件时无法读取回执意味着钩子无法判断自己是否已经触发;在这里故障时阻止会导致永远阻止,既无法补救,也没有人能看到原因——这就是循环,也是唯一比漏掉一个案例更糟的故障。反向的子情况——复制此行前请先阅读:当状态只是叠加在独立谓词之上的预算时(完成相应操作仍可解除阻止),读取失败时放行就会悄悄禁用整个钩子——一个目录不可写,就会让它对所有输入永远沉默,这是最糟糕的故障形式。此时应退回到预算存在之前的行为(继续评估谓词),而不是保持沉默。用一个问题区分两者:如果状态消失,是否仍然可以补救? 不可以 → 回执情况,允许。可以 → 预算情况,继续检查。给出具体答案,免得任何人都要重新推导:规则 7 的机制 2(回执)和机制 3(每会话计数器)都属于回执情况 → 允许——机制 3 特意不判断 R 是否发生,因此计数器是唯一的退出途径;让它沉默会使当前轮次陷入困境。预算情况则是指:计数器叠加在一个用户仍可独立满足的谓词之上
因此,在编写该行之前,先确定你的钩子属于哪一类;而测试框架中的 无法解析的路径 模板行预期值是 2,因为该模板针对的是令牌匹配器类别。弄反了,就会对一个正确的防护逻辑报出自信满满的 FAIL。对于状态推导型防护逻辑,你要查找的故障是:命令实际上会执行,但防护逻辑没有检测到它——未配对的引号会导致令牌化抛出异常,回退逻辑会放行,于是真正的跨域提交就这样通过了,没有弹出对话框(规则 1 的 ValueError 说明)。对于每一条放行记录,都要问:这条命令实际会完成操作吗? 如果不会,那么放行就是正确的。

如果已保存的证据可以解除拦截,请在该谓词内验证其格式;参阅无效证据和外层 fail-open 处理程序。 被拒绝的凭据和无关的运行时故障有各自独立的失败策略。

对真实的状态推导型防护逻辑运行这一精确探测时,第一次运行返回了两条放行结果:一条是正确的(上面的短路情况),另一条则是真正的 fail-open。探测能发现问题;但你仍然必须判定发现的问题属于哪一类——这就是为什么上面的分类表要放在各条记录之前。

真实案例(2026-07-22):某个作用域防护逻辑通过 git -C "$REPO_DIR" 读取暂存文件,其中的 REPO_DIR 是从命令文本中解析出来的——因此,cd ~/repo && git commit 会把字面值 ~/repo 交给它,git -C 执行失败,返回的暂存文件列表为空,于是防护逻辑得出“没有跨域文件,放行”的结论。每次跨仓库提交都没有受到防护,而且一直没有任何异常迹象。问题结构及其共享库造成的转折:见陷阱 #10。

这个解析器还有另一种故障方向,而且更棘手。一旦添加回退逻辑来避免 fail-open,回退在一种情况下是正确的,在另一种情况下却是错误的——而两种情况都会输出同一行。git push(未指定目标)合理地回退到事件的 cwd;git -C "$R" push 则指定了钩子无法解析的目标,于是回退到相同的 cwd,接着却对另一个仓库显示出信心满满的 ✅。这两种情况生成的内容逐字节完全相同(经测量,MD5 相等),因此钩子和读者都无法分辨诚实的判定和绑定错误的判定。回退值必须携带选择该值的原因,只有“未指定目标”才能得出判定。完整问题结构、混淆代理的分析角度,以及为什么使用字面路径的测试样例无法捕获此问题:见陷阱 #28。

6. 根据世界可回答的事实进行判断——绝不依据自己的渲染结果,也绝不依据命名习惯

规则 1 和 5 讲的是你如何匹配,以及以哪种方式失败。本规则讲的是你匹配的对象来自哪里,它有两种都会悄无声息发生的失败形式:

  • 绝不根据为人类格式化的字符串进行分支判断。 如果钩子生成一份报告——经过排序、合并,并截取前 N 项,末尾附上 (+M more)——然后再根据这份报告对自己的判断进行模式匹配,那么分支判断就继承了渲染过程的信息损失。截断范围之外的项目对它而言根本不存在,因此它在所有小型测试样例中都能正常工作,却恰恰在它本应处理的大型会话中停止触发。通过独立的通道输出机器可读事实(单独一行未截断的 KINDS:a,b,c),并匹配该事实。渲染结果是输出,不是数据源(陷阱 #12)。
  • 优先选择可验证的事实,而不是命名惯例。 根据路径形态(/skills?/[^/]+/references/)进行分类,实际上编码了一种目录布局;仓库采用其他布局时,分类结果就会悄无声息地永远变成 None。解决办法不是扩大匹配模式,因为那只会把无声漏判换成全机器范围的误报(规则 1 明确禁止这种取舍);而是提出一个文件系统可以回答的问题——这个 references/ 目录旁边是否有 SKILL.md? 事实能经受布局变化,惯例则不能。(如果候选项本身就是 SKILL.md,就没有同级文件可供询问——应按基本名称分类;#13 会解释为什么这是规范定义的事实,而不是本规则警告的命名习惯。) 可验证的事实仍可能是错误的事实——要锚定问题,并按类型筛选。 在下载文件夹中,test -f SKILL.md 也会返回真;有个守卫沿祖先目录查找这个文件,结果吞掉了整个主目录,接着让一个真实会话加载了一个以该目录命名的技能——这样的名称根本不可能存在(规则 9 中的事件)。应锚定到特定目录的同级位置,或已知的安装路径;没有锚点的祖先目录遍历,只是披着事实外衣的惯例。

这两种问题的共同征兆是:分支在生产环境中一次也没触发过,但测试却全部通过。输出原始的格式化前分类结果,就能看出你遇到的是哪一种。

7. 如果钩子要求补救,就要证明循环会终止

一个会阻塞(退出码为 2),直到完成 X 才放行的钩子——尤其是 Stop 钩子,因为它在之后每次停止时都会再次触发——不是检查,而是一个反馈循环。 (只注入要求并以 0 退出的钩子根本没有循环:没有任何东西会重新评估。这就是下面的机制 0,而且它往往比人们通常选择的做法更适合作为默认方案。)

condition T is true → hook demands remediation R → model performs R → T checked again

在第一个循环开始前写好循环契约——既适用于钩子强制执行的循环,也适用于 Agent 驱动的审查/等待/重试循环:

LOOP KEY: immutable logical target / lineage + one failure axis
FIRE T: the condition that starts another cycle
REMEDIATION R: the exact action one cycle performs
VARIANT V: the well-founded quantity that strictly decreases for this key
BUDGET: maximum cycles, fixed before cycle 1
SUCCESS EXIT: the observable that proves the axis is clear
CAPPED EXIT: what is left blocked / unshipped / pending when the budget ends

没有完整的契约,就不得设置阻塞式 Stop 钩子,也不得运行反复的审查或轮询循环。必须在第 1 轮前固定关键项。补救快照、提交记录或审查者姓名都属于同一条溯源链,不能创建新的预算。新的、无关的发现是一个新关键项:单独记录,不会重置当前循环的预算。如果某一轮无法指出一个新的证伪实验,或说明 V 有所缩小,就没有增加证据,应当停止。

对于Agent 驱动的独立审查循环,默认预算为一次初始审查,加上一轮范围严格限定、针对实质性修复的复审。第三位审查者不是自动选项。如果复审仍在同一方面复现 BLOCKER 或 MAJOR,则不要注册钩子/发布产物,报告阻塞状态,并要求创建一项新的、经用户授权的任务;该任务的循环契约必须在第 1 轮前声明预算。Agent 自行声明的预算不能授权自己。在获授权的任务中,要具体说明现在停止会导致的安全或业务失败;可选的润色不符合条件。

已填写的审查循环示例:

LOOP KEY: <initial frozen commit>'s review lineage + termination-contract fidelity
FIRE T: fresh review reports a same-axis BLOCKER / MAJOR
REMEDIATION R: reproduce that finding, apply one bounded fix, run its narrow check
VARIANT V: 2 - completed review cycles
BUDGET: 2 cycles total (initial review + one re-review)
SUCCESS EXIT: no same-axis BLOCKER / MAJOR
CAPPED EXIT: artifact stays unregistered / unshipped; report remaining findings

初始冻结提交的所有修复后代都会保留在此键中。当前快照会变化,以便审阅者检查修复;其谱系和剩余预算则不会变化。

此无钩子预算没有任何机制强制执行——只有 Agent 遵循该 Skill 时,它才会生效。正因如此,达到上限时必须明确显示退出状态,且绝不能报告为“已完成”。

如果完成 R 会让 T 再次为真,这个循环就不会收敛。 不会报错,也不会崩溃;它会一轮接一轮地消耗下去,直到有人介入——而这通常确实会发生,因为每一轮都是一个完整的修复周期(分派、等待、采纳、编辑),而不是一次廉价的重试。而这同样也是为什么测试框架的连续 8 个阻塞上限救不了你:计数器会在每次执行了工具的继续轮次中重置,所以由工具调用构成的修复周期会让它永远停留在 1(已实测 — #27)。即使上限确实触发了,它也只是防止会话失控的最后一道防线,而非设计方案:这一轮结束时,违规仍未解决,而且测试框架会将该轮报告为 reason:"completed"——这与真正完成无法区分。“最终会停止”并不符合你所期望的任何终止意义,而在这里,它甚至不会最终停止。stop_hook_active 在这里帮不了你——该字段仅涵盖一层重入(“我刚刚阻止的停止操作正在重试”)。它对跨轮次情况毫无说明:模型真正开始执行 R(实际工作,调用许多工具),然后自然停止;这是一个全新的 Stop,此时该字段为 false,钩子会基于相同理由再次触发。

这个测试借鉴自程序验证中的终止性证明——一个循环变式 / 排名函数:写出一个映射到良基序(通常就是 ℕ)的量 V,并证明在每个 trigger → remediate → re-check 周期中,V 都严格递减。没有 V,就没有终止性证明——不要注册该钩子。

V 是设计阶段的义务,不是代码——你不会在钩子中计算它。实际交付的是谓词(下文介绍的机制);V 则是证明该谓词会收敛的论据。把它写在下一位读者一定会看到的地方——脚本头部:

# TERMINATION: V = 1 - exists(<receipt path>)
# decreased by: R writes the receipt; nothing R does afterwards can remove it.

回答以下问题以说明它会递减;答案写在该注释中:

  1. R 会改变什么? 指明确切的文件 / 字段 / 时间戳。
  2. 该对象是 T 的操作数吗? 如果是,而且 R 会让它朝“触发”方向移动 → 就不存在 V。
  3. R 之后,什么是让 T 再次为真的最小输入? 如果答案是“我刚才触发所用的同一输入” → 就不存在 V。重新设计谓词;不要重新调整阈值。

一个真实的反例。 某个 Stop 钩子要求在将复合工件(规则文件、技能、其他钩子)推送前进行独立审查:

  • T(使钩子触发的条件)=“存在尚未经过审查的编辑”,实现方式是比较时间戳 last_edit > last_review(last_edit = 工件集中的最新 mtime,last_review = 审查记录的 mtime——两个单一数值,这恰恰让这种比较显得安全)
  • R = 分派独立审查者 但值得运行的一次评审是有输出的:它的发现会被同一个 Agent 立即采纳,就在它下一次尝试停止之前 → 这会产生新的编辑 → last_edit 越过 last_review → T 又为真。(如果是由人稍后、在带外采纳,就不会有循环——循环需要修复和复查都发生在同一个 Agent 的回合内,而这正是 Stop hook 所保证的。)这里没有 V——修复不会减少某个量,它会重置某个量。唯一的出路是”评审,然后什么都不改”,而这恰恰是分派评审者毫无意义的情况。观察到的现象:连续三轮,每一轮都是一个完整的评审并采纳的循环,只有用户说停才退出。

有两件事让这一点难以看清。这个比较孤立来看完全合理——”评审必须比上一次编辑更新”正是你会写下的东西。而这句话是通过条件——T 是它的否定。把它原样照搬进脑子里,不带那个否定,你在接下来的全部分析中就是在推理错误的操作数;要让 T 始终保持为触发条件的方向。(把代码写成提前退出的守卫子句——… && exit 0——是正常的 shell 风格,这里说的不是这个;这里说的是你在哪个方向上进行推理的纪律。还要注意相等的情况:同一秒的 mtime 落在通过一侧,即 fail-open,这与下面这条规则对状态读取的要求一致。)运行上面的清单,它会机械地推出来:R 改变 last_edit(Q1);last_edit 是 T 的一个操作数(Q2);能让 T 重新触发的最小输入就是修复自身的输出(Q3)→ 没有 V。

第二种失败形式:谓词根本看不到修复(可观测性缺口)。 上面的反例是一个被修复移动的时间谓词。同一族里一种更安静的失败:修复确实发生了,但谓词读取它所用的通道在这个环境里根本不存在。真实案例(2026-07-26,由一次全舰队循环审计发现):一个 Stop hook 通过扫描 tool_results 中的 agentId: <hex> 并读取 subagents/agent-<hex>.jsonl 来检测”发生了一次独立评审”——在主配置下是正确的。团队模式会话使用完全不同的 schema(spawn 回执为 agent_id: <name>@session-<uuid>,投递为 teammate_id teammate 消息,文件为 agent-a<name>-<hex>.jsonl)——零匹配,永远如此,于是 last_review 永远是 None,每一个复合编辑∧push 的回合都会重新触发这个要求:一个假阳性循环,在单个停止序列内是有界的,但跨回合是无界的,而且那个会话里它的”2/2 次触发”两次都发生在已被完整评审的工作上。同一族,不同的药:时间循环需要更好的谓词;可观测性循环需要更好的通道。给清单加第四个问题——Q4:在这个 hook 会运行的每一个环境里,谓词真的能看到 R 发生吗? 对读取 transcript 的 hook 来说,这意味着要从每个配置/模式中解析一个真实会话,而不是对一种 schema 做 fixture 测试。(上述案例的修复:多 schema 检测 + 将 teammate 投递排除在回合边界之外,这样它们就无法截断检测窗口——陷阱 #20。)

先按轴选,再按顺序选。 0 决定是否要阻塞;1 决定挂在哪个事件上;2–4 是谓词的形状(选了 1 你仍然需要 2–4 中的一个)。0→4 的顺序是”循环被移除得有多彻底”,而它与你能强制执行的程度成反比——所以选第一个仍能给你实际所需强制执行力的方案,而不是简单地选第一个。 0. 不要拦截——注入。 如果需求只是建议性质(你希望模型考虑 R,而不是不执行 R 就无法完成任务),就打印内容并以 0 退出。没有任何内容会重新评估,因此也就没有需要证明会终止的循环。对于 Tier-0 以外的所有情况,这是恰当的默认做法,代价也很明确:提醒可能会被忽略,所以请在标头中说明它采用失败时放行的策略——规则 4 的要点依然成立,主体可以绕过的关卡就不是关卡。如果你需要的是关卡,请使用 2,并接受需要提供凭据。注入通道:模式 D。 ⚠️ Stop 上不存在此选项——而规则 7 的主要对象正是 Stop,所以在使用此选项前请先读完这一段。在 Stop 上,exit 0 表示“让本轮结束”,因此文本不会有后续的推理步骤来接收;并且 hookSpecificOutput.additionalContext 与 exit 2 计入同一个 8 个区块的上限(见 Hook 类型一节),也就是说,它同样是一个区块。Stop 恰好有两种模式:拦截或静默。因此,在 Stop 上选择机制 0,意味着更改事件——将注入挂到生成该产物的工具调用上(PostToolUse,模式 D)——或者承认你实际上需要的是关卡,然后改用机制 2。 让周期性建议在整个会话期间都可用。 用经过的时间、活动阈值以及每次提示词或交付后的重置来限制其频率;不要设置每个会话的生命周期计数。生命周期计数无法在节奏限制已降低频率后进一步减少突发频率——它会将最终可用性从定期提醒变为永久静默,通常影响最需要提醒的最长会话。下面的机制 3 是用于阻塞式补救循环的终止预算,不是通用的反垃圾模式。请将两种情况作为一个序列进行测试:低于任一节奏阈值时保持静默,而第五次、第十次或之后任何一次完全满足条件的时间窗口仍会发送提醒。 ⚠️ “没有循环”仅在 R 不是你自己的匹配器目标时成立。 如果一个针对 Bash 的注入器告诉模型运行 git ls-remote,那么它会在这条命令上再次触发并重新注入。形式相同,力度较弱——模型可以忽略,因此不会强制迭代,但它会在重复操作时广播,而不是毫无影响。请确认你建议执行的 R 不是此 Hook 会匹配的操作。

  1. 将检查移到操作边界。 如果你想限制的是某个操作——推送、发布、删除——就用 PreToolUse 限制该操作,而不是用 Stop 限制整个轮次。Stop 钩子的补救循环往往是挂在错误事件上的操作门控,这正是钩子类型一节中“Stop 格格不入,也最常被误用”的具体例子。 请准确理解这样做的作用。 PreToolUse 只有在模型主动重试受限操作时才会再次触发,而且模型随时可以拒绝操作并正常结束轮次。因此,它能保证轮次会终止——最糟的情况从“轮次无法结束”降为“此操作不会发生”。它无法让一个不收敛的谓词收敛:将上面的反例原样移到 PreToolUse,循环依然存在(推送 → 阻止 → 审查 → 采纳发现 → 新修改 → 重试 → last_edit 又超前了 → 阻止)。问题出在谓词,而非事件,因此仍应从 2–4 中选择一种方案。还要注意,PreToolUse 没有框架层的兜底机制——钩子类型表中的 8 次阻止上限仅适用于 Stop——因此,将会自行重置的谓词移到这里,安全网会更少,而非更多。 ⚠️ 以下两种情况不适用此机制:必须使用受限工具本身完成 R(例如,限制 Edit 并要求先修复文件头——这会造成死锁,永远无法满足条件);以及同一会话中会重复发生的操作(例如,在一个要推送 5 个仓库的会话中设置 git push 门控,就意味着要完整满足 5 次要求;这正是下方实战案例中的频次问题,而且机制 1 并不能让你免于此问题)。

  2. 将“已完成补救”设为是否存在的事实,而非时间事实,并以需要补救的对象作为键。 让 R 生成一个制品,并检查它是否存在;关键在于键的设置方式:

    KEY=$(git rev-parse HEAD 2>/dev/null || printf 'nogit')   # or a hash of the
    RECEIPT="${TMPDIR:-/tmp}/my-guard.${KEY}.ok"              # reviewed content
    [ -f "$RECEIPT" ] && exit 0            # V = 1 - exists, for THIS key
    

    V = 1 - exists 是按键计算的:每个键只会递减一次,而且对该键而言绝不会再次增加。新工作会生成一个新键——这是新的需求,而非重新启用。同样不合适的两种键设置是:用一个全局路径会让钩子每台机器只触发一次,之后便永远失效且毫无信号;用时间作为键则属于本规则旨在禁止的时间谓词。时间谓词几乎总是错误的方案,因为你要求的补救通常正是改变比较对象的原因。 对于一次性回执门控,这个内容 SHA 键是正确的。但它不会重新定义 Agent 审查的溯源链:该溯源链通过补救生成的提交仍使用其原始键和原始预算。 ⚠️ 如果可以由模型创建回执,那这不过是换了个说法的规则 4 中已弃用的 GUARD_OK=1 逃生口。应让模型无法控制的机制来写入回执(例如审查子 Agent 自己的输出文件、git note),否则就接受该钩子仅供参考,并在其标题中说明这一点。

  3. 阻止式补救循环设置上限——绝不限制重复的建议性交付。 只有当钩子强制执行有界循环,且达到上限后的退出方式明确表示操作仍被阻止、尚未发出或仍待处理时,才对每个会话中的每个目标最多提出 N 次要求。session_id 是该终止预算的稳定键(每个事件中都有;请参阅模式参考中的 JSON 契约):

    SID=$(printf '%s' "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('session_id','nosid'))" 2>/dev/null || echo nosid)
    CNT="${TMPDIR:-/tmp}/my-guard.${SID}.count"
    N=$(cat "$CNT" 2>/dev/null || echo 0); N=$((N+1)); printf '%s' "$N" > "$CNT"
    if [ "$N" -gt 3 ]; then
      CAPPED_REASON='Loop budget exhausted; the blocked condition remains unresolved. Do not report completed.'
      python3 - "$CAPPED_REASON" <<'PY'
    import json, sys
    print(json.dumps({"continue": False, "stopReason": sys.argv[1]}))
    PY
      exit 0                              # explicit capped stop, not silent success
    fi
    

    粗糙,而且刻意不判断 R 是否真的发生了——但它是有限的,这正是之前缺少的属性。不要替换成 $$ 或 $PPID:每次钩子运行都是一个新进程,所以这些值每次调用都会改变,计数器永远不会累积。打印计数(“第 2 次提醒,共 3 次”)——至于这种措辞为何值得保留,请参见下文的惨痛教训。达到上限时使用文档规定的通用 continue:false / stopReason JSON 字段,这样用户看到的是已达到上限的停止,而不是与成功 Stop 无法区分的结果。这会停止会话;但不会保护发布操作。如果达到上限时的退出提示某个产物仍未发布,就应在操作边界使用 PreToolUse 单独强制执行。

    不要将此计数器用于以持续可用性为目标的注入器。如果提醒过于密集,就根据观察到的用量调整频率或滞后;如果达到上限后的状态是“条件仍然存在,但钩子永远静默”,那么这个计数器就没有合理的终止状态,机制 3 也就不是正确的设计。

  4. 滞后/冷却窗口(针对告警抖动的控制理论解法):触发后,在一个时间窗口内抑制重新评估——使用一个戳记文件,加上 [ $(( $(date +%s) - <stamp mtime> )) -lt 900 ] && exit 0(在 BSD/macOS 上,mtime 使用 stat -L -f %m;在 GNU 上使用 stat -L -c %Y——本文中的其他代码片段也一样;当安装程序使用符号链接时,-L 至关重要,否则读取的是链接本身的 mtime,编辑 SSOT 时戳记不会更新——#41)。适用于围绕阈值波动的条件;对于由补救操作重置的条件则不适用——这类情况需要机制 2 或 3。

    ⚠️ 滞后不提供 V——它是限速器,不是终止证明。 只有条件自行消退,循环才会结束;此时让循环结束的是外部环境,而不是你的钩子。因此,其 # TERMINATION: 行必须写明这一外部事实(“戳记过期时,X 已由 <谁> 解决”)。如果你无法如实写出这一行,你需要的是机制 2 或 3。冷却只控制一条提示多久能发出一次;它不会把周期性提示变成机制 3 所约束的有界补救循环。

状态本身的失效方向:应用规则 5 中的问题,不要只看关键词。 如果状态无法读取或写入——TMPDIR 不可写、沙箱限制、磁盘已满——应由规则 5 的防护分类表来决定,并通过提问 “如果此状态消失,补救操作仍然可能吗?” 作出判断。对于机制 2 和 3,答案是不可能——回执是 R 已发生的唯一记录,而机制 3 刻意不判断 R 是否发生,因此计数器是唯一的退出方式——所以允许停止。无法读取自身状态、却仍然阻止停止的终止机制,本身就是这个循环,只不过现在没有人能看见原因。 ⚠️ **不要仅仅因为两者都提到“计数器”,就把机制 3 归到规则 5 的“反向子情形”。**该子情形适用于这样的计数器:它只在用户仍能独立满足的谓词之上,控制唠叨的次数——在那种情况下,无法读取计数器时保持静默,会让一个原本有其他清除方式的钩子被静音,因此应继续评估该谓词。机制 3 没有这样的备用谓词。实测结果表明,这正是这种组合会导致的故障:将机制 3 的代码片段粘贴到 Pattern E 的骨架中(该骨架使用 set -uo pipefail,见关于 -e 与 trap 的项目符号),将 TMPDIR 指向一个不可写目录,连续运行五次都会返回退出码 2——N 始终无法持久保存超过 1,永远达不到上限;而 #27 已经排除了在补救涉及工具调用时由测试框架上限兜底的可能。这里的故障方向完全由代码片段中没有的一行 set 决定,因此应在实际会失败的步骤——写入——上设置保护,绝不要在读取时设置:

printf '%s' "$N" > "$CNT" 2>/dev/null || exit 0   # can't persist ⇒ can't terminate

读取操作已有保护(cat … 2>/dev/null || echo 0),并且必须保持原样:计数器文件不存在是首次运行时的正常情况,因此在读取时使用 || exit 0,会让钩子在完全正常的环境中永远静默。实测中,每个变体连续运行五次:在 TMPDIR 可写时保护写入,结果为 2,2,2,0,0;在其不可写时,结果为 0,0,0,0,0——两种情况下都正确;保护读取则在两种情况下都得到 0,0,0,0,0,也就是说,这种保护永远不会触发。

需求文本中的说明不能代替能够收敛的谓词。即使钩子消息写着“如果你认为没有必要,就再完成一次”,每一轮仍会耗费一个完整的补救周期,因为模型被告知必须做 X 时,通常就会去做 X。逃生通道必须位于谓词中,而不是建议里。

测试要求,也是这里最容易跳过的一项:自测需要包含一个“补救之后”的用例——不能只测“该触发时会触发”,还要测“R 完成后会停止触发”。否则,非终止在结构上不可见:每个测试样例都只是孤立的某个时间点判断,而非终止是一个关于序列的属性。无论包含多少个用例,只检查单个时间点的测试套件都完全无法覆盖收敛性——这就是钩子明明通过了自测,首次遇到真实情况时却仍会陷入循环的原因。能够发现这一问题的成对用例(收据缺失 → 触发,收据存在 → 静默;其设置和清理方式无法用普通的 run 行表达)已在 scripts/test_hook.sh 的“AFTER-REMEDIATION ROWS”下提供模板;症状 → 原因 → 修复见陷阱 #16。 已证明终止 ≠ 感觉上已经终止(2026-07-25 实战记录)。 一个带有正确存在性事实 V 的 Stop 钩子在一个会话中触发了三次——每次触发都是来自不同已完成任务的、合理的新推送,机制完全按设计运行——但用户的感受依然是:“为什么这东西卡在循环里?”(这与机制 2 所说的“R 做什么都无法让它重新上升”并不矛盾:V 是按键计算的——三个不同的键,对应三次独立的单向递减。当你无法判断自己遇到的是哪种情况时,这也是诊断方法:如果每次触发都带有一个新键,说明机制没问题,问题在于触发过于密集;如果重复触发共享同一个键——或者谓词根本没有键,因为它比较的是时间戳——那你遇到的就是上面的反例,需要替换谓词。)从外部看,连续三次独立的修复周期与循环没有区别。变体证明解决的是机制问题;它并未说明一个会话可能要求多少个不同的阻塞式修复。如果阻塞型 Stop 钩子产生了这种密度(这里一天会多次交付不断累积的产物),可以考虑将机制 2(存在性事实)与机制 3(限定会话范围的终止预算)结合使用,或者有意接受这种观感,并在钩子的输出中说明——“阻塞需求 2,最多 N 次”听起来是在取得进展,毫无说明的重复则像是在循环。对于周期性注入的建议,只能通过频率控制/滞后机制来解决密度问题;生命周期上限会在会话中途悄然让产品停止工作。(2026-07-26 后续:看起来像密度问题的同一钩子触发,结果 100% 都是误报——它在团队模式下的审查通道无法识别 Schema;参见上面的可观测性形式。在接受“触发合理”之前,先确认这些触发是否确实有证据支撑。)

**终止与价值是两个独立的门槛。**V 能证明循环会结束;Loop Contract 的预算和受限退出机制,则决定是否值得为另一个周期付出代价。揭示这一区别的无钩子审查循环故障,以及范围漂移为何会悄然不断生成“新”工作,见陷阱 #36。

8. 等待也需要同样的验证——通知仅供参考,轮询必须有预算

规则 7 涵盖了 钩子 创建的循环。同样的情况也会在没有钩子的情况下出现:Agent 轮询异步结果——子 Agent 的报告、后台任务的完成通知、CI 状态。真实会话(2026-07-25):子 Agent 的完成通知通过一个可能延迟或丢弃消息的邮箱送达;三个不同的 Agent 都已完成工作,通知却一直未送达,等待中的 Agent 在约 40 分钟内反复执行了十几轮 sleep 240 + 催促,直到用户询问它到底在做什么。没有任何报错;只是这个循环没有变化。

规则 7 的机制也适用——前两项可直接套用;滞后机制没有对应方案(等待不会来回振荡),它的位置由一种等待特有的陷阱取代:

  1. 轮询产物,而不是通知。 如果你真正需要的是结果(文件、git 引用、API 状态、数据库中的一行),就等待结果本身,而不是等待“它有没有说已经完成”。通知只是提示;产物才是事实。检查产物是否存在,一旦事实出现就立即结束,无论有没有消息送达。
  2. 每次等待都要有预算,并在编写循环时确定。 最大轮数 × 间隔(例如 3 × 4 分钟),并且在第一次休眠之前就要有降级方案:自己完成、询问用户,或标记为待处理并转去做其他工作。“无限期等待看看”不是降级方案——那就是循环本身。
  3. 传递协议仅供参考,并非保障机制。 写上“完成后通过 SendMessage 汇报——没有消息就视为未完成”是有价值的,但它约束的是 Agent 是否发送消息,而不是邮箱是否送达消息。某次会话中,三个 Agent 的提示词里都有这个协议,却全都没有发出消息。设计等待逻辑时,应假设通知可能永远不会到达——因为它确实可能不会到达。

同一场会话还付出了两个相关陷阱的代价:用 TaskStopping 终止一个“卡住”、实际上正在工作的 Agent——邮箱延迟不等于闲置;一名审查者花了 20 分钟做实际语料测试,却在交付前几分钟被当成“卡住”而终止。另一个陷阱是对等待本身做类似 --dry-run 的探测:认定对方没有回应之前,先确认自己的观察渠道正常(那场会话中,System Events 窗口计数对一个实际显示在屏幕上的对话框自信地返回了 0——权限故障伪装成了证据)。

9. 固定样例无法告诉你误报率——注册前先回放真实命令语料

规则 1 排出了哪种错误更糟的顺序(误拦优于漏拦,因为必须绕过的守卫会让人条件反射般地绕过)。规则 2 要求你在注册前进行测试。两者都无法告诉你实际误拦范围有多大——测试表做不到这一点,因为它的输入是你根据编写检测器时所用的同一套思维模型构造的。测试用例包含你想到的各种情况;按其构造方式,你没想到的情况就不会出现。这不是靠增加测试行就能弥补的覆盖率缺口。

实测于 2026-08-06。 一个 PreToolUse/Bash 守卫通过了包含 26 个用例、针对其运行了 5 次变异的测试表,并已注册。随后将它与从最近 60 份会话记录中提取的 11,903 条去重真实命令进行回放:143 条命令产生了候选项,运行中的钩子拦截了 46 条;人工检查全部 46 条后发现有 10 条拦截错误,占所有拦截项的 21.7%(两条边界情况算作正确;若作相反判断,比例约为 30%)。不到 39 分钟,它就拦截了 3 个真实会话,其中一个还被要求加载一个根本不可能存在的技能——这是下面第三种缺陷的体现:补救指引指向了不存在的东西。它在同一小时内被移除。三个根本缺陷都出在解析层:把续行符读成分隔符;扫描片段时把 here-doc 内容和数据参数也计作执行命令;路径分类器没有类型过滤。 这个故事不能支持两个结论。它不意味着“变异测试不起作用”:后来对同一套测试的审计发现,钩子的逻辑有 5 处可以删除,而全部 26 行仍然通过;作者标注为“已从装饰性修复”的一行实际上仍然只是装饰——“我运行了变异测试”不等于“变异测试做对了”,而重放正是揭露这两点的手段。它也不意味着“你本来不可能知道”:其中 3 个缺陷是手动重新实现了此文件已经提供的代码(split_shell_lines、命令位置遍历,以及 is_git_write 对 -C 及其参数的处理)。规则 1 的直接使用遍历器原样代码才是更省事的修复,但它被跳过了;重放是最后一道防线,而非第一道。

方法——请特别留意第 2 步:

  1. 收集。 会话记录位于 ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl,以及 ~/.claude/history-sources.json 中登记的任何归档。命令是 .message.content[] 中 type == "tool_use" 且 name == "Bash" 的条目,位于字段 .input.command——不是钩子 事件的 .tool_input.command,两者的结构不同。去重,并收集足够多的 会话记录,确保包含你自己近期的工作模式(那次运行用了 60 个)。
  2. 使用随附的检测器原样切出代码进行预筛选——绝不要手写 等效代码,否则你测量的只是自己两种猜测之间的一致性。预筛选 仅用于控制成本(11,903 → 143);真正做出判断的是第 3 步。如果检测器无法单独提取——比如它是 shell 脚本,或分散在一个被引用的库中——就把它改造成可单独提取;无法独立运行的检测器也 无法进行单元测试。
  3. 将每个候选项交给真实钩子,并附上它自己的真实会话记录和 session_id。 读取会话状态的防护程序,在伪造的上下文中会给出不同答案。事件 约定:references/hook_patterns.md。有两件事会造成麻烦——在临时 TMPDIR 下运行,否则规则 7 的收据和每会话计数器会写入真实会话,并且 会在你自己的测量过程中途让防护程序静默;如果钩子设有人为确认关卡,就通过 Pattern B 的强制拒绝路径处理,而不是回答 143 个对话框。
  4. 手动检查每个拦截项——拦截项列表就是误报测量结果。 允许列表回答的是规则 5 的问题(它保持静默是因为没有匹配项,还是因为它看不到?),而重放结果为 零 个拦截项,应让你怀疑测试框架,而不是认为一切正常——陷阱 #11 对检测不足的情况也规定了同样的检查方式。

如何处理这个数字。 如果某个误报的修复指引有误或无法执行 → 就不要注册:这种模式会催生规则 1 旨在防止的条件反射式绕过。否则,将拦截项列表视为修复清单,并重新运行重放。误报预计会集中在你编写防护程序时正在做的事情上——那次运行中有一半误报都落在钩子开发文件上,因为作者那周正在编写钩子。还要特别检查拦截项列表中的运维操作(编辑 hooks 目录下的文件、对钩子运行 bash -n、防护程序自身的 SSOT):一个会阻止自己被移除的防护程序,无法在会话中关闭。最接近的记录案例是 #25:防护程序拦截了一条只读的 git config core.hooksPath 查询——这正是距离自我锁定仅差一步的盲点。一旦防护程序已经把你锁在外面,逃生路径见 #3 的列表(使用 Edit/Write 工具编辑 settings.json,它永远不会触发 Bash 匹配器;或者使用不同的 CLAUDE_CONFIG_DIR 启动会话)。 这种聚集只是一个倾向。对于检测器为文本模式的防护规则,其中一种情况却是必然的:记录反模式会复现它自身的触发条件。解释防护规则的提交消息、文档示例、你写入自己知识库的备注——每一项都包含原样的禁用模式,作为数据写入,并且都会进入语料库。在 2026-08-30 对 852 条命令进行重放测量时,与防护规则标题所述模式匹配的 4 条命令中,3 条是正常的——一条关于防护规则的提交消息、一篇嵌入该模式的文档,以及一条校准命令,它故意在同一处运行错误形式和正确形式以展示差异。如果检测器照字面理解该模式,那么在其标志性模式下,75% 的判断都是错的;而且作者在撰写防护规则说明时,这些阻止都会在他们写到一半时打断他们。两种豁免可以消除这一类情况,而且都不是特殊案例:数据接收端 heredoc 正文(references/hook_patterns.md,受命令位置遍历器的 heredoc 限制约束——用于区分接收端的剥离器),涵盖提交消息和文档写入;以及同一条命令中存在正确形式,涵盖校准命令——作者把修复方案和错误做法并列写出,是在演示,而不是在提交错误。这与管道回退防护规则使用的 pipefail 逃生口模式相同:命令中带有证据,表明作者已经了解情况,所以别再跟他们争论。其机制是在检测器运行前再执行一项字面测试,并在匹配时提前结束——对于 pipe-fallback-guard,这是子串集合 pipefail/PIPESTATUS/pipestatus(规则 4);对于具有标准修复方案的检测器,则是该修复方案的独特片段。保持范围狭窄且匹配字面内容:针对正确形式编写一个模式会重新引入整个猜测问题,而作者必须有意输入的固定字符串很难意外匹配,且其失效方向是漏检。

规模方面,为免这看起来像个研究项目:一次收集加一轮循环,耗时几分钟。

修改已经发布的门禁,尤其是添加允许分支时,会在相反的方向上出问题:新分支可能放过危险命令,而误报计数永远无法证明这一点。使用旧版本和新版本重放语料库,将新分支的触发条件组合到每一条必须阻止的记录中,并不断调整分支,直到这些记录都变红:hook_patterns.md。

10. 阻止合法工作的防护机制需要一个同意通道——而同意信号必须来自能看到提示词的钩子

为阻止某种故障模式而构建的防护机制,最终会阻止一个形态相同、但合法且经用户授权的操作。用户会在对话中授权,但防护机制无从得知。home-scan-guard(阻止枚举个人暂存目录)第一次遇到用户说“我授权你扫描我的 Downloads 目录以清理磁盘”时,就会碰到这个问题。

应在提示词边界捕获口头授权,而不是从命令文本推断授权。保留显式的 UserPromptSubmit 和 PreToolUse 注册;各自的事件处理程序可以共用一个引擎,同时将授权和防护逻辑分开:

UserPromptSubmit granter: reads the user's prompt → matches an explicit
    authorization phrase (consent verb + action noun + target) → writes a
    time-boxed, path-scoped consent file (mtime = grant time)
PreToolUse guard: before blocking, reads the consent file — fresh (≤TTL) and
    covering the target → allow; otherwise block as before

避免通道演变为绕过手段的设计约束(已在 2026-09-19 的实现中全部验证;具体实例为 ~/scripts/claude-hooks/ 中的 home-scan-guard.sh 和 home-scan-consent-granter.sh):

  • 始终设置 TTL。 没有有效期的同意文件会造成永久解除防护。以小时计,不以天计。
  • 限定路径,绝不默认全部放行。 授权 ~/Downloads 不得解锁 ~/Pictures。通配符条目必须是明确且单独表述的授权行为。
  • 爆炸半径最高的规则必须保持硬性拦截。 对于 home-scan-guard,这是规则 A(递归扫描整个主目录):任何措辞都不能解锁它。应按防护机制分别决定哪些规则可以通过授权放行;答案通常是“只放行范围较窄的规则”。
  • Agent 绝不能手写同意文件。 授权器的输入必须是用户实际输入的提示词——这是 Agent 唯一无法伪造的内容。手写文件(或伪造授权器输入来匹配含糊的口头同意)会破坏审计记录,使防护机制沦为摆设。必须在两个钩子的头部注释中都记录此禁令。
  • 含糊的措辞不得授予授权。 单独说“我给你授权”(没有动作名词,也没有目标)不得解锁任何操作。必须包含动词,以及(动作名词或明确指定的目标)。授权器失败时应当是漏掉授权,而不是错误授予授权——这与防护机制的失败方向相反。
  • 既可通过措辞撤销授权(“撤销 home-scan 授权”),也可删除文件撤销。

激活证据: 调用已注册脚本时,读取的是其当前文件;请验证其解析后的路径及依赖项。应通过所属安装程序注册或恢复授权器(在上述设置中为 register-hook.sh),然后执行规则 4 的新会话事件检查。在授权器尚未被确认在接收授权的会话中运行之前,只能使用所有者文档明确说明、由用户操作的备用方式;如果没有这种方式,则将受防护的操作保持为待处理状态,直到授权得到验证。不要根据设置写入操作推断已捕获同意,也不要假设所有注册变更都需要重启或会立即热重载。

校准是关键环节: 授权器的自测必须验证两个方向——授权用例通过,含糊措辞和否定用例则不得授予授权(其失败模式是错误授予授权,防护机制的失败模式是错误拦截;两者都需要各自进行双向测试)。防护机制的自测还应覆盖:无同意时拦截;新鲜且限定范围的同意只允许访问相应路径;同意过期后拦截;同意永远不能解锁硬性拦截的规则。会创建同意文件的有状态自测必须在测试前备份任何真实同意文件,并在测试后恢复。

构建顺序(依次执行)

在编写或注册另一个钩子之前,按机制和宿主事件检查现有引擎。优先采用进程内规则模块,共用输入解析和惰性事实查询。每条规则的工具选择器、授权证据、状态命名空间、执行频率和失败策略都应保持独立;这些边界本身并不要求拆分为独立进程。组合匹配器时,应使用内部选择器保留其原有覆盖范围,包括非 Bash 工具,并让状态写入器沿用各自原本的生命周期事件。 组合结果时,保留完整的主机协议:退出码 0/1/2、结构化拒绝、建议性上下文和错误诊断。某条规则允许,不应解除另一条规则的拒绝。若无法组合主机/事件/运行时契约,或组合独立的人工等待会导致等待串行执行或截断其现有时限,则使用单独的条目。切勿对交互式授权关卡应用较短的通用超时。

在具有代表性的原生主机任务中,验证唯一匹配的处理程序和启动的解释器。若调度器将每个旧钩子都作为子进程启动,只是减少了注册数量,并未减少每次调用的工作量。保留模块专属测试和可观察的失败身份;不要将无关的判断合并为一个共享批准标记或“已提醒”标记。

  1. 确认这是真实复现的问题,而非假设情形,否则不要构建。 如果钩子会要求修复,而不只是阻止操作,那么在编写任何逻辑之前,先写出完整的 Loop Contract(key / axis / T / R / V / budget / two exits),并将 V 写入脚本头部的 # TERMINATION: 行(规则 7)。先检查你要拦截的是否是某个操作;如果是,在该操作上设置 PreToolUse 防护即可消除循环,而非设法控制循环。 如果无法指出在每个 trigger → remediate → re-check 循环中严格递减的量,那么该设计就不会终止——应修正设计,而非正则表达式。在添加任何重复计数器之前,先说清其有上限的乘积状态。有效的循环预算最终必须进入 blocked、unshipped、pending 或其他明确的终止状态。“尽管会话仍在继续,提示永久静默”不是终止状态;应让重复提示在整个生命周期内不设上限,并证明经过多个完全到期的窗口后,长时间运行仍具备活性。
  2. 将规则模块写入现有引擎的 SSOT;只有上述契约要求时才编写独立入口点。仅对直接调用的文件设置可执行权限。
  3. 使用 shlex 进行词元级匹配来检测(规则 1),依据应是世界能够回答的事实,而非你自己的渲染方式或命名惯例(规则 6)。针对重复进入 Skill 的绕过行为,加载和合规执行是两个独立谓词。在实际写入或传输目标处检测受管操作;已加载的标记、临时目录或重复请求都不能解除拦截。使用原始绕过行为以及健康的源代码维护、测试夹具和只读探测进行校准。如果只能检查部分操作,应明确说明范围;安装和单元测试不能证明已实现原生拦截。
    • 先检查 ShellCheck 是否已作出判定——然后记录答案,因为下一位作者也会问同一个问题。 它是 shell 反模式检测的事实标准,因此审查者首先会问:“为什么不直接用 shellcheck?”在 0.11.0 版本上,于 2026-08-15 使用 find . -name x | head -5 || echo "no" 进行测量——这是一个可以证明永远不会触发的回退,因为 || 绑定到管道的最后一个阶段,而 head 在输入为空时也会以 0 退出:默认配置不会报告任何内容,退出码为 0。--enable=all 会发现 SC2312(check-extra-masked-returns),但它也会在 cmd | jq . || echo bad 上触发;这个例子中最后一个阶段确实可能失败,回退也确实有意义。 以下理由使它不适合作为拦截门槛——每一条都适用于更广泛的情形: 它默认关闭(因此目前没有保护任何人);它无法区分无效回退和有效回退(全面触发会导致规则 1 所说的误拦截螺旋);而且它自己建议的补救方法是“使用 || true 忽略”,这与目标正好相反。它检查的还是文件,而非工具调用事件。通用的回答思路是:标准 linter 值得用来检查,但通常不应委托其实施拦截门槛,因为 linter 的设计侧重于广泛提示,而门槛需要精确性。你的钩子贡献的正是这种精确性。已发布示例:pipe-fallback-guard,其精确性来自一份简短清单,列出确实会吞掉上游退出码的末级命令(head/tail/wc/cat/sort/…);它会有意排除 grep/jq/awk/sed,因为这些命令确实可能失败。
  4. 使用触发条件和相似的健康情形验证模块和实际入口点(规则 2);对于 shell 入口点,运行 bash -n + test_hook.sh。全部通过后再注册。测试中应包含带有未展开路径的情形(cd ~/elsewhere && …,规则 5);如果钩子设有人为门槛,应加入强制拒绝的行(模式 B,“让门槛可测试”);如果钩子要求修复,应加入修复后的成对行——没有凭据时触发,有凭据时静默(模板见 scripts/test_hook.sh;规则 7——仅靠特定时点的测试夹具,结构上无法发现不终止问题)。
    • 为编辑和维护提供双向自测。 语法和注册检查无法发现钩子是否已退化为永久无操作。永不触发的防护,输出与没有任何内容需要报告的会话完全相同。应让启动流程拥有由所有者声明、范围有界、离线运行的 --liveness 模式。如果没有声明这种模式,应将逻辑报告为未经验证,并将覆盖范围报告为未知/不完整;不要改用旧版 --selftest、完整测试套件、网络调用或机器审计。要明确覆盖范围的边界:双向测试验证的是逻辑;可执行位、符号链接以及注册路径能否解析属于部署事实,由健康检查自身的可执行文件/注册扫描覆盖——自测通过不代表接线正确,接线扫描也不代表逻辑正确。两者不能互相替代。两个测试夹具只是最低要求,而非目标:一个必须拦截样例和一个必须放行样例,这样才能同时发现“停止触发”和“开始误拦截”两种问题——只有其中一种样例,无法兼顾两者。应按能杀死的变异来确定规模,而非按夹具数量;并像校准测试套件一样进行校准:故意破坏检测器,确认 --selftest 会以非零状态退出。一个从未见过失败的自测,与 exit 0 无从区分。在已发布的 compounding-edit-review 上测得:其第一个版本的两个测试夹具只杀死了 14 个变异中的 4 个——注释中声明的每一项关键行为都毫无覆盖,包括一种会让反循环检查提前返回、而自测仍打印 OK 的变异。该次测量中,修订后的套件运行了 58 个测试用例。 真正的约束不是测试夹具数量,而是会话启动时的耗时,因为每次会话都要付出这部分成本:这 58 个用例耗时约 5.3 秒,而两个探测式活性检查只需约 140 毫秒。当杀死变异会让耗时超出预算时,应当拆分,而不是缩减——在声明的 --liveness 模式中运行固定规模、廉价的离线探测,将完整回归套件留给编辑/维护检查。 将规模缩减到杀变异要求之下,只会换来一个在防护失效时仍然通过的自测。 为拆分后的完整测试部分设置触发机制,否则它永远不会运行。 “在构建时运行”不算机制——注释里写着修改后运行完整测试套件,与本文件旨在消除的“文字说明与实际强制措施脱节”问题完全一样,也会以同样方式失效。经 shared-repo-head-drift 测量(21 个用例,冷启动耗时 17.8 秒,将 SessionStart 的健康检查精简为 9 条断言的探测,耗时 2.2 秒)后,采用的方案是:将启动活性检查与 --selftest、--selftest-full 等维护模式分开。让负责构建/提交的检查在验证标识发生变化时运行完整测试套件;SessionStart 只运行有界探测。缺少完整通过标记意味着构建/提交时需要运行完整验证,而不是要求启动时运行。在 2026-10-04 对一个包含 61 个钩子的集群进行测量后发现:完整测试套件冷启动耗时 1 分 44 秒,并发启动会话会将其放大为 5–10 分钟的卡顿——因此,构建/提交门槛应仅对该次提交中暂存的文件运行自测,否则一个无关的失效防护会阻塞修复另一个防护的提交。“钩子的代码”包括已注册文件,以及它运行和导入的内容。 大多数防护都是对同级 .py 文件中分类器的薄封装,因此只基于封装文件生成的签名,在逻辑的每次修改后都仍会有效,导致测试套件恰恰不会在它本该运行的修改后执行(#49——其中也给出了依赖规则和单进程实现)。 除钩子及其依赖项外,还应将解析后的解释器及其版本、测试框架和相关配置纳入该标识。 为每次探测和整个启动扫描设置明确的截止时间;发生超时或取消时,仅清理它们自己的子进程。通过注册钩子所用的同一安装程序拥有的运行时来调用测试;对于不会读取事件的测试,应关闭标准输入。保留有界的失败诊断信息,而不是丢弃所有子进程输出。根据陷阱 #53,将超时、取消、无法读取标识或覆盖不完整的情况记录为未知;只有在预期测试实际成功完成后,才写入通过标记。通过未修改的钩子、已修改的辅助程序以及缓慢或失败的探测验证此拆分:它们都不应让完整测试套件重新进入 SessionStart。针对调度器自身源代码的一个结构性防护:如果程序主体位于命令替换中的带引号 heredoc 内,任何主体注释中多出的引号都会导致 macOS 自带的 bash 解析整个文件失败——应按照 #57 将其移出,否则调度器本身也会成为它所监管的防护之一。 选择探测用例时,不能只是“取前 N 个”:必须包含一个必定触发和一个必定静默的用例,否则无法同时覆盖两种退化方向。注意有些必定静默的用例其实没有意义——仅提供提示的钩子总是以 0 退出,因此基于 run 的退出码行无法证明没有误报,必须使用 says 形式的断言(#40、#14)。
  5. 重放真实命令语料并逐条人工检查拦截结果(规则 9)——这能测量误拦截面,这是第 4 步中的测试夹具表在结构上无法做到的。原样摘取已发布检测器进行预筛选;将每条候选命令连同其真实转录和 session_id 一起传给真正的钩子,并使用独立的临时 TMPDIR。
  6. 通过其所有者安装或恢复入口点(规则 3)。仅修改模块时复用当前入口;只有当安装程序的有效入口契约要求创建符号链接时才创建。不要注册已退役的别名。
  7. 仅在入口点或覆盖范围发生变化时协调注册项,然后统一配置文件,并在新的原生会话中验证有效入口(规则 4)。仅修改模块不会新增处理器。
  8. 对 Tier-0/不可逆操作,添加人工确认放行门槛(规则 4)。
  9. 通过仓库的常规流程持久化源文件。 工作流发生变化时,更新受影响的操作人员 SSOT 和 CLAUDE.md 路由。复用现有路由;不要重复实现细节,也不要在常驻说明中添加事件条目。

已知陷阱(调试失灵的钩子前请先阅读)

症状 → 原因 → 修复的完整目录:references/hook_pitfalls.md。

测试框架是隐藏变量——请使用 scripts/test_hook.sh,不要自行编写测试脚本。 下面每种自行编写脚本的失败模式,产生的输出都和测试通过时完全相同,因此看起来像是成功了(2026-07-22,修复 Stop 钩子的白名单时,一次就碰上了三个):

  1. 事件结构错误。 Stop 钩子读取的是 last_assistant_message / transcript_path,而不是 tool_name / tool_input。传入符合 PreToolUse 结构的事件,钩子找不到文本 → 以 0 退出 → “没有误拦截!”
  2. JSON 引号转义。 单引号中的 '{\"a\":1}' 会输出字面量反斜杠和引号;json.loads 抛出异常后,会被钩子的 2>/dev/null || exit 0 吞掉,每个用例都“通过”。
  3. 测试用例恰好属于规则的豁免范围。 基线字符串用了一个规则有意豁免的字符串(守卫会标记形式为 <name> Group 的新造昵称,但豁免常见短语 in the group / group chat——而基线行恰好用了其中一个)。本应证明守卫仍然生效的那一行没有触发,整套测试却显示全绿。

如果钩子的作用是输出消息,就不能用退出码来测试。 拦截型钩子的契约主要取决于退出码,因此 run 行可以覆盖它。但如果钩子的存在就是为了说点什么——比如 PreToolUse 钩子解释正确的替代做法,或 Stop 提醒——那么它还有一个退出码无法检查的输出通道:文案写错、条件段落反了、heredoc 吞掉某个部分,退出码仍会恰好是 2,而每一行都能通过。请在 scripts/test_hook.sh 中添加 says <label> <event> <pattern> <yes|no> 行,断言两个测试样例中的两种情况(针对目标输入时出现,遇到相似但会跳过的输入时不出现)——单独一行 want=no 在钩子完全没有输出时也会空过,因此只有旁边有一行 want=yes 能证明钩子确实会输出时,它才有意义。请匹配固定字符串,不要用正则表达式:值得断言的短语往往包含方括号,而作为 BRE 的 [skill] 是一个字符类,能匹配任何包含 s、k、i 或 l 的文本。然后通过注入错误确认这些测试行确实会失败:复制钩子,注入每行声称要捕获的确切错误,并确认该行变红。在你亲眼看到测试因正确的原因而失败之前,全绿的测试套件不提供任何信息——曾有两个真实错误在一个全绿的 24 用例测试套件中幸存下来,因为每一行都只检查退出码(陷阱 #14)。

常见模式是:所有用例结果一致是危险信号,不是绿灯。 test_hook.sh 会从结构上捕获上面的第 1 和第 2 种情况——它要求每个 run 行都明确指定 expected-exit(而不是“有没有输出”),并强制同时包含触发用例和看起来相似但正常的用例,因此触发用例返回 0 时会明确失败,而不会被掩盖。它无法捕获第 3 种情况:某行的内容是否恰好落入豁免范围,取决于你写的内容,测试框架无法得知规则的意图。只有养成习惯才能避免这种情况——先断言一个已知会触发的用例;如果没有触发,先怀疑测试行,再怀疑钩子。

参考资料

  • references/hook_patterns.md — 本文涵盖的每种钩子类型的可运行骨架、shlex 命令位置遍历器,以及 JSON 事件约定。
  • references/hook_pitfalls.md — 所有真实故障模式及其症状 → 原因 → 修复方案。
  • scripts/test_hook.sh — 端到端测试框架;创建任何新钩子时,都将它复制到钩子旁边。
  • scripts/test_hook.group-name-guard.sh — 一个真实 Stop 守卫的完整测试框架实例:包含 Stop 事件形状,以及豁免与触发的行集合。将钩子路径作为 $1 传入(bash test_hook.group-name-guard.sh ~/scripts/claude-hooks/<hook>.sh);直接运行时会打印 HOOK not found: …/CHANGE-ME.sh。它不能作为本文件要求 Stop 守卫实现的两项内容的范例——其中没有 says 行,也没有 stop_hook_active 防循环行。有关这两项内容,请参考 scripts/test_hook.sh。

维护 — 新内容放在哪里

新的故障回溯补丁应放在本文件之外,归入已有的相应位置:新的陷阱或故障剖析 → references/hook_pitfalls.md;可复用的 骨架或模式 → references/hook_patterns.md; 完整的测试框架实例(测试脚本)→ scripts/。本文件只收录 约定级规则:所有阻断型钩子都会用到的内容(新的钩子 类型、变更后的退出码约定,或 ## Rules that separate a working guard from a session-poisoning one 系列中的新规则)。触发时加载的内容保持稳定,而知识库持续扩展;更深入的内容只需通过一个指针即可访问。

记录这一点的原因(2026-08-02):故障回溯补丁实际上一直都放在参考资料中——让本文件一周内从 10k→50k 字符增长的,是规则文本(规则 5–8 直接写在本文件中),而本策略有意将其保留在这里。本策略旨在明确默认做法,供下次处理新故障的会话使用,从而让未来的增长仅限于约定级规则。经四方面设计评审(成本 / SSOT / 架构 / 证据,并进行了交叉质询),最终选择了这一方案,而不是将当时已有的八条规则拆分到不同文件。下次有人提议拆分时,重新启动拆分的标准:有一项测量(而非凭感觉)表明主文件的大小会降低规则遵循度;或者整个技能的变更趋于稳定(连续 30 天没有任何新规则或故障回溯补丁落地)。

评论

支持 Markdown