Hooks——用确定性脚本给 Agent 上护栏
- 理解 Hook 机制的核心价值:用确定性脚本填补 AI 概率性执行的空隙
- 掌握 Claude Code Hooks 的触发时机和配置格式,能写出可运行的 hook 配置
- 通过两个真实例子(格式化 hook、危险操作拦截 hook)建立落地感
- 区分 Hook 和规则文件(CLAUDE.md / .cursorrules)的适用场景,知道什么时候该用哪个
有个场景你可能遇到过:在 CLAUDE.md 里写了"改完代码一定要跑 lint",结果 Claude Code 有时跑,有时忘,有时说"这次改动很小,不需要跑"——然后 CI 挂了。
这不是 AI 偷懒,这是 AI 的本质决定的。AI 是概率性的,提示词是建议,不是合同。你加多少个"一定""必须""务必",也不能保证每次都执行。
Hooks 解决的就是这个问题:把"应该做的事"从提示词里拿出来,变成由你写的脚本在固定时机强制触发。脚本不会忘,不会判断"这次情况特殊",不会被上下文带偏。
Hooks 是什么
Hook 是 Agent 工具(以 Claude Code 为代表)在执行过程中暴露出来的脚本触发点。当 Agent 到达某个特定节点,工具会自动调用你预先配置的命令或脚本,不需要 AI 主动"记得"去做。
概念上和 git hooks 很像:你在 .git/hooks/pre-commit 放一个脚本,每次 commit 前它就跑——不管你这次 commit 是大改还是改一个标点,脚本都跑。Claude Code Hooks 的思路完全一样,只不过触发时机变成了 AI Agent 的操作节点。
常见的 hook 触发场景包括:
- 改完文件之后 → 自动格式化(prettier / gofmt / black)
- 提交代码之前 → 强制跑 lint 和测试,不通过就阻断
- 执行某类工具之前 → 检查操作是否涉及敏感路径,危险的直接拦截
- Agent 会话结束时 → 记日志、发通知、清理临时文件
核心价值在于:你写的脚本是确定性的,AI 是概率性的。 把必须做的事交给脚本,把需要判断的事交给 AI,各司其职。
为什么光靠提示词不够
在 CLAUDE.md 和 cursor rules 里写规则是第一道防线,但有天然上限。
提示词的问题:
- AI 会推理。"这次只改了注释,lint 应该没问题"——这个推理在它看来完全合理,所以跳过了。
- 上下文越长越容易漂移。一个 50 轮对话里,第 3 轮写的"记得跑测试"到第 40 轮可能已经不在 AI 的注意力范围内。
- 规则是建议,不是约束。你没有办法"强制" AI 通过提示词做某件事,它只能尽力遵守。
换句话说:提示词管的是意图,Hook 管的是行为。两者不是替代关系,而是互补的——规则文件说"你应该干什么",Hook 确保"这件事必然发生"。
Claude Code 的 Hook 机制
触发时机
Claude Code 目前支持以下几个 hook 时机(以官方文档为准,版本迭代可能调整):
| Hook 名称 | 触发时机 |
|---|---|
PreToolUse |
Agent 调用任何工具之前(比如写文件、运行命令) |
PostToolUse |
Agent 调用任何工具之后 |
Notification |
Claude Code 发送通知时(如等待用户输入) |
Stop |
Agent 完成任务、准备退出时 |
其中 PreToolUse 和 PostToolUse 是最常用的,因为 Claude Code 的核心操作(写文件 Write、执行命令 Bash、编辑 Edit)都是工具调用,可以在这两个时机拦截或后处理。
拦截逻辑:如果你的 hook 脚本退出码不为 0,Claude Code 会把脚本的输出当作错误信息反馈给 AI,让它知道"这个操作被拒绝了,原因是 XXX"。这是实现"危险操作拦截"的关键机制。
配置位置
Hook 写在项目级配置或用户级配置里。以 Claude Code 为例,配置文件路径:
- 项目级:
.claude/settings.json(跟着项目走,适合团队共用) - 用户级:
~/.claude/settings.json(本地生效,适合个人偏好)
配置格式如下(以官方文档格式为准):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "your-script.sh"
}
]
}
]
}
}
字段说明:
matcher:用正则匹配工具名,决定这个 hook 响应哪些工具调用type:目前主要是command,即运行一条 shell 命令command:要执行的命令,可以是脚本路径,也可以是内联命令
两个真实 Hook 例子
例子 1:改完文件自动格式化
场景:项目用 Prettier 做格式化。每次让 Claude Code 改文件,都要提醒它"改完 prettier 格式化一下",时不时忘、时不时格式不一致,PR 里一堆格式 diff。
目标:Claude Code 用 Write 或 Edit 改完文件,自动对改动的文件跑 Prettier,不需要 AI 记得,也不需要你手动跑。
配置(.claude/settings.json):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\" 2>&1 || true"
}
]
}
]
}
}
说明几个细节:
$CLAUDE_TOOL_INPUT_FILE_PATH是 Claude Code 传给 hook 的环境变量,代表刚才被修改的文件路径。可用变量清单以官方文档为准。|| true确保 Prettier 报错时(比如文件类型不支持)hook 不影响正常流程——格式化失败不应该中断 Claude Code 的工作。- 这个 hook 是
PostToolUse,在文件写完之后触发,对改动内容不做拦截。
效果:Claude Code 每次写文件,Prettier 在后台默默跑一遍,代码风格始终统一,你的 diff 里再也没有格式噪音。
例子 2:拦截危险的删除操作
场景:有一个重要的配置目录 config/prod/,绝对不能让 AI 误删。让 Claude Code 做清理任务时,偶尔它会把这个目录一起删掉,因为它认为"这是旧配置"。
目标:在 Claude Code 执行任何 Bash 命令之前,检查命令里是否涉及 config/prod/,如果有删除操作就立刻拒绝。
拦截脚本(scripts/guard-prod-config.sh):
#!/bin/bash
# 守护 config/prod 目录,拦截涉及该目录的删除操作
# Claude Code 通过环境变量传入命令内容:CLAUDE_TOOL_INPUT_COMMAND
COMMAND="${CLAUDE_TOOL_INPUT_COMMAND}"
# 检查是否是删除操作,且涉及 config/prod
if echo "$COMMAND" | grep -qE '(rm|rmdir|git rm|unlink)' && \
echo "$COMMAND" | grep -q 'config/prod'; then
echo "[HOOK BLOCKED] 检测到对 config/prod/ 的删除操作,已拦截。"
echo "如确需删除,请绕过 hook 手动执行,或在 ops 频道确认后操作。"
exit 1 # 非零退出码 = 拒绝这次工具调用
fi
exit 0 # 放行
配置(.claude/settings.json):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash scripts/guard-prod-config.sh"
}
]
}
]
}
}
关键点:
PreToolUse+ 退出码 1 = 硬拦截,Claude Code 不会执行这条命令,并把错误信息反馈给 AI。- AI 收到错误信息后会解释"为什么被拦截",通常会重新想方案而不是强行绕过。
- 脚本里的注释是给人看的,解释清楚为什么拦截、怎么绕过(留了正规出口,不是死堵)。
这个模式也可以扩展到:拦截对 .env 文件的读取、阻止 DROP TABLE 这类 SQL、禁止访问 /etc/ 下的系统文件。
Hook 和规则文件的区别
很多人开始时把这两个混用,用规则文件写强制要求、用 hook 写建议——两者用反了。
| 维度 | 规则文件(CLAUDE.md / .cursorrules) | Hooks |
|---|---|---|
| 本质 | 提示词,AI 会读、会理解、会遵守(大概率) | 脚本,工具链强制触发,不经过 AI |
| 强制性 | 软约束,AI 可以"推理"跳过 | 硬约束,脚本跑不过就是跑不过 |
| 适用内容 | 代码风格偏好、回复语言、任务流程 | lint、格式化、安全检查、危险拦截 |
| 出错后果 | AI 忘了执行,你发现了再补救 | 操作被拒绝,AI 看到错误信息重新想方案 |
| 维护成本 | 低,自然语言写 | 中,需要写和维护脚本 |
判断用哪个的简单规则:
- 这件事如果 AI 偶尔没做,能接受吗?能接受 → 规则文件,不能接受 → Hook。
- 这件事依赖具体环境(比如"文件路径存不存在")?依赖 → Hook 里用脚本判断,不依赖 → 规则文件写清楚就行。
具体的规则文件写法和项目记忆,参考 如何写 CLAUDE.md 和 cursor rules。代码质量和安全审查的整体思路,见 AI 代码质量与安全 review。
故障排查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| Hook 脚本一直不触发 | matcher 写错,没匹配到工具名 |
用 "matcher": ".*" 先匹配所有工具,确认 hook 框架本身生效;再缩小 matcher |
| Hook 触发了但环境变量是空 | 变量名拼错,或该 hook 时机不传这个变量 | 查官方文档确认对应时机可用的变量;在脚本里 `env |
| 脚本退出码是 1,但 Claude Code 没有拦截 | 用的是 PostToolUse 而非 PreToolUse |
拦截必须用 PreToolUse,后置 hook 的退出码不阻断操作 |
| Hook 脚本报"Permission denied" | 脚本没有可执行权限 | chmod +x scripts/your-hook.sh;Windows 下注意行尾符(CRLF 会导致 bash 解析失败) |
| 格式化 hook 把文件改乱了 | Prettier 版本和项目配置不匹配 | 先在项目根目录手动跑 npx prettier --write src/ 验证格式化结果符合预期,再加 hook |
| 拦截 hook 误杀正常操作 | 正则太宽泛 | 在脚本里加调试输出 echo "COMMAND: $CLAUDE_TOOL_INPUT_COMMAND",看实际触发时命令内容,收窄匹配条件 |
工程实践建议
把 hook 脚本版本化。放在项目的 scripts/ 或 .claude/hooks/ 目录下,纳入 git 管理。hook 脚本是项目工程规范的一部分,应该跟着代码走,不应该只活在本地。
先 || true 再去掉。新写一个 hook 时,先加 || true 让它不影响流程,跑几天观察触发情况和输出,确认逻辑符合预期后再去掉,变成真正的阻断。直接上拦截 hook 容易误杀正常操作。
不要在 hook 里做重活。Hook 是同步触发的,脚本跑多久 Claude Code 就等多久。格式化单个文件、跑几条 lint 规则可以;完整跑测试套件、拉取远程数据不合适——那些放 CI 里。
配合 SDD 工作流。在 规格驱动开发(SDD) 里,Hooks 是"实施阶段"的安全网:规格文件定义了"做什么",Hooks 保证"不做什么"(不破坏生产配置、不跳过格式化)。两者配合,才是真正有工程纪律的 AI 编程流程。
常见问题
Q:我用 Cursor 而不是 Claude Code,也有类似机制吗?
Cursor 目前没有原生的 Hook 机制,但可以结合 git hooks 和 .cursorrules 来部分实现——在 .cursorrules 里写强规则,配合 pre-commit hook 做 lint/格式化。效果不如 Claude Code Hooks 彻底,但也比纯靠提示词可靠。
Q:hook 脚本可以调用 AI 吗?比如让另一个 AI 来审查?
技术上可以,但要慎重:每次工具调用都触发 AI 审查,速度会很慢,成本也高。实际项目里,hook 里调用 AI 适合用在高风险操作的二次确认(比如批量删除前用 AI 解释一遍操作意图),而不是每次写文件都触发。
Q:团队多人用同一个项目配置,hook 脚本里的路径怎么处理?
用相对路径或脚本所在目录的 $0,避免硬编码绝对路径。如果 hook 依赖本地工具(比如 prettier 必须在本地 node_modules 里),在项目 README 或 CLAUDE.md 里写清楚依赖前置条件,否则新同学 clone 项目后 hook 莫名其妙报错,不知道去哪查。
Q:可以动态决定 hook 的行为吗?比如生产环境拦截、开发环境放行?
可以。脚本里读环境变量 $NODE_ENV 或自定义的 $CLAUDE_ENV,分支处理。配置文件里设置对应的环境变量,或在 hook 命令里传参。比如:NODE_ENV=production bash scripts/guard-prod-config.sh。
👉 看看我们的 AI 编程实战体系课,或逛 AI 编程教程大全 把基本功打扎实。