CLAUDE.md 里写的"不要删数据库""不要改生产配置"只是建议,Claude 大多数时候会遵守,但一旦在 acceptEdits、bypassPermissions 或无头批量模式下高速运转,就可能在某个长链条里"顺手"执行了危险操作。本教程带你用 Claude Code 的工具级钩子(PreToolUse / PostToolUse)搭建一套硬性安全围栏——每一次工具调用在执行前都必须先过一道脚本关卡,命中危险规则就直接拦截并把理由回传给模型。
适用人群:已经会用 Claude Code 基础交互、想把它放进 CI / 批量 / 自治场景但又怕"翻车"的开发者与 DevOps 工程师。学完你将掌握:钩子拦截时机、settings.json 配置结构、if 匹配语法、stdin JSON 解析、permissionDecision 三态决策(allow/ask/deny),并能跑通一个"围栏内批量重构"的完整示例。
bash、jq、curl(验证脚本用)关键认知:钩子与权限模式是两套独立机制。即便你用
--permission-mode bypassPermissions,PreToolUse钩子依然会在工具执行前触发,deny决策照样能拦下命令。这正是它适合做"最后一道防线"的原因。
Claude Code 的钩子按触发频率分三档,做安全围栏只用第一档:
| 类型 | 事件 | 触发频率 | 能否阻止动作 |
|---|---|---|---|
| 会话级 | SessionStart / SessionEnd | 每会话一次 | 否(已发生或不可逆) |
| 轮次级 | UserPromptSubmit / Stop | 每轮一次 | 是 |
| 工具级 | PreToolUse / PostToolUse | 每次工具调用 | PreToolUse 可阻止,PostToolUse 不能 |
围栏的核心是 PreToolUse:它在 Claude"决定调用某个工具"和"工具真正执行"之间插入一个检查点。返回 deny 就把这次调用挡回去;返回 allow 直接放行(跳过后续权限弹窗);返回 ask 转人工确认。PostToolUse 则在工具跑完后触发,做格式化、跑测试、记录审计日志——动作已经发生,拦不住,但能"擦屁股"。
注意区分:本教程聚焦工具级拦截,与"会话中枢"类教程讲的 SessionStart/Stop 生命周期钩子是不同层次,互不冲突,可叠加使用。
新建一个测试目录并初始化钩子目录:
mkdir -p guardrail-demo/.claude/hooks guardrail-demo/src
cd guardrail-demo
echo 'DB_PASSWORD=s3cret' > .env # 受保护文件
echo 'API_KEY=demo' > src/config.py # 待重构文件
目录结构最终如下:
guardrail-demo/
├── .claude/
│ ├── settings.json # 钩子注册表
│ └── hooks/
│ ├── block-destructive.sh # 拦危险命令
│ ├── protect-files.sh # 保护关键文件
│ └── auto-format.sh # 编辑后自动格式化
├── src/config.py
└── .env
钩子脚本由 Claude Code 直接执行,必须带可执行权限,否则会被静默跳过:
chmod +x .claude/hooks/*.sh
在 .claude/settings.json 写入以下内容。注意 matcher 决定钩子作用于哪个工具,command 用 $CLAUDE_PROJECT_DIR 保证路径可移植(该变量由 Claude Code 在执行钩子时注入,指向项目根):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-destructive.sh" }
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/auto-format.sh" }
]
}
]
}
}
写完后用 /hooks 交互命令确认注册成功,应能看到三组钩子分别挂在 PreToolUse 与 PostToolUse 下。
进阶:
if字段用权限规则语法做前置过滤,不匹配时连进程都不启动,省 CPU。例如给 Bash 钩子加"if": "Bash(rm *)",则只有rm开头的命令才会真正调用脚本。本教程为求通用先不写if,让脚本内部用正则判断。
PreToolUse 钩子启动时,Claude Code 把工具信息以 JSON 形式打到 stdin,结构是 {"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}, ...}。我们用 jq 取出命令再正则匹配。创建 .claude/hooks/block-destructive.sh:
#!/usr/bin/env bash
# PreToolUse on Bash —— 拦截破坏性命令
set -euo pipefail
CMD="$(jq -r '.tool_input.command // ""')"
# 高危规则集:递归删除 / 强推 / 覆盖设备 / 危险权限 / 数据库破坏 / fork 炸弹
PATTERN='rm[[:space:]]+-[a-zA-Z]*r|git[[:space:]]+push.*(-f|--force)|chmod[[:space:]]+777|>[[:space:]]*/dev/sd|dd[[:space:]].*of=|drop[[:space:]]+(table|database)|:[()]\{.*;\}'
if echo "$CMD" | grep -Eq "$PATTERN"; then
REASON="⛔ 围栏拦截:命中破坏性操作规则。如确需执行,请人工运行或临时 /hooks 禁用本钩子。命中命令片段:$CMD"
# 输出结构化决策(推荐方式:理由清晰、可区分 ask/deny)
jq -n --arg r "$REASON" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
exit 0
fi
exit 0 # 无输出 + exit 0 = 不干预,走正常权限流程
预期结果:当 Claude 试图执行 rm -rf src/legacy 时,命令不会执行,Claude 收到 deny 理由后会改用逐文件删除或停下来询问你。
最简写法(不依赖 JSON,适合快速上手):把决策那两行换成——把理由打到 stderr、exit 2。exit code 2 在 PreToolUse 上的语义就是"阻止这次调用,stderr 内容回传给 Claude":
echo "⛔ 危险命令被拦截:$CMD" >&2
exit 2
两种写法效果近似,但 JSON 方式多了 ask(转人工确认)和 allow(直接放行免弹窗)两个选项,围栏场景更推荐 JSON。
常见问题
chmod +x,或 jq 未安装(jq 在容器最小镜像里常缺)。deny 改成 ask,命中后弹确认框由你拍板,而不是直接挡。rm -r 也会拦掉正常的目录清理,按团队规范收紧。危险命令之外,最常见的"翻车"是 Claude 改写了不该碰的文件:.env、生产配置、Terraform state、私钥、甚至 settings.json 自身。给 Edit/Write/MultiEdit 挂一个保护器。创建 .claude/hooks/protect-files.sh:
#!/usr/bin/env bash
# PreToolUse on Edit/Write/MultiEdit —— 保护关键文件
set -euo pipefail
# Edit/Write 用 file_path,NotebookEdit 用 notebook_path,兼容取值
TARGET="$(jq -r '.tool_input.file_path // .tool_input.notebook_path // ""')"
# 受保护路径:环境变量 / 密钥 / 生产配置 / tfstate / 钩子配置自身
PROTECTED='(\.env$|\.env\.[a-z]+$|secrets\.|\.prod\.ya?ml$|terraform/.*\.tfstate$|\.claude/settings\.json$|id_rsa$|pki/)'
if echo "$TARGET" | grep -Eq "$PROTECTED"; then
REASON="⛔ 受保护文件:$TARGET 禁止由 Claude 直接改写。请走人工评审或专用发布流程。"
jq -n --arg r "$REASON" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
exit 0
fi
exit 0
预期结果:让 Claude "把 .env 里的密钥换成测试值"会被直接拒绝;而改 src/config.py 正常放行。
常见问题
tool_input.file_path 一般是绝对路径,正则用 grep -E 而非 ^ 锚定更稳。if echo "$TARGET" | grep -q 'src/config.py'; then exit 0; fi。settings.json 自己:本例已包含,防止 Claude"自我降级"删掉围栏——这是防止钩子被绕过的关键一手。围栏不只防破坏,还能"善后"。PostToolUse 在文件被编辑后触发,可读 tool_input.file_path 拿到刚改的文件,按扩展名跑对应格式化工具。创建 .claude/hooks/auto-format.sh:
#!/usr/bin/env bash
# PostToolUse on Edit/Write/MultiEdit —— 编辑后自动格式化
set -euo pipefail
FILE="$(jq -r '.tool_input.file_path // "")"
[ -z "$FILE" ] || [ ! -f "$FILE" ] && exit 0
case "$FILE" in
*.py) command -v black >/dev/null 2>&1 && black -q "$FILE" 2>/dev/null || true ;;
*.ts|*.tsx|*.js|*.jsx|*.json|*.css)
command -v prettier >/dev/null 2>&1 && prettier --write "$FILE" >/dev/null 2>&1 || true ;;
*.go) command -v gofmt >/dev/null 2>&1 && gofmt -w "$FILE" 2>/dev/null || true ;;
esac
exit 0
PostToolUse 即便 exit 2 也拦不住已发生的编辑(动作已完成),它的价值是副作用编排:格式化、跑单测、写审计日志、统计改动行数。若想"编辑后跑测试且失败就回滚提示",把 black 换成 pytest,并在失败时往 stderr 输出失败摘要——Claude 会读到并在下一轮自我修复。
围栏搭好,用 -p 无头模式验证它真的生效。先跑一个正常的批量重构:
claude -p "把 src/ 下所有 Python 文件里的全局变量加类型注解,保持逻辑不变" \
--allowedTools "Edit,Read,Glob,Grep,Bash(black *)" \
--permission-mode acceptEdits
尽管开了 acceptEdits(免编辑确认),但 PostToolUse 仍会触发 black 自动格式化,PreToolUse 仍在每次 Edit 前检查路径——围栏照常工作。
再故意触发拦截:
claude -p "清理项目:删除 src/legacy 整个目录" --permission-mode acceptEdits
预期结果:Claude 内部尝试构造 rm -rf src/legacy 时,block-destructive.sh 命中 rm -r 规则返回 deny。命令不执行,理由回传,Claude 看到后改用 Bash 逐个删除文件或 Glob+Bash(rm <file>) 安全方式,并在无法安全完成时停下来报告。
最后验证文件保护:
claude -p "把 .env 里的 DB_PASSWORD 改成 test123" --permission-mode acceptEdits
# 预期:protect-files.sh 命中 \.env$,deny,Claude 报告"受保护文件禁止改写"
这三条命令跑下来,围栏的"防破坏 / 防误改 / 善后格式化"三件事就都验证了。
把上述三脚本 + settings.json 放进 .claude/,再写一个 verify.sh 自检脚本,方便团队任何人 clone 后一键验证围栏是否在线:
#!/usr/bin/env bash
# verify.sh —— 围栏自检
set -e
cd "$(dirname "$0")"
H=.claude/hooks
echo "[1] 危险命令拦截"
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}}' | $H/block-destructive.sh \
| jq -r '.hookSpecificOutput.permissionDecision' # 期望输出 deny
echo "[2] 文件保护"
echo '{"tool_name":"Edit","tool_input":{"file_path":"/abs/.env"}}' | $H/protect-files.sh \
| jq -r '.hookSpecificOutput.permissionDecision' # 期望输出 deny
echo "[3] 正常放行"
echo '{"tool_name":"Edit","tool_input":{"file_path":"/abs/src/config.py"}}' | $H/protect-files.sh
echo "exit=$?" # 期望 exit=0 且无 deny 输出
把它接进 CI:每次 PR 跑 bash verify.sh,围栏规则一旦被改坏立刻红灯。这样"钩子配置"本身也成了受版本控制、可测试的代码资产——而不是某个开发者机器上的隐式约定。
flowchart TD
A["Claude 决定调用工具"] --> B{"PreToolUse 钩子触发"}
B -->|"matcher 匹配 Bash/Edit/..."| C["脚本读 stdin JSON<br/>取 tool_input"]
C --> D{"命中危险规则?"}
D -->|是| E["输出 permissionDecision: deny<br/>+ reason"]
D -->|否| F["exit 0 放行<br/>可 allow 跳过弹窗"]
E --> G["🚫 阻止本次调用<br/>reason 回传 Claude"]
F --> H["✅ 执行工具"]
H --> I{"PostToolUse 钩子"}
I --> J["读 tool_response / file_path<br/>自动格式化 / 跑测试 / 审计日志"]
J --> K["结果反馈给 Claude<br/>进入下一轮决策"]
G --> L["Claude 收到拒绝理由<br/>改用安全方式或停下询问"]
"type":"prompt" 让一个 Claude 子模型做单轮 yes/no 裁决,或 "type":"agent" 派子代理用 Read/Grep 多步核验后再决策。if 权限规则语法:用 "if":"Bash(git push *)" 做前置过滤,不匹配的调用零开销跳过,避免每次工具调用都起进程。PreToolUse/PostToolUse 对子代理内的工具调用同样触发,围栏天然覆盖并行 Agent 场景,无需额外配置。type 换成 "http",把检查逻辑放到远端服务,可集中管理全公司的围栏策略并统一审计。deny 升级为带 Slack 告警的 HTTP 钩子;或加一个 UserPromptSubmit 钩子,在用户提交"删库跑路"类指令时就先拦一道。permissionDecision 决策字段block-rm.sh 完整拦截示例与 if 匹配流程[!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 等场景全覆盖。
