Claude Code Hook 配置、事件与退出码 2
了解 Claude Code Hook 的配置位置、31 个事件及版本 2.1.232 的字段变化。退出码 2 会在工具调用前取消执行,stderr 内容将作为原因返回模型。
Claude Code hook 是什么
Claude Code hook 是 Claude Code 在自身生命周期的固定阶段自动运行的 shell 命令。这就是 hook 与规则文件的全部区别。CLAUDE.md 中的指令属于建议,模型会结合上下文中的其他信息进行权衡。hook 是代码,无论模型是否同意,都会运行。如果您的代理一直跳过您已经提醒过两次的格式化程序,就不需要更强硬的指令,而需要一个 hook。
这个机制很简单。您可以在设置文件中按事件名称注册命令。事件触发后,Claude Code 会将事件数据以 JSON(JavaScript 对象表示法)格式写入该命令的标准输入(stdin)。命令读取这些数据,执行相应操作,然后返回退出状态。PreToolUse hook 返回退出状态 2 时,会在工具调用执行前取消该调用;脚本写入标准错误(stderr)的内容会作为原因返回给模型。
本文中的事件名称和字段名称来自 Claude Code 的 hook 参考文档,并已在 2026 年 8 月根据版本 2.1.232 进行核对。该接口变化很快,因此在复制任何博客文章(包括本文)中的 JSON 之前,请先查看与您所用版本对应的参考文档。使用 claude --version 打印您当前使用的版本。
Hook 配置所在位置
Hook 是设置文件中的 JSON 块。以下 6 个位置可以存放 Hook,其作用域取决于所在文件的作用域。
~/.claude/settings.json:对本机上的所有项目生效,不影响其他人的环境。.claude/settings.json:仅对一个项目生效,并提交到代码仓库,因此克隆该项目的所有人都会获得此 Hook。.claude/settings.local.json:仅对一个项目生效,只在本机使用。- 托管策略设置:在组织范围内生效,由管理员配置。
- 插件中的
hooks/hooks.json:仅在插件启用期间生效。 - 技能或子代理的 frontmatter:仅在相应组件处于活动状态期间生效。
这些文件中的 Hook 条目会合并,而不是相互覆盖。项目设置文件会将其中的 Hook 添加到用户设置中的 Hook,而不是替换它们。因此,一个事件可以包含来自多个文件的多个 Hook。设置 "disableAllHooks": true 可将这些 Hook 停用,但有一个例外:托管策略设置中的 Hook 会继续运行,除非同时在托管设置中应用该设置。
在会话中运行 /hooks,可列出当前注册的所有 Hook,并按事件分组,同时显示每个 Hook 的源文件和匹配器。该菜单为只读,必须编辑设置文件来修改 Hook。文件监视器通常会自动检测更改,无需重启。
Claude Code 中有哪些 hook 事件
2.1.232 版本列出了 31 个事件,从 SessionStart 到 SessionEnd,涵盖压缩、子代理、worktree 和配置文件。服务器运维通常只会用到其中几个。
PreToolUse:工具调用执行前触发。这是唯一可以阻止调用的事件。PostToolUse:工具调用成功后触发。调用失败时则触发PostToolUseFailure;需要处理所有结果的 hook 必须同时配置这两个事件。PermissionRequest:工具调用需要进行权限决策时触发,也就是即将显示批准提示的时刻。UserPromptSubmit:提交提示时、Claude 处理提示前触发。此 hook 输出到 stdout 的内容会添加到模型上下文中。SessionStart和SessionEnd:分别在会话结束时触发。压缩完成后也会触发SessionStart,此时 matcher 值为compact。Stop:Claude 完成响应时触发。每轮只触发一次,不是每个完成的任务触发一次。
每组配置都有一个 matcher,用于决定哪些事件会运行 hook。对于工具事件,它按工具名称进行筛选,因此 "Edit|Write" 只会在文件编辑时触发,不会在其他情况下触发。Matcher 区分大小写。Matcher 为空时,每次事件都会触发。来自 MCP(model context protocol)服务器的工具名称格式为 mcp__<server>__<tool>,因此使用 "mcp__github__.*" 作为 matcher 可以只匹配某个服务器的工具,不影响其他服务器。
Stop hook 有一个需要提前了解的限制。阻止操作的 Stop hook 会让模型重新执行任务;连续阻止 8 次后,Claude Code 会覆盖该 hook。请从 hook 输入中读取 stop_hook_active 字段,并在其值为 true 时退出 0,否则 hook 会持续循环,直到达到该上限。
钩子从标准输入接收的内容
Claude 即将运行 npm test 时,Bash 上的 PreToolUse 钩子会从标准输入读取以下内容:
{
"session_id": "abc123",
"cwd": "/home/deploy/myproject",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}每个事件都包含 session_id、cwd、permission_mode、transcript_path 和 hook_event_name。工具事件还会添加 tool_name、tool_input 和 tool_use_id。其他事件包含各自的字段:UserPromptSubmit 获取 prompt 文本,SessionStart 获取由 startup、resume、clear、compact 或 fork 组成的 source。
在 shell 脚本中,通常使用 jq 读取这些内容;精简的服务器镜像中没有该工具。请先在 Ubuntu 和 Debian 上使用 sudo apt install -y jq 安装它。
退出状态如何影响正在执行的工具调用
共有 3 种结果。
- Exit 0 表示 hook 未提出异议。对于
PreToolUse,这不等同于批准,正常的权限流程仍会执行。对于UserPromptSubmit和SessionStart,stdout 会添加到模型的上下文中。 - Exit 2 会阻止可被阻止的事件,包括
PreToolUse;stderr 会作为原因显示给模型。对于无法阻止的事件(例如PostToolUse),阻止操作会被忽略,但 stderr 仍会作为反馈传递给模型。 - 任何其他退出代码 都表示非阻塞错误。操作会继续执行。对话记录会显示 hook 错误通知,其中包含 stderr 的第一行,且位于文本
Failed with non-blocking status code:之后。
如果需要执行阻止或保持静默之外的操作,请使用 exit 0,并将 JSON 对象输出到 stdout。PreToolUse hook 使用 permissionDecision 进行决策:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database drops go through a migration, not through the agent."
}
}"allow" 会跳过交互式提示,"deny" 会取消调用并将原因发送给模型,"ask" 会照常显示提示。每个 hook 应选择一种方式。在 stdout 中同时使用 exit 2 和 JSON 决策会产生需要额外查询才能确定的结果。
当多个 hook 与同一事件匹配时,它们会并行运行,并且每个 hook 都会运行至完成。一个 hook 返回 deny 不会停止其他 hook,因此日志 hook 仍会写入日志,而 guardrail hook 会拒绝同一个调用。Claude Code 随后会合并各个响应,并按 deny、defer、ask、allow 的顺序保留限制最严格的结果。
示例 1:在破坏性命令执行前阻止它
将以下内容保存为项目中的 .claude/hooks/block-destructive.sh:
#!/bin/bash
# Deny a Bash tool call whose command matches a banned pattern.
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
for pattern in 'rm -rf /' 'mkfs' 'dd if=' 'DROP TABLE'; do
if printf '%s' "$COMMAND" | grep -qiF -- "$pattern"; then
echo "Blocked by policy: the command matches '$pattern'. A human runs this one." >&2
exit 2
fi
done
exit 0为其添加可执行权限,然后在 .claude/settings.json 上将其注册到 PreToolUse:
chmod +x .claude/hooks/block-destructive.sh{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.sh",
"timeout": 10,
"statusMessage": "Checking the command against policy"
}
]
}
]
}
}在信任该脚本前,先手动测试。因为钩子如果因自身输入而崩溃,就会失效开放:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /var/lib/postgresql"}}' \
| .claude/hooks/block-destructive.sh
echo $?您应在 stderr 中看到 Blocked by policy: 行,并看到退出代码 2。向脚本传入无害命令,例如 ls -la,此时不应有任何输出,退出代码应为 0。在会话中,被拒绝的调用会出现在记录中,并将您的消息作为原因;模型会读取该消息并调整行为。
有一项特性使这项措施值得采用:PreToolUse 钩子会在权限模式检查之前触发,并且在所有权限模式下都如此。因此,即使处于 bypassPermissions,拒绝规则仍然有效。这也是钩子适合与 Claude Code 自动模式及其权限设置结合使用的原因:虽然提示确认次数减少了,但钩子仍会触发。
请明确这项措施的边界。针对命令字符串进行模式匹配,可以防止代理因疏忽执行命令,但无法防止代理有意绕过规则,因为同一命令可以改写为 grep 无法识别的形式。硬性规则应配置在权限系统中,并通过运行该进程的账户实施。
示例 2:每次编辑后格式化并进行代码检查
PostToolUse 配合 Edit|Write 匹配器,可在任何文件编辑工具运行后执行。将以下内容保存为 .claude/hooks/after-edit.sh:
#!/bin/bash
# Format the edited file, then report lint failures back to the model.
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0
case "$FILE" in
*.py)
ruff format "$FILE" >/dev/null 2>&1
if ! ruff check "$FILE" >&2; then
exit 2
fi
;;
*.sh)
if ! shellcheck "$FILE" >&2; then
exit 2
fi
;;
esac
exit 0{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/after-edit.sh",
"timeout": 60
}
]
}
]
}
}要求 Claude 向 Python 文件中添加一个缩进错误的函数,然后打开该文件。文件会以格式化后的状态返回。这就能确认钩子已运行,因为钩子成功时不会在对话中显示任何内容。
这里的 exit 2 不会撤销任何操作。PostToolUse 会在工具已经执行后触发,因此无论退出状态如何,编辑结果都已写入磁盘。exit 2 的作用是让 ruff check 输出作为反馈传递给模型,使模型修复刚刚引入的错误,而不是继续执行。这就是提交时才发现的代码检查失败与代理在同一轮中修复错误之间的区别。
这里有两个重要的匹配器限制。Edit|Write 无法看到 shell 命令修改的文件,而 Claude 经常通过 Bash 写入文件,因此这个缺口确实存在。若要覆盖每次调用,请同时匹配 Bash,并让脚本使用 git status --porcelain 列出已修改的文件。若要覆盖每轮对话一次,请改为将扫描放在 Stop 钩子中。
示例 3:记录每次工具调用以便审计
PostToolUse上的空匹配器会对每个工具调用触发。将记录发送到系统日志,而不是写入主目录中的文件,可以避免代理自身的 shell 访问这些记录:
{
"hooks": {
"PostToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "jq -c '{time: now|todate, session: .session_id, cwd: .cwd, tool: .tool_name, input: .tool_input}' | logger -t claude-code -p local0.info"
}
]
}
]
}
}使用 journalctl -t claude-code -o cat | tail -n 5 读取记录。您应看到每次工具调用对应一行 JSON,最新记录位于最后。没有任何输出表示钩子未运行;下面的故障排查部分介绍了相关处理方法。
在 PostToolUseFailure 下添加相同的代码块,以捕获失败的调用,因为 PostToolUse 仅在成功时触发,而失败的命令通常更值得关注。使用 logger 而不是追加到主目录中的文件,原因在于所有权:钩子以与代理 shell 相同的用户身份运行,因此该用户可以追加的内容,也可以截断。系统日志由 systemd-journald 以其自身账户写入。
钩子可以运行多长时间
The data behind this chart
[
{
"label": "command, http or mcp_tool hook",
"default_timeout_seconds": 600
},
{
"label": "agent hook",
"default_timeout_seconds": 60
},
{
"label": "prompt hook",
"default_timeout_seconds": 30
},
{
"label": "command hook on UserPromptSubmit",
"default_timeout_seconds": 30
},
{
"label": "command hook on MessageDisplay",
"default_timeout_seconds": 10
},
{
"label": "any hook on SessionEnd",
"default_timeout_seconds": 1.5
}
]命令钩子默认可运行 600 秒,即 10 分钟。某些事件会大幅缩短这一时间。SessionEnd 钩子共享 1.5 秒的总预算,因此会话结束时的清理操作必须快速完成。不过,在钩子上设置更长的 timeout 后,共享预算也会相应增加,最高为 60 秒。
达到超时时间的钩子会被取消,并且不会产生任何决策。对于 PreToolUse 防护机制,这意味着它不会阻止操作:工具调用会继续进入正常的权限处理流程。因此,防护脚本应尽量简短。对于无需等待的慢速任务(例如将日志发送到其他位置),设置 "async": true 后,钩子会在后台运行,不会阻塞工具调用。
Hooks、规则文件、skills 和 MCP 服务器
这四种机制都会改变 agent 的行为,因此经常被混淆。只有其中一种不会停留在建议层面。
规则文件(CLAUDE.md,或 .claude/rules/ 下的文件)是加载到模型上下文中的文本。它会影响行为,但不会强制执行任何内容。面对较长的对话、大型 diff 和新的用户请求时,其中的一行内容可能失效。这就是 agent 忽略你写下的指令 的常见原因。
skill 是一个包含指令和脚本的目录。模型认为相关时才会加载它。这个判断正是 skill 的作用所在,也是它的限制:仍然由模型决定是否使用它。Ponytail 会引导 agent 采用能够奏效的最小改动,就是一个例子。它能影响整个任务的处理方式,这是 hook 无法做到的,但只有模型选择加载它时才会生效。
MCP(model context protocol)服务器为模型提供可调用的新工具。它扩大了 agent 可以访问的范围,但不会让 agent 主动调用任何工具。MCP 服务器还是一个需要单独运行和维护的进程,这本身就是一项工作:参见 在 VPS 上运行 MCP 服务器。
在这四种机制中,只有 hook 不需要模型选择就会运行。对于偏好设置,使用规则文件;对于模型在适用时应遵循的流程,使用 skill。对于每次都必须执行的步骤,或绝对不能发生的事情,使用 hook。更深入的比较,包括 skill 何时优于规则文件,请参见 skills、MCP 和规则文件的比较。
plugin 是一种打包方式,不是第五种机制。它会将 hook 和 skill 打包成一个可安装单元。团队可以借此将相同的防护措施部署到每台机器上:参见 Claude Code plugin 的工作方式。
共享 VPS 上的安全决策
Hook 是由代理触发并执行的代码。它以启动 Claude Code 的用户身份运行,并继承该用户的环境和文件权限。在笔记本电脑上,这是工作流问题。在无人值守运行代理的 VPS 上,这是一个包含 4 个实际方面的安全问题。
仓库中的 hook 不是你编写的代码。 .claude/settings.json 会随仓库提交,因此克隆仓库并在其中启动会话,可能会注册仓库自带的 hook。Claude Code 会通过该文件夹的工作区信任对话框控制项目 hook,这意味着接受信任的时刻,就是你决定运行这些 hook 的时刻。先阅读 hooks 块。
Hook 可以看到完整的工具输入。 记录 tool_input 的审计 hook 会将每个命令的每个参数写入文件,其中可能包括命令行中出现的令牌。该日志随后需要与机密信息相同的保护措施。这也是 让机密信息远离 AI 代理可访问范围 这一更大问题的一部分。
Hook 可以将内容写入模型上下文。 SessionStart 或 UserPromptSubmit hook 输出到 stdout 的任何内容,都会添加到对话中。从外部来源、问题跟踪器或日志文件导入文本的 hook,会将不受信任的文本交给模型,就像这些内容是你亲自输入的一样。转发 同一 VPS 上另一个 Claude Code 会话的备注 的 hook 也是如此。一个代理的输出,并不比问题跟踪器的内容更值得信任。应将这些 stdout 内容视为输入,而不是输出。
权限才是真正的控制手段。 使用专用的非特权用户运行代理,并且只授予它所需的 sudo 规则。设置 PreToolUse deny 是有价值的,而且该机制本身就是尽力而为:参考文档对 if 过滤器也作了相同说明,并建议在需要强制拒绝时使用权限系统。权限规则以及进程运行所使用的账户,才是在高压情况下仍然有效的部分。
有一项属性在所有配置中都成立。PreToolUse hook 会在所有权限模式下、权限模式检查之前触发,因此返回 deny 的 hook 即使在 bypassPermissions 下也会阻止工具运行。Hook 可以收紧权限规则允许的操作范围,但不能放宽该范围。
我的 hook 为什么没有触发?
请按以下顺序排查。每一步都说明了你实际会看到的症状。
- 运行
/hooks,检查 hook 是否出现在预期的事件下。菜单中缺少 hook 通常表示设置文件存在 JSON 语法错误,因为不允许使用尾随逗号和注释;也可能表示文件不在上面列出的 6 个位置之一。 - 将匹配器与工具名称逐字比较。匹配器区分大小写,因此
"bash"永远不会匹配Bash工具。 - 按照上面示例 1 的方式,使用示例输入手动运行脚本。意外的退出码表示脚本存在错误。Claude Code 会将其报告为 hook 错误,而不是决策结果。
- 如果看到
jq: command not found,表示该机器缺少jq。对于你自己的脚本,如果看到command not found,表示路径解析失败,请使用${CLAUDE_PROJECT_DIR}或绝对路径。如果脚本完全没有运行,通常是因为它没有可执行权限。 - hook 输出了有效 JSON,但没有任何效果。Shell 形式的 hook 会通过
sh -c运行。如果 shell 配置文件输出启动横幅,该横幅会添加到 JSON 前面。标准输出不再以{开头,因此 Claude Code 会将整个输出作为纯文本读取,并忽略该决策。退出码为 0 时,除了调试日志外,其他位置都不会报告任何内容。请将配置文件中的任何echo包装起来,使其仅在交互式 shell 中运行。 - 仍然无法解决时,请使用
claude --debug-file /tmp/claude.log启动会话,并在第二个终端中运行tail -f /tmp/claude.log。调试日志会记录匹配的 hook、每个 hook 返回的退出码,以及它们写入标准输出和标准错误的全部内容。
FAQ
Claude Code hook 与 CLAUDE.md 指令有什么区别?
CLAUDE.md 指令是模型上下文中的文本,因此会与对话和当前请求争夺注意力,模型可以权衡它们之间的优先级。hook 是 Claude Code 在生命周期固定阶段运行的 shell 命令,因此每次发生对应事件时都会执行,不受模型决策影响。偏好设置应使用指令。必须始终执行的步骤或绝不能执行的操作应使用 hook。
如何阻止 Claude Code 运行特定的 shell 命令?
注册 PreToolUse hook,并使用 Bash matcher 从 .tool_input.command 读取命令,将原因写入 stderr,然后退出并返回 2。Claude Code 会取消此次调用,并向模型显示你的原因。此操作发生在权限模式检查之前,因此即使处于 bypassPermissions 模式,拒绝也仍然有效。对命令字符串进行模式匹配只能提供防护,不能构成安全边界,因为同一命令可以换一种模式书写,从而绕过匹配。因此,还应配合权限规则和非特权账户使用。
hook 输出了有效 JSON,但没有任何效果。为什么?
最常见的原因是 shell 配置文件。未设置 args 字段的 hook 会通过 sh -c 运行,而某些配置文件会在每次启动 shell 时输出横幅文本。该文本会出现在 JSON 之前的 stdout 中。由于输出不再以 { 开头,Claude Code 会将全部输出视为普通文本并忽略其中的决策;退出码为 0 时,转录中甚至不会报告任何内容。在配置文件中使用交互式 shell 检查,保护所有 echo。然后从 claude --debug-file /tmp/claude.log 读取调试日志,确认问题已修复。
在共享服务器上运行 Claude Code hook 是否安全?
hook 以启动 Claude Code 的用户身份运行,并拥有该用户的文件权限。因此,hook 可以执行该账户能够执行的所有操作。以下两项措施可以覆盖大部分风险:使用具有严格 sudo 策略的专用非特权账户运行代理;在接受仓库的工作区信任对话框之前,阅读其中的 hooks 块,因为项目 hook 包含在 .claude/settings.json 中。若不希望运行任何项目 hook,请在设置文件中设置 "disableAllHooks": true。