安全 hook 为什么会「拦不住」
你给 Claude Code 配了 PreToolUse hook,本意是:写 .env、执行危险 shell、外联未知地址时先过一遍检查。脚本写好了,平时也管用。直到某一天脚本依赖缺了、jq 解析失败、或者超时被掐断——操作仍然过了。
这不是偶发 bug,而是默认策略:除了明确的拦截信号,多数失败会被当成「非阻断错误」,动作继续执行。安全场景里,这叫 fail-open:守卫倒下,门还开着。
PreToolUse / PostToolUse 各自管什么
用白话说:
- PreToolUse:工具调用之前跑。可以拦截——适合「禁止写密钥文件」「禁止某些 Bash」「禁止某类网络请求」。
- PostToolUse:工具调用已经成功之后跑。不能撤销刚才那一步,最多把警告塞给 Claude——适合审计、格式化提醒,不适合当最后一道闸。
拦截策略应写在 PreToolUse(或能在动作发生前否决的事件)上。PostToolUse 再严,也挡不住已经跑完的命令。
退出码:哪一种才算「拦住了」
Claude Code 看 hook 进程的退出码大致是这样:
- 退出 0:成功。若 stdout 里有合法 JSON 决策,按 JSON 执行;没有就放行到正常权限流程。
- 退出 2:阻断错误。在 PreToolUse 上会阻止这次工具调用,stderr 会作为原因给到 Claude。
- 其他退出码(1、127、脚本崩溃、路径写错导致起不来):默认是非阻断错误——界面上可能出现 hook error 提示,但工具调用照样继续。
- 超时:命令/http 类 hook 到点被取消时,多数事件同样不会拦下动作。
所以「脚本报错了」≠「动作被拦了」。只有 exit 2(或合法 JSON 里明确 deny / block),才是真正的拦截。若你希望连脚本崩溃、超时、文件丢失也一律禁止动作,需要在 hook 配置里加上 onFailure: "block"(需较新的 Claude Code 版本支持该字段)。
准备清单
- 一台常开、不休眠的 Mac mini,已安装 Claude Code,并用你自己的账号登录。
- 能用 SSH 或 UU 远程连接进入这台机器;终端里有 git、tmux、jq。
- 一个真实项目,准备把安全 hook 放进仓库的
.claude/目录。
步骤一:写一个「失败就 exit 2」的检查脚本
在项目里建 .claude/hooks/deny-secrets.sh:
``bash #!/usr/bin/env bash set -euo pipefail input=$(cat) # 解析失败也要拦截,不要落到「非阻断」 path=$(printf '%s' "$input" | jq -er '.tool_input.file_path // empty' 2>/dev/null) || { echo "hook: invalid JSON input" >&2 exit 2 } case "$path" in *.env|*/.env|*/secrets/*|*/credentials*) echo "Blocked: writing secret-like path: $path" >&2 exit 2 ;; esac exit 0 ``
要点:
set -euo pipefail:中间任一步失败就退出,避免「半截成功却 exit 0」。jq失败时主动exit 2,而不是让 shell 以 1 结束(1 默认不拦)。- 命中规则时写清 stderr,再
exit 2。
chmod +x .claude/hooks/deny-secrets.sh。
步骤二:在 settings.json 里挂上,并打开失败即拦截
在项目 .claude/settings.json(可进 git,团队共享)里:
``json { "hooks": { "PreToolUse": [ { "matcher": "Write|Edit", "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/deny-secrets.sh", "args": [], "timeout": 10, "onFailure": "block" } ] } ] } } ``
matcher:只在写/改文件时触发,减少无关噪音。args: []:用 exec 形式启动,路径占位符更稳。onFailure: "block":脚本不存在、崩溃、超时、非法输出时,同样阻止工具调用——这才是安全 hook 要的 fail-closed。- 没有
onFailure时,缺文件、超时往往只是提示一下,危险操作仍会放行。
网络类、密钥扫描类 hook 同理:真正拒绝用 exit 2 或 JSON permissionDecision: "deny";配置层再加 onFailure: "block",防止守卫自己挂掉。
步骤三:用包装脚本兜住「崩溃 ≠ 放行」
有时业务检查逻辑很长,你不想每个分支都记得 exit 2。可以套一层:
``bash #!/usr/bin/env bash set -euo pipefail ROOT="${CLAUDE_PROJECT_DIR:?}" if ! "$ROOT/.claude/hooks/check-inner.sh"; then echo "security hook failed; blocking" >&2 exit 2 fi ``
内层随便怎么写;外层保证:非零就变成 exit 2。再配合 onFailure: "block",启动失败也不会漏网。
步骤四:把 hook 放进 git,在常开 Mac mini 上统一跑
笔记本合盖、换网、系统更新,都容易让「以为开着的守卫」悄悄失效。更稳妥的做法:
- 把
.claude/hooks/和.claude/settings.json提交到项目仓库(密钥仍走环境变量,不要写进仓库)。 - 在常开的新加坡 Mac mini 上
git clone/git pull,日常只在这台机器上开 Claude Code 长会话。 - 用 SSH 或 UU 远程连接从任意设备进入同一台机器,守卫脚本只有一份,不会「这台改了那台忘了」。
可选:个人全局规则放在 ~/.claude/settings.json,项目规则放在仓库里;两套都要自检「失败是否拦截」。
步骤五:在 tmux 里做一次真实拦截测试
``bash tmux new -s hooks-test cd /path/to/your/project claude ``
然后依次验证:
- 让 Claude 写一个普通源码文件——应正常通过。
- 让它创建或覆盖
.env——应被拦,transcript 里能看到 hook 的 stderr。 - 故意搞坏守卫:临时
chmod -x脚本,或改错路径,再触发写文件。若已设onFailure: "block",应同样被拦;若没设,你会看到「非阻断」却仍写入——这就是 fail-open 的现场演示。 - 测完立刻恢复可执行权限和正确路径,再确认正常拦截回来。
从子目录启动 Claude Code 时,项目级 .claude/settings.json 有时不会被加载。习惯在仓库根目录启动,或把关键守卫放到用户级 settings,并定期用 /hooks 确认当前会话真的注册了这些 hook。
为什么放在一台常开的新加坡整机上
安全 hook 的价值在于每一次工具调用都过同一道闸。机器休眠、会话断线、多台电脑各装一份半成品脚本,都会把 fail-closed 打回 fail-open。一台 7×24 在线的 Mac mini,tmux 里挂着长会话,hook 目录跟代码一起 git pull,你用 SSH 或 UU 远程连接进去,看到的就是同一套守卫。
Zumac.Ai 提供的就是这样的环境:位于新加坡家庭宽带上的实体 Mac mini M6,整机独享(一人一台物理机),支持 SSH 和 UU 远程连接,FileVault 全盘加密,退租时抹机重装。新客户整机租用 ¥999/月起,季付、半年付、年付有折扣。实际延迟取决于你本地的网络线路,建议先租一个月,把自己的 hook 仓库和拦截测试跑通。
小结
- PreToolUse 才能拦住工具;PostToolUse 只能事后提醒。
- 默认失败是 fail-open:崩溃、超时、exit 1 往往不拦动作。
- 明确拦截用 exit 2(或 JSON deny);安全 hook 再加
onFailure: "block"。 - 用外层包装脚本把「任何非零」收成 exit 2;脚本进 git,在常开 Mac mini 上用 tmux 实测。
