退出码 2 才是门:hook 阻断信号的按事件契约
上周同事让我看他的删除拦截脚本。逻辑写得很细,检测到递归删除就 exit 1,在他的认知里这就是“失败了,别继续”。而 Claude Code 的文档把话说得没有余地:对大多数 hook 事件来说,退出码 2 是唯一靠退出码本身就能阻断动作的码,没有合法 JSON 的时候,exit 1 被当成非阻断错误,动作照常执行,尽管 1 才是 Unix 里传统的失败码。[1] 那次审查的结论是:脚本没写错,信号选错了。
退出码只有三种表态#
退出码 0 是成功,也是你打算用 JSON 做结构化控制时该用的码。JSON 不是只在 0 的时候才被读,Claude Code 在每个退出码上都会读 stdout 里的 JSON 字段;真正独占的只有退出码 2 的阻断效果,它是唯一一件 JSON 翻不了盘的事。[1]
退出码 2 是阻断错误。在能阻断的事件上,它不管你有没有打 JSON 都拦;就算你同时打一份 permissionDecision 是 "allow" 的 JSON,也还是拦。[1]
其余退出码在多数事件上不靠自身阻断,结果全看 stdout。打出一个通过校验的 JSON 对象,事件支持的决策字段会被采纳,hook 也不记为报错;stdout 是别的内容或者干脆为空,就落进非阻断错误,动作继续。[1]
两条路线是互斥的,要么只用退出码表态,要么 exit 0 加 JSON。真混在一起,2 的阻断效果不会被 JSON 取消,JSON 字段也照样会被读,可你要在脑子里同时维护两套结论,迟早有一处对不上。[1]
有些事件天生拦不住#
能不能用退出码 2 阻断,是逐事件定义的。[1] PreToolUse 可以,UserPromptSubmit 可以,而且它会把整条提示连同处理一起作废;Stop、SubagentStop、TaskCompleted、PreCompact、PreModelSwitch、WorktreeCreate 也各自有一套阻断语义。[1]
反例更值得背下来。PostToolUse 拦不住,工具已经跑完了,这里的退出码 2 只是把 stderr 送进 Claude 的上下文。[1] 这条的镜像更容易吃亏:PostToolUse 在退出码 0 时的 stderr 只进调试日志,Claude 永远看不到。[1] 想让它执行完给模型留一句警告,就得故意“失败”一次,否则那行提示只写给了自己。
PermissionRequest 的语义更绕。事件表里写着这个事件不认退出码 2,权限流程原样往下走,要拒绝得用 decision 对象。[1] 同一页还有一句补充:退出码 2 又不带 decision,权限流程不变,stderr 会被直接丢掉,只有 decision 能批准或拒绝。[1] 也就是说,在权限弹窗这一层,退出码是完全无效的表态方式。
顺序也值得说清。PreToolUse 在每次工具调用前运行(EndConversation 例外),它的输出可以拒绝调用、强制弹窗,或者跳过弹窗直接放行。[2] 但它批不下所有东西:指向关键路径的 rm、rmdir 仍然要走正常权限流程。[2]
三种静默失效#
拦截器的失败方式不是报错,是沉默。
脚本启动不了最阴。路径写错或者忘了 chmod +x,shell 会以 127 这类码退出,落进同一个非阻断桶,转录里只留一行提示;文档还补了一句让人后背发凉的结论:settings.json 里打错的路径会让这道门静默禁用。[1] 门槛废掉的当天,没人会来通知你。
超时同样等于放行。命令型 hook 到了 timeout 会被取消、输出被丢弃;PreToolUse 上被取消的命令 hook 不阻断调用,调用继续走正常权限流程。[1] 别把“卡住的 hook”当成兜底的闸门。
第三种是事件选错,把拦截逻辑挂在拦不住的事件上。这类脚本跑起来一切正常,只是什么都拦不住。
快速上手:给 hook 配一台本地自检台#
前两种失效都能在本地离线测出来。自检台做三件事:确认每个声明出来的 hook 指向的脚本存在且可执行;把 fixture 喂进去,断言退出码和预期一致;对会打 JSON 的脚本再断言 stdout 从头到尾是一个合法 JSON。第三条抓的是 set -x、调试 echo 这类噪声,它们会让 JSON 解析失败并降级成非阻断。
mkdir -p .claude/hooks/fixtures
scripts/hooks_selftest.py:
#!/usr/bin/env python3
"""本地验证 .claude/settings.json 里的 hook 能否按预期阻断。"""
import json, os, re, shlex, subprocess, sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BLOCKABLE = {"PreToolUse", "UserPromptSubmit", "Stop", "SubagentStop", "TaskCompleted",
"PreCompact", "PreModelSwitch", "WorktreeCreate"}
FX = ROOT / ".claude/hooks/fixtures"
def handlers(doc):
for event, groups in (doc.get("hooks") or {}).items():
for group in groups:
for h in group.get("hooks", []):
if h.get("type") == "command":
yield event, h.get("command", "")
def main():
problems = []
for name in ("settings.json", "settings.local.json"):
path = ROOT / ".claude" / name
if not path.exists():
continue
for event, command in handlers(json.loads(path.read_text())):
argv = shlex.split(os.path.expandvars(command))
if not Path(argv[0]).is_file() or not os.access(argv[0], os.X_OK):
problems.append(f"{name}:{event} 脚本不可执行,会以 127 静默放行 -> {argv[0]}")
continue
print(f"[{event}] {argv[0]} 可直接阻断={'是' if event in BLOCKABLE else '否'}")
for f in sorted(FX.glob(f"{event}-*.json")):
payload = f.read_bytes()
exp = int(re.search(r"-expect(\d+)\.json$", f.name).group(1))
r = subprocess.run(argv, input=payload, capture_output=True, timeout=15)
if r.returncode != exp:
problems.append(f"{f.name}: 期望退出码 {exp},实际 {r.returncode}")
if r.returncode == 2 and not r.stderr.strip():
problems.append(f"{f.name}: 拦了但 stderr 是空的,模型拿不到原因")
out = r.stdout.strip()
if out.startswith(b"{"):
if not out.endswith(b"}"):
problems.append(f"{f.name}: stdout 以 {{ 开头却没有以 }} 结尾,会被当成纯文本")
else:
try:
json.loads(out)
except json.JSONDecodeError as e:
problems.append(f"{f.name}: stdout 不是单个合法 JSON: {e}")
print("\n".join(problems) if problems else "OK: 所有声明的 hook 都通过了断言")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
fixture 的预期退出码写在文件名里,命中用例与不命中用例各一份,内容就是 hook 从 stdin 会收到的那份 JSON:
printf '%s' '{"tool_input":{"command":"rm -rf build"}}' \
> .claude/hooks/fixtures/PreToolUse-rm-rf-expect2.json
printf '%s' '{"tool_input":{"command":"npm test"}}' \
> .claude/hooks/fixtures/PreToolUse-npm-test-expect0.json
python3 scripts/hooks_selftest.py
输出会逐条列出事件、脚本、能不能直接阻断,最后给一行结论。改策略先跑它。它跑绿只说明脚本本身没问题,hook 有没有被真的触发是另一回事,那部分还得靠 /hooks 和调试日志。[3]
一次批准,写到哪个文件#
让权限弹窗被 hook 自动放行几次之后,就该考虑把哪些批准固化下来。updatedPermissions 的每条记录都带 destination,它决定改动落在哪里:session 只在内存里,会话结束就丢;localSettings 写 .claude/settings.local.json;projectSettings 写 .claude/settings.json;userSettings 写 ~/.claude/settings.json。[1] 默认挑前两个,别顺手把一次临时批准升格成全局规则。
装 hook 的成本太低,低到你会以为写完就生效了。真正花时间的是后面这半段:信号选得对不对、事件有没有阻断能力、脚本还在不在原来的位置,这三件事都得有证据,不然门开着你也以为它关着。
Sources#
[1] https://docs.anthropic.com/en/docs/claude-code/hooks — Hooks reference - Claude Code Docs
> “For most hook events, exit code 2 is the only exit code that blocks through the code alone.”
> “Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2.”
> “Claude Code reads JSON output fields from stdout on every exit code, not just 0, and for events that use the standard decision model, a parsed object that passes schema validation takes effect alongside the code. Exit 2’s block is the one outcome JSON can’t override.”
> “When the script path doesn’t exist or isn’t executable, the shell exits with a code like 127 and you see the same notice with the interpreter’s message”
> “A timed-out command, http, or mcp_tool hook doesn’t block the tool call. The call continues through the normal permission flow, so don’t count on a stalled hook to act as a gate.”
> “Stderr from a hook that exits 0 goes to the debug log only, never the transcript, and Claude never sees it.”
> “Exit code 2 isn’t honored for this event and the permission flow proceeds unchanged. Deny through the decision object instead”
> “A hook that exits 2 without a decision object leaves the permission flow unchanged, and its stderr is discarded. Only the decision object can grant or deny the request.”
> “Shows stderr to Claude; the tool already ran”
> “Choose one approach per hook: either use exit codes alone for signaling, or exit 0 and print JSON for structured control. If you mix them, exit 2 keeps its blocking effect, and Claude Code still reads the JSON fields”
> “Blocks prompt processing and erases the prompt”
> “in-memory only, discarded when the session ends”
> “a mistyped path in settings.json leaves the gate silently disabled”
[2] https://docs.anthropic.com/en/docs/claude-code/permissions — Configure permissions - Claude Code Docs
> “When Claude Code makes a tool call, PreToolUse hooks run before the permission prompt, for every tool except EndConversation. The hook output can deny the tool call, force a prompt, or skip the prompt to let the call proceed.”
> “rm or rmdir commands that target a critical path still go through the regular permission flow”
[3] https://docs.anthropic.com/en/docs/claude-code/hooks-guide — Automate actions with hooks - Claude Code Docs
> “Use exit 2 to block with a stderr message, or exit 0 with JSON for structured control. Choose one approach per hook.”
> “Hooks are user-defined shell commands. Claude Code runs them at specific points in its lifecycle, which gives you deterministic control: certain actions always happen rather than relying on the LLM to choose to run them.”