上周写一个 Claude Code plugin,加了个 PreToolUse hook 想做工具调用前的合规校验。挂上去测了几次都正常,第二天打开会话写代码,敲完一条命令,光标就不动了。等了快一分钟,会话毫无反应,CPU 风扇先转起来了。
最后排出来是我自己亲手做的死循环:PreToolUse 阻塞 → 阻塞里跑了一个用 Bash 的子操作 → 那个 Bash 又触发了一次 PreToolUse → 套娃,套到栈爆或者卡到某个超时为止。改成 PreToolUse 里直接 deny + 把校验工作推到异步队列才彻底解决。
当时我看到的
会话不是立刻就卡的。第一次 hook 触发的时候看起来一切正常,hook 里的 Bash 命令也跑完了,工具调用也放行了。问题是连续几次工具调用之后,整个会话就停在那里。没有错误日志,没有超时提示,连 Ctrl+C 都要按好几下才反应。
ps -ef --forest 看了一眼,发现一堆 bash 进程层层嵌套,父子关系链拉得很长,最深的一支看起来有十几层。再看 pstree 输出,每一层都是同一个 hook 脚本路径在反复 fork 自己。那一刻我才意识到这不是某个命令慢,是 hook 在自己调用自己。
我以为是 timeout 配置不对
第一反应是 hook 超时设短了导致级联失败。Claude Code 的 hook 默认 timeout 是 60 秒,我把它调到 120 秒重试。结果更卡了。卡得更彻底,因为每一层嵌套都拿到了更长的等待窗口,递归还能多套几层才到边界。
第二个怀疑是 Bash 子进程没正确退出。我在 hook 脚本末尾加了 exit 0,强制清理 trap,把所有后台进程用 wait 收回,确认子进程确实退了。还是卡。
第三个怀疑是 hook 脚本本身有 bug,我把脚本剥成最小复现:hook 里只跑一个 echo hello | tee /tmp/hook.log。再试,还是卡。这条命令在终端里 1 毫秒能跑完,在 hook 里却把整个会话拖死。
到这里我才真的停下来想。把脚本剥到只剩一行 echo 还卡,说明问题不在我写了什么,而在我"在 hook 里跑了 Bash"这件事本身。换句话说,这条 Bash 命令本身又被 hook 拦了一次。
真相是 hook 在递归触发自己
我去翻 Claude Code 的 hook 文档,翻到 PreToolUse 的语义描述:每个工具调用之前都会触发,包括 hook 内部启动的工具调用。这件事在文档里只是平铺直叙地写了一句,夹在中间段落,没有任何"小心递归"之类的提醒。我读过一遍,但当时脑子里 hook 的模型还停留在 “Web 框架那种 middleware,过一次就走"的印象上,所以这句话扫过去了没接住。
我画了一下当时实际发生的事:
sequenceDiagram
participant U as 用户输入
participant CC as Claude Code
participant H as PreToolUse Hook
participant B as Bash 子进程
U->>CC: 触发工具调用 (Bash)
CC->>H: 拦截, 调用 hook
H->>B: hook 脚本里跑 echo / tee
B->>CC: 这是一次新的 Bash 调用
CC->>H: 又一次拦截
H->>B: 又一次跑脚本
Note over CC,H: 套娃, 直到栈/超时
每次 hook 想"做点小事记录一下”,这件小事本身就是 Bash 调用,又被自己拦下来。第一次同步等待时还能熬过去,工具调用堆起来之后,栈上躺着十几层互相等待的 hook,会话就不会再向前走了。
更隐蔽的是,这个 bug 在我自测时根本不出现。我自测一两条命令,递归层数不够深,Claude Code 内部某个 backpressure 兜底机制还能扛住,看起来一切正常。只有在真正写代码、连续触发十几次工具调用、每一层都还没释放就又叠新一层时才会暴露。这正是它最危险的地方:自测通过、上线卡死。
修复:在 hook 里返回 deny + 异步队列
我原本想在 hook 里直接做白名单校验,顺手记一条审计日志,挑出违规的就放行,违规的就拒。问题是"记一条审计日志"这件事本身。不管用 echo >>、用 curl 上报、用 sqlite3 写库,都要起子进程,都要被 hook 自己拦。
最后的方案是把 hook 里的工作砍到只剩"判断 + 返回":
{
"hooks": {
"PreToolUse": {
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/check.sh",
"timeout": 5
}
}
}
check.sh 里只做两件事:读一份本地静态规则,判断后输出 {"decision": "allow"} 或 {"decision": "deny", "reason": "..."}。全程不调用任何外部工具,不写日志,不发请求。
审计日志怎么办?写到一个本地 append-only 的队列文件里:
# check.sh, 不会触发 hook 自递归
QUEUE=/var/tmp/claude-hook-queue.ndjson
flock -n "$QUEUE" -c "printf '%s\n' \"$(jq -c -n --arg t \"$TOOL\" --arg a \"$ARGS\" '{ts:now, tool:\$t, args:\$a}')\" >> \"$QUEUE\""
echo '{"decision":"allow"}'
flock 加非阻塞锁是为了防止偶尔多进程并发写竞态。flock -n 拿不到锁就立刻返回非零,这种情况下我宁愿丢一条审计也不愿让 hook 阻塞,所以脚本里 flock 这一行是 || true 兜底。一个独立的 systemd timer 每 30 秒读一次这个队列、上报、清空。这个消费者不在 hook 链路上,跑 Bash、curl、sqlite 都不会再触发递归。
关键的设计原则只有一句:PreToolUse 是同步阻塞路径,它里面绝对不能再产生任何工具调用。能在 hook 里做的只有读静态文件、做纯计算、写 append-only 文件这三类不需要 fork 子进程的事。任何需要副作用的操作,都推到异步消费者去。
复盘:我学到的
第一,hook 文档里"会拦截工具调用"这句话,要把"包括 hook 内部产生的工具调用"补上自己脑子里。这是文档没说但应该警觉的隐含约束。
第二,所有"同步拦截 + 副作用"的设计都要先问一句:这个副作用本身会不会触发拦截?Web 框架的中间件、数据库的触发器、消息队列的 interceptor、Linux 的 LD_PRELOAD,本质上都是同一类陷阱。我以为 hook 只是个简单的 callback,意识到它其实是一个 reentrant 的拦截层之后,设计就清楚了。判断一个 hook 安不安全只要看一行代码:grep -E '(curl|sqlite3|jq|sed|awk)' check.sh 有没有命中,命中就是雷。
第三,自测的通过率是个陷阱信号。一两次跑得过,十几次连续跑会卡死的 bug,只能在真实工作流里才显形。下次写 hook 我会主动构造一个"连续触发 50 次工具调用"的压测脚本,放到 CI 里,在 merge 前用 time 卡 P95 延迟和总耗时上限,任何一项超阈值就 fail——光看自测的"成功/失败"二元结果远远不够。
什么时候不要这么做
异步队列的方案有它自己的代价,不是任何场景都该用。我把它推到生产之前过了一遍我自己想得到的反例,主要是下面两类:
如果你的校验本来就需要一个真权威的同步答案——比如要查远端的权限服务才能判断是不是 deny——那么把校验逻辑挪到异步根本不解决问题,deny 决定本身就要等远端。这种情况下更合理的做法是把"远端权限"缓存到本地一份,hook 只读本地缓存,再单独跑一个进程定期刷新缓存。本质上还是把同步副作用推开,但形式不一样。
另外,审计日志推到异步意味着断电会丢一段窗口的日志。如果你做的是金融、合规这类对审计完整性要求很高的场景,我会额外加一层:hook 里直接 write(2) 到一个 fd(open 一次复用,不 fork),让落盘走系统调用而不是子进程,牺牲一点 hook 启动开销换审计的强一致。
最后还有一个我没完全想清楚的点:hook 触发的递归到底是 Claude Code 的设计取舍,还是一个值得在文档里标红的坑?如果是前者,那它在 plugin 生态里应该会反复咬到人,后续可能要靠社区约定一份"hook 里能干什么、不能干什么"的小手册;如果是后者,我想提个 issue 让文档加一句话——只要在 PreToolUse 那段补一行"hook 内部启动的工具调用同样会被本 hook 拦截",就能省下下一个写 plugin 的人一天的调试时间。