Claude Code Hooks:给 AI 助手装上"安全带"
先说一个真实的痛
你让 AI 帮忙清理项目,它顺手执行了 rm -rf ./node_modules------结果路径拼错,把整个项目目录删了。或者你让它改了三个文件,改完之后忘了跑 lint,提交上去 CI 红了一片。
这些问题本质上都是同一个矛盾:AI 很能干,但它没有你项目里的"规矩" 。它不知道哪些命令不能跑,不知道改完代码要格式化,不知道你的项目架构长什么样。
Hooks 就是解决这个矛盾的机制。它让你在 AI 工作流的关键节点上,插入自己写的逻辑。不是改变 AI 本身,而是在它行动之前/之后,加上你的检查、你的自动化、你的上下文。
一个直觉性的理解: Hooks 之于 Claude Code,类似 Git Hooks 之于 Git。pre-commit 在提交前跑 lint,PreToolUse 在工具执行前做安全检查------思路完全一样。
两套 Hooks,两种玩法
Claude Code 有两套 Hooks 系统,面向不同的使用场景:
| CLI Hooks(配置文件) | SDK Hooks(代码回调) | |
|---|---|---|
| 在哪配置 | settings.json 里写 JSON |
Python / TypeScript 代码里注册函数 |
| 能做什么 | 跑 Shell 脚本、发 HTTP 请求、调 LLM 做判断 | 任意代码逻辑 |
| 适合谁 | 日常开发的开发者 | 构建自定义 AI 应用的人 |
| 覆盖事件 | 核心子集 | 全部 20 种 |
大多数人的起点是 CLI Hooks ------在项目里加一个 .claude/settings.json,写几行配置就能跑。SDK Hooks 是给更复杂的场景准备的。这篇文章两条线都会讲到,但主线是 CLI。
全部 Hook 事件一览
20 种事件,分成四类,先有个全貌:
工具调用(最常用)
| 事件 | 什么时候触发 | 能干什么 |
|---|---|---|
PreToolUse |
工具执行前 | 阻止执行、改参数、注入上下文 |
PostToolUse |
工具执行成功后 | 注入提示、改输出 |
PostToolUseFailure |
工具执行失败后 | 错误处理 |
PostToolBatch |
一批工具全跑完后 | 批量后处理 |
用户交互
| 事件 | 什么时候触发 | 能干什么 |
|---|---|---|
UserPromptSubmit |
用户发消息时 | 改 prompt、注入上下文 |
Notification |
发通知时 | 自定义通知行为 |
PermissionRequest |
请求权限时 | 自定义权限决策 |
会话生命周期
| 事件 | 什么时候触发 | 能干什么 |
|---|---|---|
SessionStart |
会话启动时 | 预加载环境、装依赖 |
SessionEnd |
会话结束时 | 清理、生成报告 |
Stop |
Claude 完成回复时 | 后处理、判断是否真的做完了 |
SubagentStart / SubagentStop |
子代理启停时 | 初始化、收集结果 |
Setup |
初始化阶段 | 配置加载 |
其他高级事件
| 事件 | 什么时候触发 |
|---|---|
PreCompact |
上下文压缩前(可以趁机塞重要信息) |
TeammateIdle |
协作队友空闲时 |
TaskCompleted |
任务完成时 |
ConfigChange |
配置变更时 |
WorktreeCreate / WorktreeRemove |
创建/删除 worktree 时 |
MessageDisplay |
消息渲染时 |
PostToolBatch 和 MessageDisplay 目前只有 TypeScript SDK 支持。CLI Hooks 覆盖的是最常用的核心事件。
CLI Hooks 怎么配
配置文件放在哪
三个层级,优先级从低到高:
js
~/.claude/settings.json # 你个人的全局配置,所有项目生效
.claude/settings.json # 项目级,可以提交到 Git,团队共享
.claude/settings.local.json # 项目本地,不提交 Git,个人覆盖用
一个经验: 安全策略、格式化规则放项目级(团队一起用);个人偏好的通知方式放用户级。
基本结构
js
{
"hooks": {
"<HookEvent>": [
{
"matcher": "Bash|Edit",
"hooks": [
{
"type": "command",
"command": "your-script.sh"
}
]
}
]
}
}
关键点:matcher 决定了这个 hook 在哪些工具上触发。支持正则:
js
"matcher": "Bash" // 只匹配 Bash 工具
"matcher": "Edit|Write" // 匹配编辑和写入
"matcher": "mcp__github__.*" // 匹配所有 GitHub MCP 工具
// 不写 matcher → 所有工具都触发
还有一个更精细的 if 字段,按工具参数过滤:
js
{
"type": "command",
"if": "Bash(rm *)",
"command": ".claude/hooks/block-rm.sh"
}
意思是只有 Bash 工具执行的命令以 rm 开头时,才触发这个 hook。👇 这个功能在做安全策略时非常实用。
三种 Hook 类型
Command(最常用)
执行一个 Shell 命令。输入通过 stdin 传 JSON,输出通过 stdout 返回。
js
{
"type": "command",
"command": "bash .claude/hooks/my-hook.sh",
"timeout": 60,
"async": false
}
timeout:超时秒数,默认 60async:设为true就不阻塞 Claude,它在后台跑
HTTP
给远程服务发请求。适合接审计系统、安全策略服务之类的外部系统。
js
{
"type": "http",
"url": "http://localhost:8080/hooks/pre-tool-use",
"timeout": 30,
"headers": {
"Authorization": "Bearer $MY_TOKEN"
},
"allowedEnvVars": ["MY_TOKEN"]
}
注意:allowedEnvVars 是一个安全白名单,只有在这里声明的环境变量才可以在 headers 里用。还需要在 settings 里加一级声明:
js
{
"httpHookAllowedEnvVars": ["MY_TOKEN", "HOOK_SECRET"]
}
HTTP hook 默认非阻塞------请求失败不会阻止 Claude 继续工作。
Prompt(让 LLM 做判断)
把 hook 的输入扔给一个轻量模型(默认 Haiku),让它决定该不该继续。适合需要"理解语义"的场景。
js
{
"type": "prompt",
"prompt": "判断 Claude 是否应该停止:$ARGUMENTS。检查所有任务是否真的完成了。",
"model": "claude-haiku-4-5-20251001",
"timeout": 30
}
$ARGUMENTS 会被替换成 hook 的 JSON 输入。用 Haiku 是因为便宜且快,做这类简单判断足够了。
Hook 的输入和输出
这是理解 Hooks 的关键------数据怎么进,怎么出。
输入(stdin 收到的 JSON)
每个 hook 都会通过 stdin 收到一个 JSON 对象:
js
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../session.jsonl",
"cwd": "/Users/.../my-project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}
不同事件会带不同的字段:
| 字段 | 哪些事件有 | 说明 |
|---|---|---|
session_id |
全部 | 当前会话 ID |
transcript_path |
全部 | 会话记录文件路径 |
cwd |
全部 | 当前工作目录 |
tool_name |
工具相关事件 | 被调用的工具名 |
tool_input |
工具相关事件 | 工具的输入参数 |
tool_output |
PostToolUse |
工具执行结果 |
prompt |
UserPromptSubmit |
用户发的消息 |
source |
SessionStart |
启动来源:startup / resume / clear / compact |
输出(stdout 返回的 JSON)
Hook 脚本的标准输出可以返回 JSON 来影响 Claude 的行为。什么都不输出 + 退出码 0 = "没事,继续"。
PreToolUse 的输出是最强大的,可以控制权限、改参数:
js
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "不允许删除 node_modules 以外的目录",
"updatedInput": {
"file_path": "/sandbox/new-path.ts"
},
"additionalContext": "当前环境: production,请谨慎操作"
}
}
| 字段 | 说明 |
|---|---|
permissionDecision |
allow(放行)、deny(阻止)、ask(问用户)、defer(交给默认流程) |
permissionDecisionReason |
原因,Claude 会看到 |
updatedInput |
替换后的工具参数(比如改文件路径) |
additionalContext |
额外上下文,Claude 下一步会参考 |
PostToolUse 和 UserPromptSubmit 的输出简单一些,主要是注入 additionalContext。
退出码
| 退出码 | 含义 |
|---|---|
0 |
正常,没有特殊决策 |
2 |
阻止操作 (只在 PreToolUse 有效) |
| 其他非零 | 出错了,但不会阻止 |
💡 一个细节: 退出码 2 和 JSON 里的 permissionDecision: "deny" 都能阻止操作。区别在于退出码 2 是"快速拒绝",写脚本时一行 exit 2 搞定;JSON 方式更灵活,能附带原因和修改建议。
Agent SDK Hooks
如果你在构建自己的 AI 应用(而不是日常写代码),可以在代码里直接注册 hook 回调。这比 CLI hooks 灵活得多------完整编程能力,想干什么都行。
Python 写法
js
from claude_agent_sdk import query, ClaudeAgentOptions, HookMatcher, HookContext
from typing import Any
async def validate_bash(input_data: dict, tool_use_id: str | None, context: HookContext) -> dict:
"""阻止 rm -rf"""
if input_data["tool_name"] == "Bash":
command = input_data["tool_input"].get("command", "")
if "rm -rf" in command:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "危险命令已阻止",
}
}
return {}
async def log_usage(input_data: dict, tool_use_id: str | None, context: HookContext) -> dict:
"""记录所有工具调用"""
print(f"[AUDIT] {input_data.get('tool_name')}")
return {}
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[validate_bash]),
HookMatcher(hooks=[log_usage]),
],
"PostToolUse": [HookMatcher(hooks=[log_usage])],
}
)
async for message in query(prompt="重构 auth 模块", options=options):
print(message)
TypeScript 写法
js
import { query, type HookInput, type HookJSONOutput } from "@anthropic-ai/claude-agent-sdk";
const auditBash = async (input: HookInput): Promise<HookJSONOutput> => {
if (input.hook_event_name !== "PreToolUse") return {};
const toolInput = input.tool_input as { command?: string };
if (toolInput.command?.includes("rm -rf")) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "危险命令已阻止",
},
};
}
return {};
};
for await (const message of query({
prompt: "重构 auth 模块",
options: {
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [auditBash] }],
},
},
})) {
if (message.type === "result" && message.subtype === "success") {
console.log(message.result);
}
}
SDK Hooks 和 CLI Hooks 的核心区别
- SDK 覆盖全部 20 种事件,CLI 只有核心子集
- SDK 是代码,能查数据库、调 API、做复杂逻辑
- SDK 可以链式调用,多个 HookMatcher 按顺序执行
- CLI 更轻量,写个 Shell 脚本就能跑,学习成本最低
实战:九个真实场景
理论讲完了,下面是真正有用的部分。
场景 1:阻止危险命令 ✅
问题: AI 执行了不该执行的删除命令。
配置:
js
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": ".claude/hooks/block-rm.sh"
}
]
}
]
}
}
js
#!/bin/bash
command=$(jq -r '.tool_input.command' < /dev/stdin)
if [[ "$command" == *"rm -rf"* ]]; then
echo "Blocked: rm -rf commands are not allowed" >&2
exit 2 # 退出码 2 = 阻止执行
fi
exit 0
为什么用 if 过滤: 没必要让每次 Bash 调用都跑这个脚本。加了 if: "Bash(rm *)" 之后,只有命令以 rm 开头时才触发,其他命令零开销。
场景 2:自动格式化
问题: AI 改完代码不跑格式化,代码风格不一致。
js
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
一行配置,从 stdin 读文件路径,喂给 prettier。改完就格式化,不用你操心。
场景 3:后台跑测试
问题: 想让 AI 改完代码自动跑测试,但不想让它等测试跑完才继续干活。
js
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests.sh",
"async": true,
"timeout": 300
}
]
}
]
}
}
async: true 是关键。Claude 改完文件立刻继续写下一个,测试在后台跑。测试失败了,结果会在后面的对话中通知到。
场景 4:按关键词自动注入上下文
这个是我最推荐的一个用法。
问题: 每次都要手动告诉 AI "你去看看 docs/frontend 下的文档",很烦。
思路: 让 hook 分析用户消息里的关键词,自动把对应的文档喂给 AI。
js
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/context-router.sh"
}
]
}
]
}
}
js
#!/bin/bash
PROMPT=$(cat)
FRONTEND_KEYWORDS="前端|frontend|react|vue|css|component|UI|页面"
BACKEND_KEYWORDS="后端|backend|api|server|database|数据库|接口|service"
CONTEXT=""
if echo "$PROMPT" | grep -qiE "$FRONTEND_KEYWORDS"; then
DOCS=$(find ./docs/frontend -name "*.md" -exec cat {} ; 2>/dev/null | head -c 8000)
CONTEXT="检测到前端任务,已加载相关文档:\n$DOCS"
fi
if echo "$PROMPT" | grep -qiE "$BACKEND_KEYWORDS"; then
DOCS=$(find ./docs/backend -name "*.md" -exec cat {} ; 2>/dev/null | head -c 8000)
CONTEXT="${CONTEXT}\n检测到后端任务,已加载相关文档:\n$DOCS"
fi
if [ -n "$CONTEXT" ]; then
jq -n --arg ctx "$CONTEXT" '{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": $ctx}}'
fi
本质上是把"你该去读这个"变成"我已经读了,在这儿"。 AI 省了搜索的时间,你省了提醒的口水。
场景 5:桌面通知
问题: AI 在后台跑一个长任务,你想在它需要你的时候知道。
js
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification "Claude Code 需要你的关注" with title "Claude Code"'"
}
]
}
]
}
}
macOS 用 osascript,Linux 换成 notify-send "Claude Code" "需要你的关注"。简单直接。
场景 6:会话启动时预加载
问题: 每次新开会话都要手动装依赖、初始化环境。
js
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": ""$CLAUDE_PROJECT_DIR"/scripts/bootstrap.sh"
}
]
}
]
}
}
matcher 这里过滤的是启动来源:startup 是全新会话,resume 是恢复会话。你可以选择只在全新会话时跑初始化。
场景 7:审计日志
问题: 需要记录 AI 执行了哪些 Shell 命令,用于安全审计。
js
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> ~/.claude/bash-audit.log"
}
]
}
]
}
}
这个 hook 什么都不返回,只是静默地把命令追加到日志文件。对 Claude 的行为完全透明。
场景 8:让 AI 自己判断该不该停
问题: AI 说"我做完了",但其实还差一个收尾步骤没做。
js
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "评估 Claude 是否应该停止:$ARGUMENTS。检查所有任务是否真的完成了。如果还有未完成的工作,输出阻止决策。"
}
]
}
]
}
}
用 Prompt 类型,让一个轻量 LLM 审查"AI 说做完了"这个决策。本质上是一个低成本的二次检查。Haiku 的调用成本很低,做这种判断刚好。
场景 9:沙箱路径重定向
问题: 在沙箱环境里跑 AI,需要把所有文件写入操作重定向到安全目录。
js
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/redirect-to-sandbox.sh"
}
]
}
]
}
}
js
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
echo "$INPUT" | jq --arg newpath "/sandbox${FILE_PATH}" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"file_path": $newpath
}
}
}'
updatedInput 替换了原始的文件路径,AI 以为自己写到了 /src/app.ts,实际写到了 /sandbox/src/app.ts。
几条踩过的坑
Hook 脚本要快
Hook 默认是同步阻塞的。你的脚本跑 10 秒,Claude 就干等 10 秒。尽量控制在 5 秒以内。 耗时的操作(测试、构建、部署)务必设 async: true。
用 jq 处理 JSON
stdin 传入的是 JSON。在 Shell 脚本里,jq 是最顺手的工具:
js
TOOL_NAME=$(jq -r '.tool_name')
COMMAND=$(jq -r '.tool_input.command')
FILE_PATH=$(jq -r '.tool_input.file_path')
出错不等于阻止
Hook 脚本崩了,默认不会 阻止 Claude 继续。这是一个设计选择------hook 是辅助,不是瓶颈。如果你希望出错时阻止操作,显式返回 exit 2 或在 JSON 里设 permissionDecision: "deny"。
别让 Hook 做 AI 该做的事
Hook 擅长确定性的自动化 :格式化、阻止、日志、路径重写。不确定的判断("这段代码写得怎么样"、"该不该用这个方案")交给 AI 本身或者 Prompt Hook。别用 Shell 脚本写一个半吊子的代码审查器。
小结
Hooks 解决的核心问题是:怎么让 AI 助手遵守你项目里的规矩。
它能做的事:
- 安全:阻止危险命令、沙箱重定向
- 质量:自动格式化、lint、测试
- 效率:上下文自动注入、环境预加载
- 可观测:审计日志、桌面通知
- 智能判断:LLM 做二次检查
从哪里开始?我建议先从场景 2(自动格式化) 或 场景 4(上下文注入) 入手。这两个配置简单、效果明显,能让你马上感受到 Hooks 的价值。
参考资源