<!-- title: 钩子造命令护栏拦危险操作 -->

## 概述

`PreToolUse` 钩子是 Claude Code 在工具真正执行前留给你的"关卡"。本教程带你从零造一个**命令护栏**——它能在 Bash 命令落地前拦截 `rm -rf /`、强推受保护分支、发布重复版本号等高危操作。关键差异在于：护栏是**动态**的，能读取 `cwd`、`git` 分支、文件内容做条件判断，这是静态权限规则永远做不到的。最后用一条 `-p` 批处理把护栏变成可回归测试的工件。适合在无人值守、批处理、CI 自愈等场景需要安全兜底的开发者与团队负责人。

## 前置条件

- 已安装 Claude Code（`claude --version` 可用）
- Node.js 18+（护栏脚本运行环境，`node -v` 验证）
- `jq`（批量测试解析语料用，`jq --version` 验证）
- 了解 JSON、shell 基本语法，能看懂正则表达式

---

## 一、为什么不用权限规则就够了

很多人第一反应是：危险命令用 `permissions.deny` 拦不就行了？它能挡住 `rm -rf /`，但挡不住下面这些**取决于上下文**的判断：

| 危险操作 | 静态 deny 能拦吗 | 护栏（钩子）能拦吗 |
|---|---|---|
| `rm -rf /` | ✅ 能 | ✅ 能 |
| `rm -rf build/` 在仓库里 | ❌ 会误伤（清理构建产物是合法的） | ✅ 能，按目录放行 |
| `git push --force` 推到 `main` | ❌ 字符串匹配会连 feature 分支一起挡 | ✅ 能，先读分支再决定 |
| `npm publish` 已存在的版本号 | ❌ 完全无法判断 | ✅ 能，查注册表 |
| `kubectl delete ns prod-*` | ❌ 只能全挡或全放 | ✅ 能，按前缀/当前上下文 |

权限规则是"静态字符串匹配"，钩子是"可执行代码"。当规则需要**读运行时状态**才能下结论时，只有钩子能胜任。本教程的护栏正是为补齐这一层而设计。

## 二、理解 PreToolUse 钩子的输入输出

### 2.1 配置位置

钩子写在 `settings.json` 里，三个层级都生效（后者覆盖/合并前者）：

- 用户级：`~/.claude/settings.json`
- 项目级：`.claude/settings.json`（进 git，团队共享）
- 本地级：`.claude/settings.local.json`（不进 git）

### 2.2 stdin 传入的 JSON

每次模型要调用工具前，Claude Code 把如下 JSON 通过 **stdin** 喂给你的脚本：

```json
{
  "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`。

### 2.3 三种退出码的语义

| 退出码 | 含义 | 效果 |
|---|---|---|
| `0` | 放行 | 工具正常执行 |
| `2` | **阻塞** | 工具被取消，`stderr` 内容回灌给模型当理由 |
| 其它非零 | 非阻塞错误 | `stderr` 显示在 transcript，工具仍执行 |

记住这条：**想拦就 `exit 2`，理由写 `stderr`**。模型会读到理由并自行调整下一步。

### 2.4 进阶：stdout 的 JSON 决策

除了退出码，脚本还能向 `stdout` 输出 JSON 做更精细控制：

```json
{ "decision": "block", "reason": "强推受保护分支" }
```

`decision` 取值 `approve`（强制放行，跳过权限询问）/`block`（阻塞）。本教程主路径用退出码 `2`（最简单可靠），把 stdout JSON 作为进阶补充。

## 三、动手：写最小护栏脚本

新建 `~/.claude/hooks/guard.mjs`：

```javascript
#!/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 仓库就返回空，规则自然不触发
}
```

给执行权限：

```bash
chmod +x ~/.claude/hooks/guard.mjs
```

**注意**：`return` 在顶层 ESM 模块里合法（等价于提前 `exit`），但为可读性也可改成 `if (...) { block(...); }`。`block()` 内部已 `exit`，不会继续往下跑。

## 四、注册到 settings.json

编辑 `~/.claude/settings.json`：

```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，团队改策略不用动代码：

```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 批量回归测试护栏（关键一步）

护栏本身也是代码，会改、会回归。手动一条条试不现实。这里用 `-p` 无头模式 + 一份命令语料，把它变成自动化测试。

### 7.1 准备语料 `corpus.jsonl`（每行一个用例）

```json
{"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"}
```

### 7.2 单元级批量测试（快、零 API 成本）

直接把合成 JSON 喂给脚本，比对退出码：

```bash
#!/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 检查，护栏改一行正则立刻知道有没有放掉该挡的命令。

### 7.3 端到端冒烟（用 `-p` 真实链路）

单元测试覆盖了脚本逻辑，但钩子是否真的被 Claude Code 调起？用 `-p` 做几条烟雾测试：

```bash
# 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` 幂等地合并配置（避免重复追加）：

```bash
#!/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 自动跑语料回归。

## 九、整体工作流程

```mermaid
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 这套范式，同构可迁移到其它钩子事件：

- **PostToolUse + `Write|Edit`**：每次改完代码自动跑 `prettier`/`black`/`eslint --fix`，格式漂移即时纠正。
- **UserPromptSubmit**：在每条用户输入前注入"当前 git 未提交文件清单""本次会议纪要"等上下文，模型永远带着现状开工。
- **Stop**：会话结束时若 `git status` 有改动，自动 `post` 一条钉钉/Slack 通知——无人值守任务的"完成播报"。
- **SessionStart**：注入当日待办、上次中断点，把长任务串成"会呼吸的流水线"。

护栏解决"别让它干坏事"，这些兄弟钩子解决"让它持续把好事干好"。

## 延伸阅读

- **Claude Code 官方 Hooks 文档**（`code.claude.com/docs/en/agent-sdk/hooks`）——钩子事件、stdin schema、退出码语义的权威出处，遇到字段以这里为准。
- **PreToolUse Bash Guard 实战**（`how2.sh`）——一个聚焦 Bash 命令解析的护栏范例，正则设计值得借鉴。
- **Claude Code Security Hook**（GitHub `randomdreft/claude-code-security-hook`）——开箱即用的危险命令拦截器，适合不想自己写正则的团队直接采用。
- **Building Guardrails …PreToolUse Hook System**（`dev.to/mikelane`）——把团队策略（禁 `git reset --hard`、要求 GPG 签名）固化进钩子的工程化思路。

## 训练营适用性评估

- **安全/DevSecOps 训练营**：⭐⭐⭐⭐⭐ 主力实验。半天讲解 + 半天动手，学员带走的不仅是护栏，更是"AI 编程时代的安全兜底范式"。
- **AI Agent 自动化训练营**：⭐⭐⭐⭐ 作为"无人值守任务的安全前置课"，配合 Stop/PostToolUse 钩子串成完整闭环。
- **新人入门训练营**：⭐⭐⭐ 偏进阶，建议放在权限模式之后讲。先用 `permissions` 建立安全感，再用钩子讲"动态规则"，认知梯度更顺。
- **预计教学时长**：讲授 45 分钟 + 动手 90 分钟（含写正则、调语料、跑 CI 回归）+ 答疑 30 分钟，合计约 3.5 小时。可裁剪为 2 小时精简版（去掉外置策略表与 `-p` 端到端部分）。

---

## 参考来源

1. [Intercept and control agent behavior with hooks — Claude Code Docs](https://code.claude.com/docs/en/agent-sdk/hooks) — 官方钩子参考，事件类型、stdin JSON schema、退出码与 decision 字段的权威定义。
2. [Building Guardrails for AI Coding Assistants: A PreToolUse Hook System for Claude Code — DEV Community](https://dev.to/mikelane/building-guardrails-for-ai-coding-assistants-a-pretooluse-hook-system-for-claude-code-ilj) — 把团队工程规范（禁 `git reset --hard`、要求 GPG 签名）固化为钩子的工程化实践。
3. [Claude Code: block dangerous commands — Thiago Perrotta](https://perrotta.dev/2025/12/claude-code-block-dangerous-commands/) — 极简 `block-dangerous-commands.sh` 范例，展示了 `exit 2` + stderr 回灌的真实终端输出。
4. [How to Build a Claude Code PreToolUse Bash Guard — how2.sh](https://how2.sh/posts/how-to-build-a-claude-code-pretooluse-bash-guard/) — 聚焦 Bash 命令字符串解析与正则设计的实战教程。
5. [claude-code-security-hook — GitHub](https://github.com/randomdreft/claude-code-security-hook) — 开箱即用的危险命令拦截钩子，适合直接集成或作为策略参考。

> [!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 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
