PreToolUse 钩子是 Claude Code 在工具真正执行前留给你的"关卡"。本教程带你从零造一个命令护栏——它能在 Bash 命令落地前拦截 rm -rf /、强推受保护分支、发布重复版本号等高危操作。关键差异在于:护栏是动态的,能读取 cwd、git 分支、文件内容做条件判断,这是静态权限规则永远做不到的。最后用一条 -p 批处理把护栏变成可回归测试的工件。适合在无人值守、批处理、CI 自愈等场景需要安全兜底的开发者与团队负责人。
claude --version 可用)node -v 验证)jq(批量测试解析语料用,jq --version 验证)很多人第一反应是:危险命令用 permissions.deny 拦不就行了?它能挡住 rm -rf /,但挡不住下面这些取决于上下文的判断:
| 危险操作 | 静态 deny 能拦吗 | 护栏(钩子)能拦吗 |
|---|---|---|
rm -rf / |
✅ 能 | ✅ 能 |
rm -rf build/ 在仓库里 |
❌ 会误伤(清理构建产物是合法的) | ✅ 能,按目录放行 |
git push --force 推到 main |
❌ 字符串匹配会连 feature 分支一起挡 | ✅ 能,先读分支再决定 |
npm publish 已存在的版本号 |
❌ 完全无法判断 | ✅ 能,查注册表 |
kubectl delete ns prod-* |
❌ 只能全挡或全放 | ✅ 能,按前缀/当前上下文 |
权限规则是"静态字符串匹配",钩子是"可执行代码"。当规则需要读运行时状态才能下结论时,只有钩子能胜任。本教程的护栏正是为补齐这一层而设计。
钩子写在 settings.json 里,三个层级都生效(后者覆盖/合并前者):
~/.claude/settings.json.claude/settings.json(进 git,团队共享).claude/settings.local.json(不进 git)每次模型要调用工具前,Claude Code 把如下 JSON 通过 stdin 喂给你的脚本:
{
"session_id": "abc123",
"transcript_path": "/tmp/transcript.jsonl",
"cwd": "/home/me/myrepo",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/x"
}
}
对 Write/Edit,tool_input 里是 file_path;对 Bash,是 command。
| 退出码 | 含义 | 效果 |
|---|---|---|
0 |
放行 | 工具正常执行 |
2 |
阻塞 | 工具被取消,stderr 内容回灌给模型当理由 |
| 其它非零 | 非阻塞错误 | stderr 显示在 transcript,工具仍执行 |
记住这条:想拦就 exit 2,理由写 stderr。模型会读到理由并自行调整下一步。
除了退出码,脚本还能向 stdout 输出 JSON 做更精细控制:
{ "decision": "block", "reason": "强推受保护分支" }
decision 取值 approve(强制放行,跳过权限询问)/block(阻塞)。本教程主路径用退出码 2(最简单可靠),把 stdout JSON 作为进阶补充。
新建 ~/.claude/hooks/guard.mjs:
#!/usr/bin/env node
// guard.mjs — PreToolUse Bash 命令护栏
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
// 1) 读取 Claude Code 注入的 JSON(文件描述符 0 = stdin)
let raw = '';
try { raw = readFileSync(0, 'utf8'); }
catch { process.exit(0); } // 读不到 stdin 就放行,绝不卡死工作流
let payload;
try { payload = JSON.parse(raw); }
catch { process.exit(0); } // 非 JSON(误触发)也放行
// 2) 只对 Bash 工具生效
if (payload.tool_name !== 'Bash') process.exit(0);
const cmd = payload.tool_input?.command ?? '';
const cwd = payload.cwd ?? process.cwd();
// 3) 静态危险模式:无脑拦截
const DENY = [
{ re: /rm\s+-rf?\s+\/(\s|$)/, reason: '禁止 rm -rf 根目录' },
{ re: /:\(\)\s*\{.*\}\s*;\s*:/, reason: '疑似 fork 炸弹' },
{ re: /mkfs(\.ext[234])?\s+\/dev\//, reason: '禁止格式化块设备' },
{ re: /dd\s+.*of=\/dev\/(sd|nvme|disk)/, reason: '禁止裸写磁盘' },
{ re: />\s*\/dev\/sda/, reason: '禁止覆盖块设备' },
{ re: /chmod\s+-R\s+777\s+\//, reason: '禁止递归 777 根目录' },
];
for (const r of DENY) {
if (r.re.test(cmd)) return block(r.reason, cmd);
}
// 4) 上下文感知:强推只拦受保护分支
if (/\bgit\s+push\b.*(-f|--force)/.test(cmd)) {
const branch = safeGit(cwd, 'rev-parse --abbrev-ref HEAD');
if (/^(main|master|release\/.*)$/.test(branch)) {
return block(`禁止强推到受保护分支 ${branch}`, cmd);
}
}
// 5) 上下文感知:rm -rf 按目录放行
const rmrf = cmd.match(/rm\s+-rf?\s+([^\s;&|]+)/);
if (rmrf) {
const target = rmrf[1].replace(/\/+$/, '');
const SAFE = [/\/tmp\//, /\/var\/tmp\//, /\/build\/?$/, /\/dist\/?$/, /\/node_modules\/?$/];
if (!SAFE.some(s => s.test(target)) && target !== '/tmp') {
return block(`rm -rf 仅允许清理临时/构建目录,疑似越界:${target}`, cmd);
}
}
process.exit(0); // 全部规则通过 → 放行
// ---- 工具函数 ----
function block(reason, cmd) {
console.error(`[guard] ⛔ ${reason}\n 命令:${cmd}`);
process.exit(2);
}
function safeGit(cwd, sub) {
try { return execSync(`git ${sub}`, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); }
catch { return ''; } // 不是 git 仓库就返回空,规则自然不触发
}
给执行权限:
chmod +x ~/.claude/hooks/guard.mjs
注意:return 在顶层 ESM 模块里合法(等价于提前 exit),但为可读性也可改成 if (...) { block(...); }。block() 内部已 exit,不会继续往下跑。
编辑 ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node ~/.claude/hooks/guard.mjs" }
]
}
]
}
}
matcher 是正则,匹配 tool_name。"Bash" 只对命令执行触发;想顺带拦 Write/Edit 写入敏感路径,可写 "Bash|Write|Edit",再到脚本里按 tool_name 分支处理。
在 Claude Code 交互里让它执行一条危险命令:
> 把 /tmp/test 删掉,用 rm -rf
⏺ Bash(rm -rf /tmp/test)
⎿ PreToolUse:Bash hook returned blocking error
⎿ Error: [guard] ⛔ rm -rf 仅允许清理临时/构建目录,疑似越界:/tmp/test
(按上方规则,/tmp/test 不在白名单精确匹配里——/tmp/ 前缀正则会放行 /tmp/anything,这里实际放行;若你看到拦截,说明把白名单收紧了,这正是"按团队策略调参"的体现。)
强行强推 main:
> git push --force origin main
⎿ Error: [guard] ⛔ 禁止强推到受保护分支 main
模型会读到理由,通常会改用 --force-with-lease 或先确认,护栏完成了它的兜底职责。
| 现象 | 原因 | 解决 |
|---|---|---|
| 钩子从不触发 | matcher 写错或 settings.json JSON 语法错 |
node -e "JSON.parse(require('fs').readFileSync('~/.claude/settings.json','utf8'))" 校验 |
| 正常命令也被拦 | 正则太宽 | 把规则改严,或加白名单分支 |
| 钩子报错卡住工作流 | 脚本异常未捕获 | 所有 execSync/解析都 try-catch,失败统一 exit 0 |
Windows 上 ~ 不展开 |
shell 差异 | 用绝对路径,或 node %USERPROFILE%\\.claude\\hooks\\guard.mjs |
| 看不到实际 stdin | 调试困难 | 脚本开头加 readFileSync(0,...) 后 appendFileSync('/tmp/hook.log', raw) 抓包 |
护栏脚本最怕"逻辑长进代码里"。把规则外置成 JSON,团队改策略不用动代码:
// policies.json
{
"deny": [
{ "id": "rm-root", "re": "rm\\s+-rf?\\s+/(\\s|$)", "reason": "禁止 rm -rf 根" },
{ "id": "fork-bomb", "re": ":\\(\\)\\s*\\{.*\\}\\s*;\\s*:", "reason": "fork 炸弹" }
],
"guarded_branches": ["main", "master", "release/.*"],
"rm_safe_dirs": ["/tmp/", "/var/tmp/", "/build$", "/dist$", "/node_modules$"]
}
脚本启动时 import 它,循环套用。这样护栏的可演进性就和权限配置一样轻,但表达能力远超后者。
护栏本身也是代码,会改、会回归。手动一条条试不现实。这里用 -p 无头模式 + 一份命令语料,把它变成自动化测试。
corpus.jsonl(每行一个用例){"cmd":"rm -rf /","expect":"block"}
{"cmd":"rm -rf /tmp/build","expect":"allow"}
{"cmd":"git push --force origin main","expect":"block","cwd":"/path/to/repo-on-main"}
{"cmd":"git push --force origin feat/x","expect":"allow","cwd":"/path/to/repo-on-feat"}
{"cmd":"echo hello","expect":"allow"}
{"cmd":":(){ :|:& };:","expect":"block"}
直接把合成 JSON 喂给脚本,比对退出码:
#!/usr/bin/env bash
# test-guard.sh
set -u
PASS=0; FAIL=0
while IFS= read -r line; do
cmd=$(echo "$line" | jq -r '.cmd')
exp=$(echo "$line" | jq -r '.expect')
wd=$(echo "$line" | jq -r '.cwd // "."')
payload=$(jq -nc --arg c "$cmd" --arg w "$wd" \
'{tool_name:"Bash",tool_input:{command:$c},cwd:$w}')
echo "$payload" | node ~/.claude/hooks/guard.mjs >/dev/null 2>&1
code=$?
got=$([ "$code" -eq 2 ] && echo block || echo allow)
if [ "$got" = "$exp" ]; then PASS=$((PASS+1));
else FAIL=$((FAIL+1)); printf ' ✗ FAIL expect=%s got=%s cmd=%s\n' "$exp" "$got" "$cmd"; fi
done < corpus.jsonl
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
跑一次:
$ bash test-guard.sh
PASS=6 FAIL=0
CI 里把它接到 PR 检查,护栏改一行正则立刻知道有没有放掉该挡的命令。
-p 真实链路)单元测试覆盖了脚本逻辑,但钩子是否真的被 Claude Code 调起?用 -p 做几条烟雾测试:
# dangerous.txt: 让模型执行危险命令的提示
claude -p --permission-mode acceptEdits "执行 rm -rf /tmp/x 并报告结果" \
| grep -qi "block\|拦截\|cancelled" && echo "E2E OK" || echo "E2E SUSPECT"
--permission-mode acceptEdits 让模型不卡权限询问,但 PreToolUse 钩子优先级高于权限——钩子 exit 2 照样拦。这条命令验证了"钩子真的接进了主循环"。把 3~5 条这样的 -p 断言串成脚本,就是护栏的端到端回归套件。
把上述部件组装成一个可复制的小项目,目录如下:
bash-guard/
├── hooks/
│ └── guard.mjs # 护栏主脚本
├── policies.json # 外置策略表
├── tests/
│ ├── corpus.jsonl # 命令语料
│ └── test-guard.sh # 单元批量测试
├── e2e/
│ └── smoke.sh # -p 端到端冒烟
└── install.sh # 一键注册到 settings.json
install.sh 用 jq 幂等地合并配置(避免重复追加):
#!/usr/bin/env bash
set -euo pipefail
SETTINGS="${1:-$HOME/.claude/settings.json}"
mkdir -p "$(dirname "$SETTINGS")"
[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS"
TMP=$(mktemp)
jq --arg cmd "node $PWD/hooks/guard.mjs" \
'.hooks.PreToolUse = ((.hooks.PreToolUse // []) | map(select(.matcher!="Bash"))
+ [{matcher:"Bash", hooks:[{type:"command", command:$cmd}]}])' \
"$SETTINGS" > "$TMP" && mv "$TMP" "$SETTINGS"
echo "已注册护栏到 $SETTINGS"
新人 clone 仓库 → ./install.sh → bash tests/test-guard.sh 即可拥有一致的护栏。策略调整只改 policies.json,CI 自动跑语料回归。
flowchart TD
U[用户/模型发起 Bash 命令] --> CC{Claude Code 主循环}
CC -->|tool_input JSON| H[PreToolUse 钩子: guard.mjs]
H --> P{解析 stdin}
P -->|tool_name!=Bash| A0[exit 0 放行]
P -->|读 command/cwd| D[静态危险模式表]
D -->|命中| BLK[exit 2 阻塞<br/>理由回灌模型]
D -->|未命中| C[上下文感知检查<br/>git 分支 / 目录白名单]
C -->|越界| BLK
C -->|合规| A0
A0 --> EX[工具真正执行]
BLK --> R[模型读到理由<br/>自行修正下一步]
R --> CC
H -.每周 CI.-> T[test-guard.sh<br/>+ -p 冒烟回归]
掌握了 PreToolUse 这套范式,同构可迁移到其它钩子事件:
Write|Edit:每次改完代码自动跑 prettier/black/eslint --fix,格式漂移即时纠正。git status 有改动,自动 post 一条钉钉/Slack 通知——无人值守任务的"完成播报"。护栏解决"别让它干坏事",这些兄弟钩子解决"让它持续把好事干好"。
code.claude.com/docs/en/agent-sdk/hooks)——钩子事件、stdin schema、退出码语义的权威出处,遇到字段以这里为准。how2.sh)——一个聚焦 Bash 命令解析的护栏范例,正则设计值得借鉴。randomdreft/claude-code-security-hook)——开箱即用的危险命令拦截器,适合不想自己写正则的团队直接采用。dev.to/mikelane)——把团队策略(禁 git reset --hard、要求 GPG 签名)固化进钩子的工程化思路。permissions 建立安全感,再用钩子讲"动态规则",认知梯度更顺。-p 端到端部分)。git reset --hard、要求 GPG 签名)固化为钩子的工程化实践。block-dangerous-commands.sh 范例,展示了 exit 2 + stderr 回灌的真实终端输出。[!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 等场景全覆盖。
