Claude Code hook 莫名其妙拦下 Edit

PreToolUse hook 不停地拒掉 Edit、连安全的修改也拦。多半是 exit code 逻辑、stdin 解析、或者 matcher 范围出了问题。

你写了一个 PreToolUse hook 想拦敏感路径的 Edit,结果现在每次 Edit 都被拒——连改 README 这种无害操作都被拦。或者你在终端里手动跑这个脚本是 success,Claude Code 偏偏当成 block 处理。PreToolUse hook 是按 exit code 控制每次匹配的工具调用:0 放行、非 0 拒绝。Hook 通过 stdin 收到一个描述工具调用的 JSON,settings.json 里的 matcher pattern 决定它能看到哪些调用。多数「莫名 block」要么是 matcher 太宽、要么是 exit code 写错、要么是 stdin 解析失败装成了刻意拒绝。debug print 加一次手动测试,几分钟就能定位。

常见原因

按命中率从高到低。

1. Hook 脚本意外返回了非 0

常见模式:set -e 配合一个 fall-through 的 ifset -u 下用了未定义变量、或者最后一个命令在 happy path 也返回了 1。任何非 0 退出都会 block 工具调用。

怎么判断:手动给 hook 脚本喂一个示例 stdin payload 跑一次、然后 echo $?。success 分支看到不是 0 就是 bug。

2. Matcher pattern 比你以为的更宽

Matcher 写 Edit 会匹配所有 Edit 调用、不只是某个路径的。如果你的拒绝逻辑依赖路径检查、但脚本默认走 block,那就是全拒。

怎么判断:看 .claude/settings.json~/.claude/settings.json 里的 matcher 行。如果就是 "matcher": "Edit"、你本意是路径过滤,那 matcher 就太宽了。

3. Stdin JSON 解析静默失败

Hook 脚本通过 stdin 拿工具调用的 payload。如果你用 jq 解析、jq 报错了,你的脚本可能默认非 0 退出、看起来就像是刻意拒绝。

怎么判断:脚本开头加 jq . > /tmp/hook-input.json。触发一次工具调用、然后看这个文件。空的或非法的就是解析失败。

4. Hook 被覆盖或重复加载

如果项目 .claude/settings.json 和用户 ~/.claude/settings.json 都为 Edit 定义了 PreToolUse hook,两个都会跑。任何一个拒绝都会 block 这次调用。

怎么判断:在两个 settings 文件里搜同样 matcher 的 hook entry。先禁一个,看 block 是不是停了。

5. Stdout 的输出被当成了 deny 原因

某些 hook 版本会读 stdout 当 deny message。如果你不小心把 debug 信息打到了 stdout(不是 stderr),CLI 可能把它当成 block 原因显示出来。

怎么判断:看 Claude Code 打的 block message。里面如果包含你脚本里 log 的内容,就是 debug 输出漏到 stdout 了。

6. 全新 shell 下 hook 运行时缺失

如果 hook 用 python3node、但启动 shell 的 PATH 里没有它,hook 还没读 stdin 就挂了。Claude Code 把这种挂当成 block。

怎么判断:在 Claude Code 用的同一个 shell 里跑 which python3。空的就是这个。

开始前

  • 改 settings.json 之前先备份当前那份。
  • 准备好临时禁用这个 hook 做对照。
  • 用一次随手的 Edit(在 scratch 文件里改个注释)来测试。
  • 装好 jq 用来看 stdin payload。

需要收集的信息

  • Claude Code 版本:claude --version
  • Hook 脚本的完整内容和文件路径。
  • settings.json 里的 matcher 条目。
  • Block 发生时 Claude Code 打的 deny 信息。
  • 一份 stdin payload 示例(用下面 Step 3 的招捕获)。
  • Block 那一刻的 claude --debug 输出。

一步一步修复

Step 1:隔离复现

对一个 scratch 文件做最小化 Edit。确认 block 触发。记下 deny 信息原文。

Step 2:临时禁用 hook

在 settings.json 里把这个 hook 条目注释掉、重启 Claude Code。同样的 Edit 现在能过,就确认是 hook 的问题(不是 permissions 或别的配置)。

Step 3:捕获 stdin payload

Hook 脚本头部加一行 debug:

#!/usr/bin/env bash
tee /tmp/hook-input.json | jq . > /dev/null

启用 hook、触发一次 Edit,看 /tmp/hook-input.json。里面应该有工具名、路径、改动内容。

Step 4:用捕获的 payload 手动测脚本

cat /tmp/hook-input.json | bash ~/.claude/hooks/my-hook.sh
echo "exit: $?"

应该过的 payload 手动跑出来 exit 非 0,就锁定逻辑 bug 了。

Step 5:收紧 matcher

只想拦 secrets/ 下的 Edit,就在 matcher 里或者脚本最前面做路径过滤:

{
  "matcher": "Edit",
  "hooks": [
    { "type": "command", "command": "~/.claude/hooks/block-secrets.sh" }
  ]
}
#!/usr/bin/env bash
path=$(jq -r '.tool_input.file_path' < /dev/stdin)
case "$path" in
  */secrets/*) echo "blocked: path under secrets/" >&2; exit 2 ;;
  *) exit 0 ;;
esac

默认分支显式 exit 0 很关键。

Step 6:把 stdout 和 stderr 分开

Debug log 只往 stderr 打:

echo "checking $path" >&2

Stdout 留给官方的 deny-reason 通道(如果你的 CLI 版本用它)。

Step 7:固定 runtime

Hook 用了 python3 或 node 就用绝对路径:

#!/usr/bin/env bash
/usr/bin/env python3 ~/.claude/hooks/block.py

或者脚本头部 export 一个 PATH。

怎么验证修好了

  • 在非敏感文件上的安全 Edit 不再被拦、也不弹 prompt。
  • 故意对受保护路径下的文件做 Edit 会被拦、且 deny 信息符合预期。
  • Deny 信息就是你脚本打的、不是 Claude Code 通用提示。
  • 重启 Claude Code 后行为不回退。

长期预防

  • Success 分支永远显式 exit 0,别靠隐式退出。
  • 在 CI 里加一个小 harness,对每个 hook 脚本喂样例 payload 跑一遍。
  • Matcher 尽量收窄,路径过滤能在 matcher 里做就在 matcher 里做。
  • Debug 全部往 stderr 打、绝不往 stdout 打。
  • ~/.claude/hooks/ 和 settings.json 纳入版本控制,回归时方便 bisect。

容易踩的坑

  • set -e 当成显式 exit code 的替代品,它不是——一样可能漏个 1 出去。
  • 写一个匹配 * 的大 hook 然后里面 switch 工具名;小而专的 hook 更好调。
  • 忘了 hook 跑在一个全新的非交互 shell 里、PATH 很瘦。
  • print 打到 stdout 然后纳闷为什么 deny 信息变样了。
  • 用 root 手动跑 hook 脚本、错过了 user context 才暴露的权限 bug。

常见问答

  • 哪个 exit code 算放行? 0 放行,非 0 拦。
  • 能看到 Claude Code 给 hook 发了啥吗? 能,stdin 用 tee 落地一份就行。JSON payload 里有 tool_nametool_input
  • deny message 是哪里来的? 看 Claude Code 版本。有的读 stderr、有的读 stdout 上特定格式的 JSON 响应。看你那版的文档。
  • 能按 diff 内容判断、而不只是路径吗? 能。Payload 里有 proposed change,用 jq 解出来判断。
  • Hook 对每个工具都跑、还是只跑 match 的? 只跑 match matcher 的。matcher 越具体越省事。
  • 为什么 hook 在终端能跑、Claude Code 跑就挂? 几乎都是 PATH 或缺解释器。用绝对路径加干净的 shebang。

相关

标签: #Claude Code #hooks #排查 #排查