你写了个 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 放行) |
0 | permissionDecision: "allow" | Edit 执行,跳过常规 permission 弹窗 |
0 | permissionDecision: "deny" | Edit 被拦;显示 permissionDecisionReason |
0 | permissionDecision: "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 要调 python3、node、甚至 jq,而它们不在那个 PATH 上,脚本还没读 stdin 就挂了。截至 2026 年 6 月,这种挂退的不是 2,所以是非阻塞——意味着症状通常是「我的安全 hook 悄悄啥也没干」,而不是「全被拦了」。不管怎样都值得排查一下。
怎么判断:跑 claude --debug、触发一次 Edit,找 hook 报错那行。或者在干净 shell 里跑 which python3 jq(env -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_name、tool_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 要外调 python3 或 node,别信 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_name和tool_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 settings.json 不生效
- Claude Code permissions 弹窗循环
- Claude Code Skill 没被发现
- Claude Code 改错文件
- Claude Code 半执行后卡住
标签: #Claude Code #hooks #排查 #排查