Claude Code hook 莫名其妙拦下 Edit

PreToolUse hook 不停拒掉 Edit、连安全修改也拦。根因几乎都是 exit code 2 的逻辑、deny JSON 格式、stdin 解析、或者 matcher 范围。

你写了个 PreToolUse hook 想拦敏感路径的 Edit,结果现在每次 Edit 都被拒——连给 README 改一个字这种无害操作都被拦下。又或者你在终端里手动跑这个脚本明明是 success,Claude Code 偏偏当成 block 处理。

最快的修法PreToolUse hook 只有两种方式能拦住一次工具调用,其中一种写得稍微不对,就是大多数问题的根源。要么 (a) 脚本以退出码 2 退出、并把原因写到 stderr;要么 (b) 脚本以 0 退出、在 stdout 上打一段 JSON,把 permissionDecision 设成 "deny"。除此之外的任何路径——jq 崩了、变量未定义、解释器缺失——退出码会是 1(或别的值),而截至 2026 年 6 月,这属于非阻塞错误:Claude Code 记一笔日志,照样放行这次 Edit。所以如果是安全的 Edit 被拒,那几乎可以肯定是你的脚本不该走 deny 分支时却走了(exit 2 或者打了一段 deny JSON)——而不是「崩进了 block」。手动拿一份捕获的 payload 跑脚本、打印 $?,就能看出走了哪个分支。

下面这篇按 symptom → cause → fix 把问题理清,并给你一个干净可用的 hook。这里的退出码与 JSON 规则对应官方 Claude Code hooks 文档

PreToolUse 到底怎么拦截(2026 年 6 月)

把下面这张表搞清楚,大半「莫名 block」就消失了。PreToolUse hook 通过 stdin 收到工具调用的 JSON,再用退出码加输出来表态:

退出码stdout JSON结果
0不表态——走正常 permission 流程(Edit 放行)
0permissionDecision: "allow"Edit 执行,跳过常规 permission 弹窗
0permissionDecision: "deny"Edit 被;显示 permissionDecisionReason
0permissionDecision: "ask"升级为交互式 permission 弹窗
2忽略Edit 被;stderr 文本作为原因回传给 Claude
1 或其他忽略非阻塞错误——记日志,但 Edit 照样放行

两个最容易绊倒人的结论:

  • hook 跑挂了,已经不会拦了。 别想当然「我的 jq 崩了,所以 Edit 被拦」——先看退出码。崩了是退 1,非阻塞。如果 Edit 真被拒了,那是你的脚本走到了显式 exit 2、或者打了 "deny" JSON。
  • exit 2 和 deny-JSON 是两条不同通道。exit 2 时,Claude 读 stderr 拿原因、忽略 stdout。走 permissionDecision: "deny" 时,你退 0,原因来自 stdout 上的 JSON。两者混着用(打了 JSON 却 exit 2,或者退 0 又往 stderr 写原因还不给 JSON)是「原因显示得不对」或者「根本拦不住」的常见来源。

stdin 的完整 payload 长这样(字段按文档):

{
  "session_id": "abc123",
  "transcript_path": "/home/you/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/you/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/home/you/my-project/README.md",
    "old_string": "...",
    "new_string": "..."
  }
}

常见原因

按命中率从高到低。

1. 安全的 Edit 也走进了 deny 分支

每次 Edit 都被拒时,这是最常见的根因。常见写法:某个 case/if 的默认分支退了 2 而不是 0;路径判断匹配范围比你想的宽(*secrets* 也会匹配上 my-secrets-guide.md);或者无条件地打了一段 deny-JSON。

怎么判断:拿一份已知安全的 payload 手动跑脚本(见下面 Step 4)、echo $?。看到 2、或者一个本该放行的文件 stdout 里出现 "permissionDecision": "deny",那个分支就是 bug。

2. Matcher pattern 比你以为的更宽

Matcher 写 Edit 会匹配所有 Edit 调用、不只是某个路径——matcher 只过滤 tool_name,从不过滤文件路径。如果你的拒绝逻辑依赖路径检查、但脚本默认走 deny,那就全拒了。还要注意:截至 2026 年 6 月,matcher 写 "*"可靠触发,要匹配所有工具请用 ""(空字符串),或者显式列名字比如 "Edit|Write"

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

3. Stdin JSON 解析失败,而你的默认值是 deny

Hook 脚本从 stdin 拿 payload。如果 jq 报错(字段路径写错、输入为空),而你的错误处理 fall-through 到了 exit 2 或一段 deny JSON,看起来就像刻意拦截。(jq 单独失败本身退 1——非阻塞——所以只有当你自己的逻辑把这个失败变成 deny 时才会拦。)

怎么判断:脚本头部加 tee /tmp/hook-input.json。触发一次 Edit,再看这个文件。如果是空的、或者你读的字段(.tool_input.file_path)是 null,问题就在解析。

4. Hook 被覆盖或重复加载

如果项目 .claude/settings.json 和用户 ~/.claude/settings.json(还可能有 .claude/settings.local.json 或某个 plugin 的 hooks.json)都为 Edit 定义了 PreToolUse hook,它们会全部执行。任何一个返回 block 都会拦下这次调用。

怎么判断:在 Claude Code 里跑 /hooks,它会列出每个已配置的 hook 以及来源。先禁掉一个来源,看 block 是不是停了。

5. stdout 和 stderr 接错了线

如果你打算用 exit 2 拒绝,原因必须走 stderr。如果你 exit 2 的同时不小心把 debug 文本或 JSON 打到了 stdout,Claude 会忽略它,你拿到的就是一个通用或看着不对的原因。反过来,如果你打算用 JSON 拒绝,那段 JSON 必须是 stdout 上唯一的东西、并且必须 exit 0

怎么判断:看 Claude Code 打出来的 block 信息。里面如果混着零散的 debug 文本,就是你把它漏到了 CLI 要读的那条通道上。

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

Hook 跑在一个非交互 shell 里、PATH 很瘦。如果 hook 要调 python3node、甚至 jq,而它们不在那个 PATH 上,脚本还没读 stdin 就挂了。截至 2026 年 6 月,这种挂退的不是 2,所以是非阻塞——意味着症状通常是「我的安全 hook 悄悄啥也没干」,而不是「全被拦了」。不管怎样都值得排查一下。

怎么判断:跑 claude --debug、触发一次 Edit,找 hook 报错那行。或者在干净 shell 里跑 which python3 jqenv -i bash -c 'which python3 jq')。输出为空就指向这里。

开始前

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

需要收集的信息

  • Claude Code 版本:claude --version(hook 行为在不同版本间有变化,先固定你这版)。
  • Hook 脚本的完整内容和文件路径。
  • Matcher 条目,以及它在哪个 settings 文件里(用 /hooks 看)。
  • 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 或别的配置)。再用 /hooks 确认它确实没了。

Step 3:捕获 stdin payload

在 hook 脚本头部接一个 debug 旁路——tee 把 stdin 复制到文件、同时继续往下传:

#!/usr/bin/env bash
tee /tmp/hook-input.json | jq . > /dev/null 2>&1
exec < /tmp/hook-input.json   # 倒带,让后面的脚本还能读 stdin

启用 hook、触发一次 Edit,再看 /tmp/hook-input.json。里面应该有 tool_nametool_input.file_path 和改动内容。

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

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

对照上面那张表读结果。本该放行的 payload 跑出 exit: 2(或 stdout 出现 "deny" JSON),就锁定逻辑 bug 了。如果是 exit: 1,说明脚本崩了——那是非阻塞的,真正的 block 来自别的 hook(回头重查 Step 4 / /hooks)。

Step 5:收紧 matcher 和脚本

只匹配你在意的工具,路径过滤放进脚本里做。下面这个例子拦掉任意 secrets/ 目录下的 Edit、其余显式放行:

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

默认分支显式 exit 0 是全部的关键——少了它,一个未定义变量或者 set -e 的疏漏就可能把你留在非 0 路径上。(// empty 能在字段缺失时防止 jq 吐出字面量字符串 null。)

如果你更想用结构化 JSON 通道、而不是 exit 2,那就返回这段并 exit 0

jq -n '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: "blocked: path under secrets/"
  }
}'
exit 0

有一个坑值得知道:一份未结案的报告(issue #37210)提到某些版本里,permissionDecision: "deny" JSON 对 Edit 工具会被忽略。如果你的 deny-JSON 拦不住 Edit,就退回到 exit 2 + stderr 这种写法,它目前是拦 Edit 更可靠的路径。

Step 6:让 stdout 和 stderr 各走各的通道

exit 2 这条路时,原因和所有 debug 只往 stderr 打:

echo "checking $path" >&2

stdout 保持干净。走 JSON 那条路时,deny JSON 必须是 stdout 上唯一的东西、并且必须 exit 0。两者绝不能混。

Step 7:固定 runtime

如果 hook 要外调 python3node,别信 PATH。用绝对解释器路径,或者在脚本头部 export 一个 PATH:

#!/usr/bin/env bash
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
/usr/bin/python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/block.py"

$CLAUDE_PROJECT_DIR 会注入每个 hook 进程,所以不管在哪个工作目录,都能可靠地引用项目内相对路径的脚本。

怎么确认修好了

  • 在非敏感文件上的安全 Edit 顺利通过、不再多弹 prompt。
  • 故意对受保护路径下的文件做 Edit 会被拦、且显示的是的那条原文信息——不是 Claude Code 的通用提示。
  • claude --debug 显示两次 Edit hook 都触发了,且每次的决定都符合预期。
  • 重启 Claude Code(并重新打开项目)后行为不回退。

长期预防

  • Success 分支永远显式 exit 0,别靠隐式退出状态。
  • 记住规则:只有 exit 2(或一段 "deny" JSON)会拦;其余任何非 0 都是非阻塞错误。别拿 exit 1 去拦。
  • 在 CI 里加一个小 harness,对每个 hook 喂样例 payload 跑一遍、断言退出码。
  • Matcher 尽量收窄(用 Edit|Write、别用 "*"),路径过滤在脚本里做。
  • exit 2 的原因走 stderr;deny-JSON 单独占 stdout。别混通道。
  • ~/.claude/hooks/ 和你的 settings 文件纳入版本控制,回归时方便 bisect。

容易踩的坑

  • set -e 当成显式退出码的替代品——你照样可能 fall-through 到一个非 2 的非 0 状态。
  • 想当然以为 hook 崩了就会拦下 Edit。截至 2026 年 6 月并不会,它只记日志然后继续。
  • "matcher": "*",它可能不触发——改用 "" 或显式名字。
  • 写一个匹配一切的大 hook、里面再按工具名 switch;小而专的 hook 好调太多。
  • 忘了 hook 跑在一个全新的非交互 shell 里、PATH 很瘦。
  • 用 root 手动跑 hook,错过了只在真实 user context 才暴露的权限 bug。

常见问答

  • 哪个退出码放行、哪个拦截? 0 放行(除非你 stdout 的 JSON 说 deny)。只有 2 通过 stderr 拦。1 或别的码是非阻塞错误——Edit 照样执行。
  • 我的 hook 崩了,但 Edit 还是过了,为啥? 截至 2026 年 6 月这是预期行为。崩了退 1,非阻塞。要真拦,就 exit 2、或者打 permissionDecision: "deny" JSON 并 exit 0
  • 能看到 Claude Code 给 hook 发了啥吗? 能——tee 把 stdin 落地一份(Step 3)。payload 里有 tool_nametool_input(Edit 的话是 tool_input.file_path)。
  • deny 信息从哪来?exit 2 就来自 stderr;你 exit 0 并给 deny 决定,就来自 stdout JSON 里的 permissionDecisionReason。这是两条独立通道。
  • 我的 deny-JSON 偏偏对 Edit 不生效。 某些版本的已知行为(issue #37210)。Edit 用 exit 2 + stderr 这种写法,拦得稳。
  • 怎么找出所有可能在拦的 hook? 在 Claude Code 里跑 /hooks。它会列出每个已配置 hook 及其来源文件,你就能发现项目、用户、local、plugin 各处的重复定义。
  • 为什么 hook 在终端能跑、Claude Code 跑就挂? 几乎都是非交互 shell 里 PATH 或缺解释器。用绝对路径、export PATH,再用 claude --debug 确认。

相关

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