文档大纲

Claude Code Hooks完全实操手册:30个钩子点+12个生产级案例

读完这篇你会拿到:

  • 30 个钩子点的完整地图,知道每个事件触发时到底传给你什么 JSON
  • 退出码 0 / 2 / 其他的精确语义,以及"拒绝"和"出错"是两件事
  • JSON 结构化输出的所有字段,从 permissionDecision 到 terminalSequence
  • 4 种 hook 类型(command / http / mcp_tool / prompt / agent)的差异化用法
  • 异步 hook、持久化环境变量、defer 工具调用这种"高级玩法"
  • 12 个真实可跑的案例脚本
  • 安全红线 & 调试技巧

长文预警,建议收藏后慢慢看。

一、先把概念钉死:hooks 跟 prompt 是两个世界

很多人第一次接触 hooks,会问这个问题:

"我直接在 prompt 里告诉 Claude'别 rm -rf',不就行了吗?为啥还要配 hook?"

答案很扎心:prompt 是劝告,hook 是制度。

Claude 是 LLM,本质是个"用概率生成下一个 token 的机器"。你让它"记得不要 rm -rf",它十次里有八次记得——剩下两次它就会"忘记"或者"判断失误"。

Hook 不一样。它是在事件点强制执行的 shell 命令。到点了不管 Claude 想不想,那个 pre-tool-use.sh 一定会跑,跑完 exit 2 就一定能拦截。

这跟系统编程里的 signal handler 是一个思路:你不希望进程靠"自觉"响应 SIGTERM,你装个 handler 强制处理。Claude 的 hook 就是那个 handler。

所以:

Prompt 提醒Hook
性质概率性(80% 记得)确定性(100% 执行)
适用软规范("请遵循我们的代码风格")硬规则("绝对不能改 .env")
触发点每次 Claude 思考时固定的生命周期事件点
实现自然语言shell 命令 / HTTP / LLM 判定

经验法则:能用 hook 兜底的,不要依赖 prompt。

二、30 个钩子点的完整地图

我把所有事件按"触发频率"分了三类。先建立全局视野,再决定你该配哪几个。

2.1 会话级(一辈子一次)

SessionStart、Setup、SessionEnd、InstructionsLoaded、ConfigChange、PreCompact、PostCompact、WorktreeCreate、WorktreeRemove

这八个钩子都跟"会话本身"相关,不跟单条对话或单次工具调用绑定。

2.2 轮次级(每轮对话一次)

UserPromptSubmit、UserPromptExpansion、Stop、StopFailure、MessageDisplay、PostToolBatch、Notification、TeammateIdle、TaskCreated、TaskCompleted

每次你和 Claude 来回一轮,会触发这一组里的部分事件。

2.3 工具级(每个工具调用)

PreToolUse、PostToolUse、PostToolUseFailure、PermissionRequest、PermissionDenied、SubagentStart、SubagentStop、CwdChanged、FileChanged、Elicitation、ElicitationResult

Claude 每调一个工具,会触发这一组。这是最容易写出花活儿的一类。

2.4 8 个最常用的事件(按使用频率排序)

排名事件干啥用的
1PostToolUse工具调完后做事(格式化、lint、自动 commit)
2PreToolUse工具调前拦截(防 rm、防改敏感文件)
3Notification桌面通知、声音提醒
4UserPromptSubmit改写/增强用户的 prompt,注入上下文
5SessionStart启动时加载项目上下文
6StopClaude 答完一轮时做事(自动验收)
7PermissionRequest自动批准特定权限
8PostCompact压缩后重新注入关键信息

剩下 22 个事件等你遇到具体场景再查表,文档里都有详细 JSON schema。

三、退出码语言:0 / 2 / 其他到底是啥

这是最容易栽的地方。新手往往以为"退出码 1 = 阻止",错。

3.1 三种退出码的精确语义

退出码含义Claude 的反应
0hook 没意见正常流程继续(不会"批准",权限流程照旧)
2拒绝 + 反馈操作被阻止;你的 stderr 内容作为反馈给 Claude
其他(1、3、127…)出错操作继续,但转录里显示 "hook error" + stderr 第一行

关键点 1:退出 0 不会"自动批准"。比如 PreToolUse hook 退出 0 只是说"我没意见",Claude 接下来还会走正常的权限检查。如果你想跳过权限提示,必须用 permissionDecision: "allow"(后面讲)。

关键点 2:退出 2 之后,Claude 收到的反馈是 stderr 而不是 stdout。如果你的拒绝原因写到 stdout,Claude 看不到,你就失败了。

关键点 3:退出非零非二的码(比如 shell 里 exit 1),不会阻止操作。Claude 还会执行那个工具。这跟"退出 2 = 阻止"的直觉相反,是 hooks 设计里反直觉但重要的一点。

3.2 用错退出码的两个经典 bug

Bug 1:以为 exit 1 能阻止

#!/bin/bash
if [ dangerous ]; then
  echo "Blocked" >&2
  exit 1   # ❌ 不会阻止!Claude 还是会跑那个命令
fi
exit 0

修法:把 exit 1 改成 exit 2。

Bug 2:拒绝原因写到 stdout

#!/bin/bash
if [ dangerous ]; then
  echo "Blocked: ..."          # ❌ 写到 stdout,Claude 看不到
  exit 2
fi
exit 0

修法:改成 echo "..." >&2。

3.3 哪个事件能"阻止",哪个不能

不是所有事件都支持"阻止"语义。下面这些事件不支持退出 2 阻止(即使你退出 2 也只会显示 stderr 给用户,操作继续):

  • SessionStart
  • Setup
  • Notification
  • Stop
  • StopFailure
  • SessionEnd
  • MessageDisplay
  • TeammateIdle
  • WorktreeCreate
  • WorktreeRemove

这些事件想"阻止"得用 JSON 输出里的 decision 字段。

完整列表查文档的 Exit code 2 behavior per event。

四、JSON 输出:让 hook 跟 Claude 说得更细

退出码只能表达"同意 / 拒绝"二元决策。真实场景里你需要更复杂的反馈,比如:

  • "允许执行,但要在用户终端弹个警告"
  • "拒绝,并把推荐工具告诉 Claude(让它换个做法)"
  • "批准后顺便切换到 acceptEdits 模式"

这些都需要结构化 JSON 输出。

4.1 JSON 输出的通用结构

{
  "continue": true,
  "stopReason": "stopped",
  "systemMessage": "这是一条给用户看的消息",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "用 rg 代替 grep,性能更好"
  }
}

字段含义:

  • continue:true/false,false 时整个会话立即终止
  • stopReason:终止原因,会显示给用户
  • systemMessage:纯文本消息,弹出给用户看
  • hookSpecificOutput:不同事件用不同子字段

4.2 PreToolUse 的三种决策

hookSpecificOutput.permissionDecision:

值含义
"allow"跳过权限提示直接执行
"deny"取消工具调用,把 reason 给 Claude
"ask"照常弹出权限提示
"defer"退出进程,让 Agent SDK 接管(用于 -p 模式)

⚠️ 重要:"allow" 不能覆盖权限系统里的 deny 规则。也就是说,如果你的权限配置拒绝 Bash(rm *),那 hook 即使返回 "allow",这条命令还是会被拒。hook 可以收紧权限,不能放宽。

4.3 完整案例:拒绝并给建议

#!/bin/bash
# 用 rg 替代 grep 的 hook

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

if echo "$COMMAND" | grep -q '^grep\b'; then
  cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "项目约定用 rg (ripgrep),不要 grep。性能快 5-10 倍,gitignore 也自动尊重。"
  },
  "systemMessage": "检测到 grep 命令,已自动拦截并要求改用 rg"
}
EOF
  exit 0  # 注意:结构化输出时退出 0,不是 2
fi

exit 0

⚠️ 注意:用 JSON 输出时,退出码必须是 0。Claude Code 在你退出 2 时会忽略 JSON。

4.4 终端通知:terminalSequence

如果你想在用户的终端弹个原生通知(类似 Claude 自己的进度提示),用 OSC(Operating System Command)序列:

{
  "terminalSequence": "\u001b]777;notify;Title;Body\u0007"
}

777 是 iTerm2 的私有 OSC 编号,其他终端可能不支持。常见的 OSC:

编号含义
0设置窗口标题
1设置图标标题
2设置窗口标题
9 / 99iTerm2 进度条
777iTerm2 通知

五、Matcher 匹配器:精确控制触发条件

5.1 三种评估方式

Matcher 的值有三种评估方式,根据值的字符判断走哪种:

值的特征评估方式例子
&quot;*&quot;、&quot;&quot; 或省略匹配所有&quot;&quot;
仅含字母数字、_、-、空格、,、`\`精确字符串或列表Bash、`Edit\Write、Edit,Write`
包含其他字符(.、*、(、[ 等)JavaScript 正则(未锚定)^Notebook、mcp.read.

Edit|Write 和 Edit,Write 在 v2.1.191+ 是等价的。

5.2 MCP 工具的命名约定

MCP 工具名字长这样:mcp__<server>__<tool>,比如:

  • mcp__github__search_repositories
  • mcp__filesystem__read_file
  • mcp__plugin_my_plugin_db__query

注意中间是两个下划线,不是冒号也不是点。

要匹配某个 server 的所有工具:

{
  "matcher": "mcp__github__.*"
}

要匹配所有 MCP 工具:

{
  "matcher": "mcp__.*__.*"
}

5.3 实战:同时匹配多个工具

匹配"代码相关"的工具:

{
  "matcher": "Edit|Write|MultiEdit"
}

匹配 MCP 工具:

{
  "matcher": "mcp__github__create_.*|mcp__github__update_.*"
}

5.4 容易栽的坑

坑 1:匹配器区分大小写。"Bash" 和 "bash" 不一样。

坑 2:如果你想写正则但漏了 .*,匹配会很严格。"^Read" 不会匹配 "ReadFile",但 "Read" 会(因为未锚定)。

坑 3:Matcher 是"或"语义,不是"且"。"Edit|Write" = 匹配 Edit 或 Write。要"且"得用后面的 if 字段。

六、if 字段:比 Matcher 更细的过滤

if 字段在 v2.1.85 加入,比 Matcher 更精确。Matcher 只看工具名,if 可以看工具名 + 参数。

6.1 基本语法

if 字段用权限规则语法,跟你写 permissions.allow 的格式一样:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git *)",
            "command": "./.claude/hooks/check-git-policy.sh"
          }
        ]
      }
    ]
  }
}

这段配置:只有当 Claude 跑 Bash 且命令以 git 开头时,才触发检查脚本。

6.2 if 的解析规则(尽力而为)

if 模式命令触发?说明
Bash(git *)git push✅命令名匹配
Bash(git *)npm test &amp;&amp; git push✅每个子命令都被检查
Bash(git *)echo $(git log)✅$() 和反引号内命令也被检查
Bash(git *)echo $(date)❌没有子命令匹配 git
Bash(git push *)echo $(date)✅模式过宽,只要有任何 git 就触发
Bash(git push *)git log❌命令名不匹配

6.3 为什么是"尽力而为"

if 字段是性能优化,不是安全机制。

如果 Bash 命令里嵌入复杂语法(条件命令、eval、转义),if 可能解析不出来。这种时候 hook 还是会跑——所谓"fail-open"。

所以硬安全约束要靠权限系统的 deny 规则,不要只靠 hook 的 if。

6.4 if vs Matcher 怎么选

  • 想按"工具类型"过滤 → Matcher
  • 想按"工具 + 具体参数"过滤 → if
  • if 只在工具事件上有效:PreToolUse、PostToolUse、PostToolUseFailure、PermissionRequest、PermissionDenied
  • 把 if 用到其他事件上会让 hook 静默不跑

七、4 种 hook 类型(不止 shell 命令)

很多人以为 hooks 就是 shell 命令,其实还有 4 种类型,每种适合不同场景。

7.1 command(最常用)

{
  "type": "command",
  "command": "jq -r '.tool_input.file_path' | xargs prettier --write"
}

两种形式:

  • Shell 形式:省略 args,通过 sh -c(macOS/Linux)或 Git Bash(Windows)执行
  • Exec 形式:提供 args,直接 spawn 进程,绕过 shell

Exec 形式的好处:避免 shell 引号问题、避免 .bashrc 污染 JSON、性能稍好。

{
  "type": "command",
  "command": "/path/to/script.sh",
  "args": ["--check", "--strict"]
}

7.2 http

把事件数据 POST 到一个 HTTP 端点:

{
  "type": "http",
  "url": "http://localhost:8080/hooks/tool-use",
  "headers": {
    "Authorization": "Bearer $MY_TOKEN"
  },
  "allowedEnvVars": ["MY_TOKEN"]
}

⚠️ 一定要写 allowedEnvVars!没有它,环境变量插值会被清空。这是安全设计,不是 bug。

7.3 mcp_tool

在已连接的 MCP 服务器上调用工具:

{
  "type": "mcp_tool",
  "toolName": "mcp__audit__log_action",
  "toolInput": {
    "action": "tool_used"
  }
}

适合把 hook 跟 MCP 工具链集成,比如"工具调用后调 MCP 审计工具记录"。

7.4 prompt(基于提示)

让 Claude 模型判断要不要继续:

{
  "type": "prompt",
  "prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
}

默认用 Haiku 模型(快便宜),可以指定别的:

{
  "type": "prompt",
  "prompt": "...",
  "model": "sonnet"
}

适合"软判断"场景,比如"测试全过没?没过让 Claude 继续"。

7.5 agent(基于代理)

比 prompt 更猛,开一个 subagent 去验证。subagent 可以读文件、搜代码、跑命令。

{
  "type": "agent",
  "prompt": "Verify that all unit tests pass. Run the test suite and check the results.",
  "timeout": 120
}

实验性,生产慎用。但作为"自动验收人"非常香。

7.6 5 种类型对比

类型适合场景性能成本
command确定性规则、文件操作、外部通知极快极低(本地命令)
http团队共享审计、集成现有服务取决于服务网络 + 服务成本
mcp_tool复用 MCP 工具链取决于 MCP取决于 MCP
prompt软判断("任务完成没")中等(单轮 LLM)模型调用成本
agent复杂验证("测试过没")慢(多轮 LLM + 工具)高

经验法则:能用 command 解决就别上 prompt,能用 prompt 解决就别上 agent。

八、12 个真实可跑的案例

这一节是全文最实操的部分。每个例子都包含完整代码 + 注册到 settings.json 的方法。直接抄就能用。

案例 1:阻止 rm -rf(PreToolUse + if 字段)

.claude/hooks/block-rm.sh:

#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')

if echo "$COMMAND" | grep -qE '\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "危险命令已拦截。请用 mv 到回收站,或用 trash-cli (macOS: trash, Linux: gio trash)。"
    },
    systemMessage: "⚠️ rm 命令被拦截"
  }'
  exit 0
fi

exit 0

注册:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

案例 2:自动格式化 + 自动 lint

.claude/hooks/auto-format.sh:

#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path')

# 只处理存在的文件
[ ! -f "$FILE_PATH" ] && exit 0

case "$FILE_PATH" in
  *.cpp|*.h|*.hpp|*.cc)
    command -v clang-format >/dev/null && clang-format -i "$FILE_PATH"
    ;;
  *.py)
    command -v black >/dev/null && black "$FILE_PATH" 2>/dev/null
    ;;
  *.ts|*.tsx|*.js|*.jsx)
    command -v prettier >/dev/null && prettier --write "$FILE_PATH" 2>/dev/null
    ;;
  *.go)
    command -v gofmt >/dev/null && gofmt -w "$FILE_PATH"
    ;;
  *.rs)
    command -v rustfmt >/dev/null && rustfmt "$FILE_PATH"
    ;;
esac

exit 0

注册:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs -I{} ${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-format.sh",
            "args": []
          }
        ]
      }
    ]
  }
}

案例 3:保护敏感文件

.claude/hooks/protect-files.sh:

#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')

PROTECTED_PATTERNS=(
  ".env"
  ".env.local"
  ".env.production"
  "package-lock.json"
  "yarn.lock"
  "CMakeLists.txt"
  "vcpkg.json"
  ".git/"
  "id_rsa"
  "*.pem"
  "*.key"
)

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    cat <<EOF >&2
Blocked: $FILE_PATH matches protected pattern '$pattern'

如确实需要修改,请在 .claude/settings.local.json 中临时禁用此 hook,或联系团队负责人。
EOF
    exit 2
  fi
done

exit 0

案例 4:会话启动注入项目上下文

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Project: '$(basename $(pwd))' | Build: CMake (vcpkg for deps) | Test: ctest | Lint: clang-tidy | Style: clang-format | Recent:' && git log --oneline -5"
          }
        ]
      }
    ]
  }
}

stdout 任何内容都会注入到 Claude 上下文。

案例 5:压缩后保留关键信息

{
  "hooks": {
    "PostCompact": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "echo '⚠️ IMPORTANT: This is a C++ project using CMake + vcpkg. Do not use Makefile. Use std::format instead of printf. Run ctest before committing.'"
          }
        ]
      }
    ]
  }
}

案例 6:自动验收(Stop + prompt)

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Check whether the user's original request has been fully addressed. If anything remains unfinished or unclear, respond with {\"ok\": false, \"reason\": \"<what's left to do>\"}. Otherwise respond {\"ok\": true}."
          }
        ]
      }
    ]
  }
}

Claude 答完一轮,Haiku 来判断"事干完没",没干完就让 Claude 继续干。

案例 7:自动跑测试(Stop + agent)

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Run the project's test suite (e.g., `npm test`, `pytest`, `ctest`, `go test ./...`) and check whether all tests pass. If any fail, respond {\"ok\": false, \"reason\": \"<list of failing tests with their error messages>\"}. Otherwise respond {\"ok\": true}.",
            "timeout": 180
          }
        ]
      }
    ]
  }
}

⚠️ 警告:agent hook 是实验性的,且 timeout 默认 60 秒(这个例子里我手动改成 180)。

案例 8:所有 Bash 命令审计日志

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -c '{ts: now|todate, cmd: .tool_input.command, cwd: .cwd}' >> ~/.claude/audit.jsonl"
          }
        ]
      }
    ]
  }
}

每个 Bash 命令执行前都追加一行 JSON 到审计日志。可以配合 grep / jq 后续分析。

案例 9:direnv 集成(持久化环境变量)

如果你用 direnv 管理项目级环境变量,配这个让 Claude 的 Bash 工具也吃到:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
          }
        ]
      }
    ],
    "CwdChanged": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "direnv export bash > \"$CLAUDE_ENV_FILE\""
          }
        ]
      }
    ]
  }
}

$CLAUDE_ENV_FILE 是 Claude Code 提供的环境变量,指向一个临时文件,Claude Code 在每个 Bash 命令前都会 source 这个文件。这样 direnv 的变量就生效了。

案例 10:自动批准 ExitPlanMode

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
          }
        ]
      }
    ]
  }
}

⚠️ matcher 必须严格。空 matcher 会自动批准所有权限提示,等于自杀。

案例 11:异步后台 lint(async)

不想让 lint 阻塞 Claude 主流程?

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "async": true,
            "command": "jq -r '.tool_input.file_path' | xargs clang-tidy"
          }
        ]
      }
    ]
  }
}

async: true 让 hook 在后台跑,Claude 不等它。但异步 hook 的输出(stdout/stderr/退出码)都不会影响 Claude——它是纯"通知式"的。

如果你想异步跑,但 hook 拒绝时还要 Claude 知道,用 asyncRewake: true。

案例 12:HTTP hook 推到团队审计服务

{
  "hooks": {
    "PostToolUse": [
      {
        "hooks": [
          {
            "type": "http",
            "url": "https://audit.internal.example.com/claude/events",
            "headers": {
              "Authorization": "Bearer $AUDIT_TOKEN",
              "Content-Type": "application/json"
            },
            "allowedEnvVars": ["AUDIT_TOKEN"]
          }
        ]
      }
    ]
  }
}

九、几个高级玩法

9.1 异步 Hook(async)

两种模式:

字段含义
async: true后台跑,输出不影响 Claude
asyncRewake: true后台跑,但退出码 2 时"唤醒" Claude 接收反馈

什么时候用 asyncRewake?比如后台跑 lint,发现错误时还想让 Claude 修。

9.2 Defer 工具调用

PreToolUse 的 permissionDecision 可以是 "defer",这时 hook 进程会退出,让上层进程(比如 Claude Agent SDK)接管。

用途:在 -p 模式下,hook 不能直接弹权限提示,所以退出进程让 SDK 自己处理。

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "defer"
  }
}

需要 Claude Code v2.1.89+。

9.3 一次性 hook(once)

只跑一次的 hook,用 once: true:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "once": true,
            "command": "echo 'Welcome! This message appears only once per session.'"
          }
        ]
      }
    ]
  }
}

适合"第一次启动时提醒用户"这种场景。

9.4 自定义加载器消息(statusMessage)

Hook 跑起来时,Claude Code 会在底部显示一个加载提示。你可以用 statusMessage 自定义:

{
  "type": "command",
  "command": "long-running-lint.sh",
  "statusMessage": "正在跑 clang-tidy,预计 30 秒…"
}

用户体验上的小细节,但做产品级配置时很有用。

9.5 通过 additionalContext 注入上下文

UserPromptSubmit hook 可以往 Claude 上下文里塞东西:

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "用户当前在 src/database/ 目录下。最近 3 次提交都与 connection pool 相关。"
  }
}

这跟 stdout 注入有什么区别?

  • stdout 注入:每次事件触发都加,适合"项目元信息"
  • additionalContext:动态生成,适合"本次任务相关的临时上下文"

十、安全红线(必看)

hooks 是个强大的能力,也是个巨大的攻击面。Claude Code 本身做了一些安全设计,你也要自己绷紧神经。

10.1 v2.1.139+ 的重要变化

从 v2.1.139 开始,macOS / Linux 上 hook 在无控制终端的会话中运行,无法访问 /dev/tty。

这意味着:

  • hook 不能再从用户终端读取输入(防止"假装问用户"窃取信息)
  • 一些需要交互的命令会失败(设计上的取舍)

10.2 你必须做的

1. 永远不要把 secret 写到 stdout

stdout 内容会被注入到 Claude 上下文,Claude 可能会把它写进文件、commit、推到远程。

#!/bin/bash
# ❌ 危险
echo "My API key is: $(cat ~/.aws/credentials)"

# ✅ 正确
echo "Loaded AWS credentials" >&2

2. 谨慎使用 allowedEnvVars

不要写 *,要明确列出:

{
  "allowedEnvVars": ["MY_TOKEN", "USER_NAME"]
}

3. hook 脚本本身的权限管理

  • 项目 hook 用 ${CLAUDE_PROJECT_DIR} 引用,相对路径相对当前仓库根
  • 用户 hook 用绝对路径或 ~
  • 脚本要 chmod +x 但 owner 要是你自己

4. 不要 hook 改 .claude/、~/.claude/ 下的文件

Claude Code 的部分目录对 hook 是"高危"的——写错位置会破坏配置。建议用 permissions.deny 显式禁止:

{
  "permissions": {
    "deny": ["Edit(.claude/**)", "Write(.claude/**)"]
  }
}

10.3 团队环境

如果是团队项目:

  • 项目级 .claude/settings.json 提交到 git
  • 涉及 secret 的 hook 放 .claude/settings.local.json
  • 团队共享的审计/通知 hook 放 plugin
  • review PR 时把 hook 当普通代码 review

十一、调试大全

11.1 三种查看 hook 状态的方式

方式 1:/hooks 菜单

只读浏览,看你的 hook 是否注册成功、各事件的 hook 数。

方式 2:Ctrl+O 转录视图

切到转录视图,每个 hook 触发会显示一行:

  • 成功 → 静默
  • 阻止 → 显示 stderr
  • 错误 → 显示 "hook error" + stderr 第一行

方式 3:--debug-file 启动日志

claude --debug-file /tmp/claude.log

另一个终端:

tail -f /tmp/claude.log

能看到 hook 的:是否匹配、退出码、stdout、stderr 全量内容。

不写启动参数也能开:会话内 /debug 开启日志,会告诉你日志路径。

11.2 三个最常见的坑

坑 1:jq 解析失败

症状:hook error 或 stderr 显示 jq 错误。

排查:

echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./your-hook.sh
echo "Exit code: $?"

坑 2:路径找不到

症状:command not found。

修法:用绝对路径或 ${CLAUDE_PROJECT_DIR} 变量。Windows 用 PowerShell 时配 "shell": "powershell"。

坑 3:shell 配置污染 JSON

症状:JSON validation failed,即使脚本输出合法。

原因:.bashrc / .zshrc 里有 echo "Shell ready",在非交互 shell 也会执行(因为 hook 跑在非交互 shell 里,但 BASH_ENV / shell rc 文件还会被 source)。

修法:

# 在 ~/.zshrc 或 ~/.bashrc 里
if [[ $- == *i* ]]; then
  echo "Shell ready"
fi

i 表示交互式 shell。

十二、我现在的完整配置

参考用。包含:

  • 桌面通知
  • 自动格式化
  • 保护敏感文件
  • 自动 ExitPlanMode
  • 项目上下文注入

~/.claude/settings.json:

{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude 在等你\" with title \"Claude Code\"'"
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
          }
        ]
      }
    ]
  }
}

项目 .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs -I{} sh -c 'case \"{}\" in *.cpp|*.h|*.hpp|*.cc) clang-format -i \"{}\" ;; *.py) black \"{}\" 2>/dev/null ;; *.md) prettier --write \"{}\" 2>/dev/null ;; esac'"
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
            "args": []
          }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm *)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm.sh",
            "args": []
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "echo '项目: '$(basename $(pwd))' | 构建: CMake + vcpkg | 测试: ctest | 最近:' && git log --oneline -3"
          }
        ]
      }
    ],
    "PostCompact": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "echo '⚠️ C++ 项目约定:CMake + vcpkg;用 std::format 不用 printf;提交前跑 ctest。'"
          }
        ]
      }
    ]
  }
}

辅助脚本:

.claude/hooks/protect-files.sh:

#!/bin/bash
FILE_PATH=$(jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" "CMakeLists.txt" "vcpkg.json" ".git/" "*.key" "*.pem")
for p in "${PROTECTED[@]}"; do
  if [[ "$FILE_PATH" == *"$p"* ]]; then
    echo "Blocked: $FILE_PATH matches '$p'" >&2
    exit 2
  fi
done
exit 0

.claude/hooks/block-rm.sh:

#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE '\brm\s'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "请用 mv 或 trash-cli,不要直接 rm。"
    }
  }'
fi
exit 0

chmod +x 两个脚本。

十三、FAQ:常见问题集中解答

写完上面那堆,朋友群里立刻扔过来一堆问题。我把最有代表性的 12 个挑出来,统一答一下。

Q1:hooks 跟 CLAUDE.md / subagents / skills 怎么分工?

这四个东西看起来都"扩展 Claude",但分工完全不同:

工具干啥触发时机写入方式
CLAUDE.md给 Claude 静态上下文(项目约定、编码风格)每次会话开始自动加载Markdown
Skills让 Claude 调用一组预定义的工作流Claude 主动 /命令 触发Markdown + 可选脚本
Subagents把任务派给独立上下文的"副驾"Claude 自动 / @ 触发Markdown 文件
Hooks在事件点强制执行 shell 命令固定生命周期点JSON 配置 + shell 脚本

经验法则:

  • "我希望 Claude 记得我们的项目约定" → CLAUDE.md
  • "我想让 Claude 能调用一个复杂的测试流程" → Skill
  • "我想让 Claude 把脏活派给独立的副驾" → Subagent
  • "我需要 Claude 每次写完文件自动格式化" → Hook

四个不互斥,可以组合用。比如我项目的 CLAUDE.md 里写"用 C++20″,配合 hook 自动 clang-format,再加上 cpp-reviewer subagent 自动审查。

Q2:多个 hook 同时触发,谁优先?

答案是:都跑,按规则合并结果。

具体来说:

  • 同一事件有多个 hook → 全部并行执行
  • 多个 PreToolUse 返回权限决策 → 最严格的赢:deny > defer > ask > allow
  • 多个 hook 都想写 additionalContext → 全部拼起来注入

⚠️ 不要依赖 hook A 的输出去影响 hook B 的行为(比如 hook A 写文件,hook B 读那个文件)。并行执行意味着顺序不确定,依赖关系会偶发翻车。

如果两个 hook 想"接力",把它们合并成一个 hook 脚本里的多次调用,别拆成两个 hook。

Q3:改完 settings.json 要重启 Claude Code 吗?

通常不用。 Claude Code 监视 ~/.claude/settings.json 和 .claude/settings.json,几秒内自动拾取。

例外情况:

  • 第一次创建 .claude/agents/ 或类似目录 → 重启一次让它发现
  • 用 --disable-slash-commands 启动的会话 → 完全不监听
  • 极少数文件监视器 bug → 重启解决

如果 /hooks 菜单里看不到你新加的 hook,但 JSON 文件写得没问题,大概率是文件监视器抽风了,重启一次就好。

Q4:hook 能在 Windows / macOS / Linux 之间共享吗?

配置 JSON 可以共享,shell 脚本不行——因为命令语法不一样。

跨平台 hook 的标准做法:

1. 命令里区分平台

# 在 hook 脚本里
case "$(uname -s)" in
  Darwin)
    osascript -e 'display notification "..."'
    ;;
  Linux)
    notify-send "Claude" "..."
    ;;
  MINGW*|CYGWIN*|MSYS*)
    powershell.exe -Command "[System.Reflection.Assembly]::..."
    ;;
esac

2. JSON 里用 $CLAUDE_PROJECT_DIR

这个变量在三个 OS 上都解析成项目根目录,跨平台脚本里直接用。

3. Windows 上指定 shell

{
  "type": "command",
  "command": "C:/path/to/script.ps1",
  "shell": "powershell"
}

最干净的方案:hook 脚本本身用纯 Bash(Git Bash 兼容),JSON 配置不动。macOS / Linux 原生 bash,Windows 装 Git for Windows 后 Claude Code 自带 Git Bash。PowerShell 留给 Windows 特有场景。

Q5:hook 性能开销大吗?频繁触发会不会拖慢?

老实说:有点,但一般可以忽略。

实测数字(我自己的项目,MacBook Pro M2):

场景hook 类型每次开销
PostToolUse 跑 prettiercommand50-200ms
PreToolUse 解析 Bash 命令command5-10ms
SessionStart 跑 git logcommand50-100ms
Stop 用 prompt 判定prompt1-3s(Haiku 单轮)
Stop 用 agent 跑测试agent30-120s

优化建议:

  • 热路径 hook(PostToolUse / PreToolUse)必须轻量。超过 500ms 用户会感觉"卡"
  • 重活儿用 async: true,别阻塞 Claude
  • 重活儿用 prompt / agent 放到 Stop 而不是 PostToolUse
  • 冷启动钩子(SessionStart)可以重,反正一年就跑几次
  • 文件 I/O 别同步阻塞——读 secret、写日志用 & 后台跑

Q6:hook 输出字符有上限吗?

有,且文档里没说清楚。

实测上限是 10,000 字符。超出部分会被截断。

具体场景:

  • stdout 注入上下文:超出截断,可能导致关键信息丢失
  • stderr 反馈给 Claude:超出截断,反馈不完整
  • JSON 输出:解析失败,整个 hook 视为错误

如果你的 hook 需要输出大段内容(比如 dump 整个文件),写到文件里别写到 stdout:

#!/bin/bash
# ❌ 危险
cat /etc/passwd

# ✅ 正确
cat /etc/passwd > /tmp/claude-context-dump.txt
echo "Dump written to /tmp/claude-context-dump.txt"

Q7:为什么我的 hook 静默不跑?

按这个清单排查:

1. JSON 格式错——多余逗号、缺引号、错括号。/hooks 菜单里看不到就是格式错。

2. matcher 不匹配——区分大小写。bash ≠ Bash。

3. 触发的事件不对——PreToolUse 在工具执行前,PostToolUse 在之后;Notification 是给"通知事件",不是所有事件。

4. 权限被拒绝——permissions.deny 里有 Agent(...) 把整个 subagent 系统禁用时,hook 也跑不了。

5. 在非交互模式(-p)下 PermissionRequest 不触发——改用 PreToolUse。

6. matcher 留空但 hook 类型是 prompt——prompt hook 必须有非空 matcher(设计上要求)。

7. 文件权限——hook 脚本没 chmod +x。

8. --disable-slash-commands——这个标志顺便禁用了 hook 监视。

Q8:hook 能 chain 起来吗?一个 hook 调用另一个 hook?

严格说不能"hook 调用 hook"(hook 不会触发另一个 hook 事件),但你可以让一个 hook 脚本里顺序执行多个动作:

#!/bin/bash
# 一个 hook 脚本里做三件事
jq -r '.tool_input.command' >> ~/.claude/bash.log
clang-format -i "$(jq -r '.tool_input.file_path')"
ctest --output-on-failure 2>&1 | tail -20
exit 0

如果真的需要 hook A 的结果影响 hook B(比如 hook A 拦截了命令,hook B 才能继续),把它们合并成一个 hook 脚本,别拆成两个 hook 配置文件项。

Q9:hook 能修改 Claude 即将看到的 prompt 吗?

能,但仅限 UserPromptSubmit。

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "当前在 src/auth/ 目录。最近一次提交: abc123。"
  }
}

⚠️ 注意:additionalContext 是追加到上下文,不是替换原 prompt。如果你想"重写"用户的 prompt,得用 UserPromptExpansion(专门干这个的事件,但更复杂)。

Q10:团队怎么共享 hook 而不泄露 secret?

分层策略:

项目级(.claude/settings.json,提交 git):通用 hook,所有人都用

  • 自动格式化
  • 拦截 rm
  • 注入项目约定

本地级(.claude/settings.local.json,gitignore):个人配置

  • 个人通知脚本
  • 调试用 verbose hook
  • 本地路径相关的 hook

用户级(~/.claude/settings.json,本机私有):跨项目通用

  • 桌面通知
  • 个人习惯的 hook

Plugin 级:可分享的封装

  • 团队通用的工作流 hook 包
  • 配合 ${CLAUDE_PLUGIN_ROOT} 做路径隔离

secret 相关(API key、token)一律不写到 hook 命令里。用环境变量 + allowedEnvVars(HTTP hook)或 $VAR(command hook)。

Q11:plugin 里的 hook 跟项目 hook 冲突怎么办?

plugin hook 优先级最低,项目 hook 覆盖 plugin hook。

具体冲突解决顺序(高 → 低):

  1. 托管设置(管理员级别)
  2. 用户级 ~/.claude/settings.json
  3. 项目级 .claude/settings.json
  4. 项目级 .claude/settings.local.json
  5. Plugin 的 hooks/hooks.json

如果你装了 plugin 提供的 hook 想禁用它,别改 plugin 文件——在项目级配置里覆盖:

{
  "permissions": {
    "deny": ["Agent(plugin:hook-name)"]
  }
}

或者用 disableAllHooks: true(但这会禁用所有 hook,慎用)。

Q12:hook 能用环境变量吗?怎么从 Claude Code 传进来?

三种方式:

方式 1:Claude Code 内置环境变量

#!/bin/bash
echo "Project: $CLAUDE_PROJECT_DIR"
echo "Plugin root: $CLAUDE_PLUGIN_ROOT"
echo "Env file: $CLAUDE_ENV_FILE"
echo "Session: $CLAUDE_SESSION_ID"

这些是 Claude Code 自动注入的,跨平台可用。

方式 2:host 系统的环境变量

hook 命令在 shell 上下文跑,能直接读到 host 环境变量:

#!/bin/bash
echo "USER=$USER"
echo "PATH=$PATH"

方式 3:HTTP hook 传 header(带白名单)

{
  "type": "http",
  "url": "https://example.com/hook",
  "headers": {
    "Authorization": "Bearer $MY_TOKEN"
  },
  "allowedEnvVars": ["MY_TOKEN"]
}

$MY_TOKEN 会被替换为环境变量值,但只在你白名单里的变量才生效。

如果还有问题没覆盖到,直接扔评论区或者邮件,我看到就回。

十四、写在最后:hooks 是"AI + 确定性"的混合架构

回过头看,hooks 真正的设计哲学不是"加个自动化脚本",而是承认一个事实:

LLM 永远会有概率失误,关键路径必须有"机械的兜底层"。

医疗 AI 误诊时不能靠"再训一遍模型",必须有"人 review"的环节。自动驾驶 AI 看到行人时不能靠"概率足够高",必须有"硬刹车"的逻辑。Claude 写代码同理——

重要的不是"Claude 记不记得",而是"如果它忘了,系统能不能兜住"。

Hooks、Subagents、Permissions、CLAUDE.md,这些 Claude Code 的设计元素本质上都在做同一件事:把"AI 应该做的"和"系统必须保证的"分开。

AI 做创造性的部分(写代码、判断意图、生成方案),hooks 做确定性的部分(格式化、拦截敏感文件、审计日志)。两个层各司其职。

下次再遇到"Claude 又忘了"的场景,别再写 prompt 劝告了——加个 hook,让它没法忘。

参考链接:

阅读量: 349