本教程带你为 Claude Code 装配一套确定性安全护栏:用 PreToolUse 钩子在命令执行前拦截 rm -rf、git push --force、DROP TABLE 等危险操作,用 PostToolUse 钩子在文件写完后自动跑 Prettier/Black/gofmt 格式化。完成本教程后,你将拥有一份可直接复制进任何工程的 .claude/settings.json + 三个钩子脚本,让 AI 在 --dangerously-skip-permissions 自主模式下也守规矩。适合已经写过第一个 Claude Code 项目、想把 AI 接入真实工程或 CI 流水线的开发者,预计动手 40–60 分钟。
claude --version 可正常输出,建议 2.0.10 及以上,以支持 updatedInput 改写工具输入)jq(钩子解析 JSON 的核心依赖,jq --version 验证)npx prettier 或 python -m black 任一即可很多人在 CLAUDE.md 里写「永远不要删除 .env」「每次改完文件都要跑 Prettier」。Claude 大多数时候会照做,但「大多数时候」在自主模式下远远不够——一次遗漏就可能把生产密钥覆盖、把锁文件改乱、把半成品代码推进主分支。
钩子(Hooks)和 CLAUDE.md 的根本区别在于确定性:
| 维度 | CLAUDE.md | Hooks(钩子) |
|---|---|---|
| 性质 | 自然语言建议 | 事件触发的 Shell 命令 |
| 执行 | 靠 LLM 自觉,偶尔跳过 | 每次工具调用必定触发 |
| 能力 | 只能"提醒" | 能阻止/改写/反馈工具调用 |
| 适合 | 风格、约定、背景 | 安全护栏、强制规范 |
一句话:CLAUDE.md 提供指南,Hooks 提供护栏。 自主化(-p 批量、auto 模式、CI 机器人)的安全感,全部建立在这层护栏之上。
理解钩子的关键,是看懂 Claude Code 每次工具调用经历的生命周期。截至 2026 年初,Claude Code 已从最初的 6 个钩子事件扩展到 17 个,但日常工程里 90% 的护栏只用两个:
PreToolUse:工具执行前触发。能拦截、能放行、能改写输入。PostToolUse:工具执行后触发。能跑格式化、能回传反馈。钩子和工具之间,靠两个"信号"通信:
tool_name、tool_input(命令/文件路径)等推给钩子脚本。PreToolUse 最重要的字段是:{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "rm -rf 已被安全护栏拦截"
}
}
permissionDecision 有三个取值:deny(直接拒绝)、allow(直接放行,跳过权限询问)、ask(仍走正常权限询问)。这正是我们造护栏的"决策器"。
sequenceDiagram
participant U as 用户/Claude
participant H as Hook 系统
participant Pre as PreToolUse 钩子
participant T as 工具(Bash/Edit)
participant Post as PostToolUse 钩子
U->>H: 发起工具调用
H->>Pre: 推送 JSON(tool_name, tool_input)
alt 返回 deny
Pre-->>H: permissionDecision: deny
H-->>U: 🛑 阻止执行 + 原因回传
else 返回 allow 或 无输出
Pre-->>H: 放行
H->>T: 执行工具
T-->>H: 工具结果
H->>Post: 推送 JSON(tool_response)
Post-->>H: 格式化/反馈(可选)
H-->>U: 返回结果
end
我们先搭一个最小可运行的工程骨架:
my-guarded-app/
├── .claude/
│ ├── settings.json # 双钩配置 + 权限 deny 清单
│ └── hooks/
│ ├── block-dangerous.sh # PreToolUse:拦危险命令
│ ├── protect-files.sh # PreToolUse:护敏感文件
│ └── format-file.sh # PostToolUse:写完自动格式化
├── CLAUDE.md
├── package.json # 便于演示 prettier
└── src/app.js
提示:钩子脚本放在
.claude/hooks/而非~/.claude/hooks/,好处是随仓库版本化,团队成员 clone 下来即用。命令里用$CLAUDE_PROJECT_DIR解析项目根路径,跨平台更稳。
新建 .claude/hooks/block-dangerous.sh:
#!/usr/bin/env bash
# PreToolUse 钩子:拦截危险 Bash 命令
set -euo pipefail
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
command=$(echo "$input" | jq -r '.tool_input.command // ""')
# 非 Bash 工具直接放行
[ "$tool_name" != "Bash" ] && exit 0
blocked=false
reason=""
# 1) rm -rf(覆盖 -rf / -fr / -rvf 等各种 flag 排列)
if echo "$command" | grep -qE 'rm\s+.*(-[a-zA-Z]*r[a-zA-Z]*f|-rf|-fr)\b'; then
blocked=true; reason="rm -rf 已被安全护栏拦截,请改用逐项删除"
fi
# 2) 强制推送——别让 AI 把历史改飞
if echo "$command" | grep -qE 'git\s+push.*--force|git\s+push.* -f\b'; then
blocked=true; reason="禁止 force push,请用普通 git push"
fi
# 3) 数据库毁灭性操作
if echo "$command" | grep -qiE '\bDROP\s+(TABLE|DATABASE)\b'; then
blocked=true; reason="禁止 DROP,请改用软删除或迁移脚本"
fi
if [ "$blocked" = true ]; then
cat <<EOF
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"$reason"}}
EOF
exit 0
fi
exit 0
要点:
- cat 读 stdin 拿到 JSON,jq 解析。// "" 是 jq 的"空值兜底",缺字段也不报错。
- 命中危险模式就打印 deny JSON 并 exit 0。注意是 exit 0 不是 exit 2——因为我们用结构化 JSON 表达决策,退出码保持 0 即可;Claude Code 会读 stdout 里的 permissionDecision。
- set -euo pipefail 保证脚本健壮,但若你的规则链很长、担心某条 grep 失败影响整体,可酌情去掉 -e。
赋予执行权限:
chmod +x .claude/hooks/block-dangerous.sh
钩子本质是个吃 JSON、吐 JSON 的命令行程序,完全可以在不启动 Claude 的情况下独立测试。把模拟的工具调用喂给它,看决策对不对。下面是我实际跑出来的结果:
# T1 危险:rm -rf
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/test"}}' \
| bash .claude/hooks/block-dangerous.sh
# 输出: {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny",
# "permissionDecisionReason":"rm -rf 已被安全护栏拦截"}}
# T2 安全:ls -la(应无输出、exit 0,表示放行)
echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
| bash .claude/hooks/block-dangerous.sh; echo "exit=$?"
# 输出: exit=0 (无 stdout,放行)
# T3 危险:git push --force
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
| bash .claude/hooks/block-dangerous.sh
# 输出: ...permissionDecision:"deny",..."禁止 force push..."
# T4 危险:DROP TABLE
echo '{"tool_name":"Bash","tool_input":{"command":"mysql -e \"DROP TABLE users\""}}' \
| bash .claude/hooks/block-dangerous.sh
# 输出: ...permissionDecision:"deny",..."禁止 DROP..."
# T5 安全:git push origin main(普通推送,应放行)
echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \
| bash .claude/hooks/block-dangerous.sh; echo "exit=$?"
# 输出: exit=0
五个用例全部符合预期:三个危险操作被精准 deny,两个安全操作静默放行。这种"喂 JSON 验证"的方式,让你在接入真实会话前就能 100% 确认护栏行为——这是把钩子用稳的关键习惯。
排错锦囊:如果钩子没按预期触发,按这个顺序查——① 脚本是否
chmod +x;②jq是否在 PATH;③ 用claude --debug启动能看到完整的钩子执行日志;④/hooks命令可列出当前所有已加载钩子及其来源,确认你的配置被识别。
危险命令之外,第二大类事故是 AI 直接改写了不该碰的文件:环境变量、密钥、锁文件、CI 配置、Terraform 状态。第二个钩子盯住 Edit|Write|MultiEdit 三类写操作。
新建 .claude/hooks/protect-files.sh:
#!/usr/bin/env bash
# PreToolUse 钩子:保护敏感文件不被 AI 改写
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
# 只关心写文件类工具
case "$tool_name" in
Edit|Write|MultiEdit) ;;
*) exit 0 ;;
esac
file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
# 受保护清单(用 | 分隔的 grep -E 模式)
protected='(^|/)\.env(\..*)?$|\.env$|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|.*\.lock$|\.gitlab-ci\.yml|\.github/workflows/.*\.yml|terraform\.tfstate'
if echo "$file_path" | grep -qE "$protected"; then
reason="$file_path 受保护,禁止 AI 直接改写。如确需修改请在终端人工操作。"
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
exit 0
fi
exit 0
同样独立测试:
chmod +x .claude/hooks/protect-files.sh
echo '{"tool_name":"Write","tool_input":{"file_path":"/app/.env"}}' \
| bash .claude/hooks/protect-files.sh
# 输出: ...deny..."/app/.env 受保护..."
echo '{"tool_name":"Edit","tool_input":{"file_path":"src/app.js"}}' \
| bash .claude/hooks/protect-files.sh; echo "exit=$?"
# 输出: exit=0 (放行)
格式化靠"提醒 AI"是最不靠谱的——它可能忘、可能用错工具、可能只格式化了一半。PostToolUse 让"每次写文件必格式化"变成物理事实。
新建 .claude/hooks/format-file.sh:
#!/usr/bin/env bash
# PostToolUse 钩子:文件写完后按扩展名自动格式化
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')
# 没路径或文件不存在就静默退出
[ -z "$file_path" ] && exit 0
[ ! -f "$file_path" ] && exit 0
case "$file_path" in
*.js|*.ts|*.jsx|*.tsx|*.json|*.css|*.md)
npx --yes prettier --write "$file_path" >/dev/null 2>&1 || true ;;
*.py)
python3 -m black -q "$file_path" >/dev/null 2>&1 || true ;;
*.go)
gofmt -w "$file_path" >/dev/null 2>&1 || true ;;
esac
exit 0
这里有个关键细节:格式化钩子用 exit 0(静默成功),不要用 exit 2。exit 2 会把 stderr 回喂给 Claude,对格式化这种"副作用型"任务是噪音;我们只想让文件在磁盘上被悄悄改干净,Claude 下次读文件时自然看到规范结果。|| true 保证工具缺失时也不中断流程。
在 .claude/settings.json 写入完整配置。注意我们同时配了 permissions.deny 清单——它和钩子是双保险:deny 清单是 Claude Code 内置的权限层,钩子是额外的脚本层,两者任一拦截都能挡住事故。
{
"permissions": {
"deny": [
"Bash(rm -rf:*)",
"Bash(git push --force:*)",
"Bash(sudo:*)",
"Read(.env)",
"Read(.env.*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-file.sh",
"statusMessage": "正在格式化…"
}
]
}
]
}
}
配置要点解读:
matcher:正则,决定哪些工具触发本钩子。"Bash" 只匹配 Bash;"Edit|Write|MultiEdit" 匹配三种写操作;留空 "" 匹配所有工具。大小写敏感。timeout:钩子超时秒数。超时后钩子被杀、当作非阻塞失败处理(即放行)。简单检查给 5 秒足够,跑 LLM 判断的钩子给 30 秒以上。statusMessage:钩子运行时在界面显示的提示文字。$CLAUDE_PROJECT_DIR:Claude Code 注入的项目根路径变量,比写死绝对路径更可靠。保存后重启 Claude Code,配置在下一个会话生效。
在项目目录启动 claude,让它做点"危险试探",预期效果如下(对照官方文档与社区实测的行为):
> 帮我清理临时目录:rm -rf /tmp/test
⏺ Bash(rm -rf /tmp/test)
⎿ PreToolUse:Bash hook returned blocking error
⎿ Error: rm -rf 已被安全护栏拦截,请改用逐项删除
该命令被安全钩子拦截了(配置见 .claude/hooks/block-dangerous.sh)。
你可以选择:
1. 改用更安全的逐项删除
2. 直接在终端手动执行
3. 临时调整钩子规则
> 把 API key 更新到 .env
⏺ Write(.env)
⎿ 被安全护栏拦截:.env 受保护,禁止 AI 直接改写。
> 重构 src/app.js 里的计算函数
⏺ Edit(src/app.js)
⎿ [文件已修改]
⎿ ⏵ 正在格式化… ← PostToolUse 自动跑了 prettier
✅ 完成,已自动格式化
注意第二个例子:被拦后 Claude 不会硬闯,而是把拒绝原因读进上下文,主动给出替代方案——这正是 permissionDecisionReason 回喂的价值,护栏不只是"挡",还教会 AI"该怎么走"。
| 现象 | 原因 | 解决 |
|---|---|---|
| 钩子完全不触发 | 脚本未 chmod +x / matcher 拼错大小写 |
chmod +x;用 /hooks 确认加载 |
| 安全命令也被拦 | 正则太宽(如 -f 误匹配) |
收紧正则,加单词边界 \b |
| 格式化没生效 | 项目没装 prettier/black | npm i -D prettier 或 pip install black |
| settings.json 改了没反应 | 缺逗号/JSON 非法 | jq . .claude/settings.json 校验语法 |
| 钩子卡住拖慢响应 | timeout 太大 / 脚本里调了交互命令 | 设 timeout;脚本里别用 read 等交互 |
1. 用 updatedInput 改写命令(v2.0.10+)
拦不如改。与其 deny,不如把危险命令改写成安全版再放行:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": { "command": "rm -i file.tmp" }
}
}
把 rm -rf 自动降级成 rm -i,AI 无感、事故归零。
2. 异步钩子 async: true
日志、通知类钩子设 "async": true 后台跑,不阻塞工具调用。适合埋点统计、桌面通知,但不能用来拦截。
3. matcher 正则威力
"Bash(npm test:*)" 这类带参数的匹配是 deny 清单的语法;钩子的 matcher 只认工具名正则。两者配合:deny 管粗粒度白/黑名单,钩子管细粒度内容审查。
4. 子代理也受约束
PreToolUse 钩子对子代理(subagent)发起的工具调用同样生效。这意味着无论你用 Task 工具派出去多少个并行子代理,rm -rf 一律被拦——批量任务的安全感就建立在这里。
5. 防止 Stop 钩子死循环
如果你还会配 Stop 钩子(任务结束时触发),务必检查 stdin 里的 stop_hook_active 字段:为 true 就立即 exit 0,否则会"Stop→让它继续→再次 Stop"无限循环。
6. 三个来源的配置优先级
~/.claude/settings.json(用户全局)<.claude/settings.json(项目共享)<.claude/settings.local.json(本地私有,别提交)。护栏脚本建议放项目级,个人偏好放 local 级。
flowchart TD
A[Claude 决定执行命令] --> B{工具类型?}
B -->|Bash| C[block-dangerous.sh]
C --> D{命中危险模式?}
D -->|是 deny| Z[🛑 拦截 + 原因回喂 Claude]
D -->|否| E[放行执行]
B -->|Edit/Write/MultiEdit| F[protect-files.sh]
F --> G{命中受保护文件?}
G -->|是 deny| Z
G -->|否| E
E --> H[工具执行成功]
H --> I{是否改写了文件?}
I -->|是| J[format-file.sh 自动格式化]
J --> K[✅ 规范结果返回]
I -->|否| K
Z -.-> L[Claude 读到原因<br/>主动给出安全替代方案]
updatedInput 与权限决策:官方 JSON Schema 参考,理解 permissionDecision 三态与输入改写eslint、pytest,做成"改完即验"的流水线command 之外的两种处理器,适合"判断式"校验(如让 LLM 评审 diff)--dangerously-skip-permissions 安全实践:本教程的护栏正是让该模式可用的前提,配合沙箱进一步降低风险block-dangerous-commands.sh 脚本与 deny 清单 Bash(rm -rf:*) 写法,本文拦截钩子的直接蓝本updatedInput(v2.0.10)、async、17 事件演进的系统讲解$CLAUDE_PROJECT_DIR、/hooks 调试updatedInput,建议 3 小时工作坊。> /dev/sda、chmod 777);③ 能解释 deny 清单与钩子的双保险关系。-p 批量任务或 CI 评审机器人,观察自主模式下护栏触发的频率与拦截原因分布。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
