SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-15

Claude Code Hooks 配置、事件与退出码 2

了解 Claude Code hooks 的配置位置、31 个事件及 JSON 输入方式,重点说明退出码 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,文件的作用域就是 Hook 的作用域。

  • ~/.claude/settings.json:适用于您计算机上的所有项目,但不适用于其他人的计算机。
  • .claude/settings.json:适用于单个项目,并提交到仓库,因此所有克隆该项目的人都会获得此 Hook。
  • .claude/settings.local.json:适用于单个项目,仅在您的计算机上生效。
  • 托管策略设置:适用于整个组织,由管理员设置。
  • 插件中的 hooks/hooks.json:插件启用期间生效。
  • Skill 或 subagent 的 frontmatter:组件处于活动状态期间生效。

这些文件中的 Hook 条目会合并,而不是相互覆盖。项目设置文件会将其中的 Hook 添加到用户设置中的 Hook,而不是替换它们。因此,一个事件可以包含来自多个文件的多个 Hook。设置 "disableAllHooks": true 会停用这些 Hook,但有一个例外:托管策略设置中的 Hook 会继续运行,除非同时在托管设置中应用该设置。

在会话中运行 /hooks,可按事件列出当前注册的所有 Hook,并显示每个 Hook 的源文件和匹配器。该菜单为只读菜单,因此需要编辑设置文件来修改 Hook。文件监视器通常会自动检测到修改,无需重启。

Claude Code 存在哪些 hook 事件

Release 2.1.232 列出了 31 个事件,从 SessionStartSessionEnd,涵盖压缩、子代理、工作树和配置文件。服务器管理通常只使用其中几个。

  • PreToolUse:工具调用执行前触发。这是唯一可以阻止调用的事件。
  • PostToolUse:工具调用成功后触发。调用失败时改为触发 PostToolUseFailure,因此必须处理每种结果的 hook 需要同时配置这两个事件。
  • PermissionRequest:工具调用需要权限决定时触发,也就是应显示批准提示的时刻。
  • UserPromptSubmit:提交提示后、Claude 处理提示前触发。此 hook 写入 stdout 的内容会添加到模型上下文中。
  • SessionStartSessionEnd:分别在会话结束时触发。压缩后也会触发 SessionStart,此时 matcher 值为 compact
  • Stop:Claude 完成响应时触发。每轮只触发一次,而不是每个任务完成时触发一次。

每组事件都有一个 matcher,用于决定哪些情况会运行 hook。对于工具事件,它按工具名称进行筛选,因此 "Edit|Write" 只会在编辑文件时触发,不会在其他情况下触发。Matcher 区分大小写。Matcher 为空时,会在每次发生事件时触发。来自 MCP(model context protocol)服务器的工具名称为 mcp__<server>__<tool>,因此使用 "mcp__github__.*" 作为 matcher 可以匹配某个服务器的工具,而不会匹配其他服务器的工具。

Stop hook 有一个重要行为,编写 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_idcwdpermission_modetranscript_pathhook_event_name。工具事件还会添加 tool_nametool_inputtool_use_id。其他事件包含各自的字段:UserPromptSubmit 会获取 prompt 文本,SessionStart 会获取由 startupresumeclearcompactfork 组成的 source

在 shell 脚本中,通常使用 jq 读取这些内容;最小化服务器镜像中没有该工具。请先在 Ubuntu 和 Debian 上使用 sudo apt install -y jq 安装它。

退出状态对正在执行的工具调用的影响

共有三种结果。

  • 退出码 0 表示钩子未提出异议。在 PreToolUse 中,这不等同于批准,正常的权限流程仍会执行。在 UserPromptSubmitSessionStart 中,stdout 会添加到模型的上下文中。
  • 退出码 2 会阻止可阻止的事件,包括 PreToolUse,并将 stderr 作为显示给模型的原因。对于无法阻止的事件(例如 PostToolUse),阻止操作会被忽略,但 stderr 仍会作为反馈传递给模型。
  • 任何其他退出码 都表示非阻塞错误。操作会继续执行。会话记录会显示钩子错误通知,其中包含 stderr 的第一行,且位于文本 Failed with non-blocking status code: 之后。

如果需要执行阻止或保持静默以外的操作,应使用退出码 0,并将 JSON 对象输出到 stdout。PreToolUse 钩子通过 permissionDecision 作出决定:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database drops go through a migration, not through the agent."
  }
}

"allow" 跳过交互式提示,"deny" 取消调用并将原因发送给模型,"ask" 则按正常方式显示提示。每个钩子应选择一种方式。将退出码 2 与 stdout 中的 JSON 决策混用,会产生需要自行查找的结果。

当多个钩子匹配同一事件时,它们会并行运行,并且每个钩子都会运行至完成。一个钩子返回 deny 不会停止其他同级钩子,因此日志钩子仍会写入记录,同时防护钩子拒绝同一个调用。随后,Claude Code 会合并这些响应,并按拒绝、延后、询问、允许的顺序保留限制最严格的结果。

示例 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

为其添加可执行权限,然后在 PreToolUse.claude/settings.json 中注册它:

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"
          }
        ]
      }
    ]
  }
}

在信任脚本前,先手动测试,因为 hook 如果因自身输入而崩溃,就会默认放行:

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 hook 会在权限模式检查之前触发,并且适用于所有权限模式,因此即使在 bypassPermissions 下,拒绝规则仍然有效。这使 hook 适合与 Claude Code 自动模式及其权限设置 一起使用;在该模式下,提示会减少,但 hook 仍会触发。

需要明确其局限性。对命令字符串进行模式匹配,只能防止代理因疏忽执行命令,不能防止代理构造更巧妙的命令,因为同一个命令可以用 grep 看不到的形式编写。硬性规则应配置在权限系统中,并通过运行进程的账户强制执行。

示例 2:每次编辑后格式化并执行 lint

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 文件中添加一个缩进错误的函数,然后打开该文件。返回的内容会已完成格式化。这就能确认 hook 已运行,因为 hook 成功时不会在对话中显示任何内容。

这里的 exit 2 不会撤销任何操作。PostToolUse 在工具执行完毕后才触发,因此无论结果如何,编辑内容都已写入磁盘。exit 2 的作用是让 ruff check 的输出作为反馈传递给模型,使模型修复刚刚引入的错误,而不是继续执行。这就是提交时才发现 lint 失败与 agent 在同一轮中修复错误之间的区别。

这里有两个 matcher 限制需要注意。Edit|Write 看不到 shell 命令修改的文件,而 Claude 经常通过 Bash 写入文件,因此这个遗漏确实会发生。若要覆盖每次调用,请同时匹配 Bash,并让脚本使用 git status --porcelain 列出已修改的文件。若要每轮只执行一次扫描,则应将扫描放在 Stop hook 中。

示例 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 使用其自身的账户写入。

钩子允许运行多长时间

ChartDefault hook timeout in seconds, by hook type and event
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 guardrail,这意味着它不会阻止操作:工具调用会继续进入正常的权限流程。因此,guardrail 脚本应尽量简短。对于不需要等待的耗时工作,例如将日志发送到其他位置,请设置 "async": true,这样钩子会在后台运行,不会阻塞工具调用。

Hook、规则文件、技能和 MCP 服务器

这四者都会改变代理的行为,因此经常被混淆。但只有其中一种不会停留在建议层面。

规则文件(CLAUDE.md,或 .claude/rules/ 下的文件)是加载到模型上下文中的文本。它会影响行为,但不会强制执行任何内容。在长对话、大型差异和新的用户请求面前,其中的一行内容可能被忽略。这就是 代理忽略您写下的指令 的常见原因。

技能是一个包含指令和脚本的目录。模型在判断某项技能相关时加载它。这个判断正是技能的作用所在,也构成了它的限制:仍然由模型决定是否使用它。您可以从 Ponytail,它会引导代理采用可行的最小改动 这样的技能中看到这两面。它会以 hook 无法做到的方式影响整个任务的处理方法,但只有在模型选择加载它时才会生效。

MCP(模型上下文协议)服务器为模型提供可调用的新工具。它扩大了代理可以访问的范围。但它不会促使代理主动调用任何工具,而且它是一个需要单独运维的独立进程;详见 在 VPS 上运行 MCP 服务器

Hook 是四者中唯一一种无需模型选择即可运行的机制。对于偏好设置,使用规则文件;对于模型在适用时应遵循的操作流程,使用技能。对于每次都必须执行的步骤,或绝不能发生的事情,使用 hook。更深入的比较,包括技能何时优于规则文件,参见 技能、MCP 和规则文件的比较

插件是一种打包方式,而不是第五种机制。它将 hook 和技能打包成一个可安装单元,因此团队可以将同一套防护措施部署到每台机器;详见 Claude Code 插件的工作方式

共享 VPS 上的安全决策

Hook 是由代理触发并执行的代码。它以启动 Claude Code 的用户身份运行,并继承该用户的环境和文件权限。在笔记本电脑上,这是工作流问题。在无人值守运行代理的 VPS 上,这是一个包含 4 个实际方面的安全问题。

仓库中的 hook 可能是您未编写的代码。 .claude/settings.json 已提交到仓库,因此克隆仓库并在其中启动会话,可能会注册仓库自带的 hook。Claude Code 会通过该目录的工作区信任对话框控制项目 hook,这意味着接受信任的同时,也是在决定运行这些 hook。请先阅读 hooks 块。

Hook 可以看到完整的工具输入。 记录 tool_input 的审计 hook 会将每条命令的所有参数写入文件,包括命令行中恰好出现的任何令牌。随后,该日志需要与机密信息同等级别的保护。这也是 让机密信息无法被 AI 代理访问这一更大问题的一部分。

Hook 可以将内容写入模型上下文。 SessionStartUserPromptSubmit hook 输出到 stdout 的任何内容,都会添加到对话中。将外部来源、问题跟踪器或日志文件中的文本通过管道传入的 hook,实际上是在把不受信任的文本交给模型,就像这些文本是您亲自输入的一样。应将此 stdout 视为输入,而不是输出。

权限才是真正的控制手段。 使用专用的非特权用户运行代理,并且只授予它所需的 sudo 规则。设置 PreToolUse deny 是有价值的,而且其设计目标本来就是尽力而为:参考文档对 if 过滤器也有相同说明,并要求在需要强制拒绝时使用权限系统。权限规则和运行进程的用户账户,才是在高压力情况下仍然有效的部分。

有一项属性在所有配置中都成立。PreToolUse hook 会在每种权限模式下、权限模式检查之前触发,因此返回 deny 的 hook 即使在 bypassPermissions 下也会阻止工具运行。Hook 可以收紧权限规则允许的操作范围,但不能放宽这些范围。

为什么我的 hook 没有触发?

按以下顺序排查。每一步都说明您实际会看到的症状。

  • 运行 /hooks,检查 hook 是否出现在预期的事件下。hook 未出现在菜单中,通常表示设置文件存在 JSON 语法错误,因为不允许使用尾随逗号和注释;也可能表示文件不在上述 6 个位置之一。
  • 将 matcher 与工具名称逐字符比较。matcher 区分大小写,因此 "bash" 永远不会匹配 Bash 工具。
  • 按上文示例 1 的方式,使用示例输入手动运行脚本。未预期的退出码表示脚本存在错误。Claude Code 会将其报告为 hook 错误,而不是决策结果。
  • 如果看到 jq: command not found,表示该计算机缺少 jq。如果您自己的脚本出现 command not found,表示路径无法解析,因此请使用 ${CLAUDE_PROJECT_DIR} 或绝对路径。如果脚本完全没有运行,通常是因为它没有执行权限。
  • hook 输出了有效 JSON,但没有任何效果。Shell 形式的 hook 会通过 sh -c 运行。如果您的 shell profile 输出启动横幅,该横幅就会添加到 JSON 前面。标准输出不再以 { 开头,因此 Claude Code 会将整个输出读取为纯文本,并忽略其中的决策。退出码为 0 时,除调试日志外不会在任何位置报告结果。请将 profile 中的任何 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 之前。由于输出不再以 { 开头,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