<!-- title: 造工具级安全钩子建执行围栏 -->

## 概述

`CLAUDE.md` 里写的"不要删数据库""不要改生产配置"只是**建议**，Claude 大多数时候会遵守，但一旦在 `acceptEdits`、`bypassPermissions` 或无头批量模式下高速运转，就可能在某个长链条里"顺手"执行了危险操作。本教程带你用 Claude Code 的**工具级钩子（PreToolUse / PostToolUse）**搭建一套硬性安全围栏——每一次工具调用在执行前都必须先过一道脚本关卡，命中危险规则就直接拦截并把理由回传给模型。

适用人群：已经会用 Claude Code 基础交互、想把它放进 CI / 批量 / 自治场景但又怕"翻车"的开发者与 DevOps 工程师。学完你将掌握：钩子拦截时机、`settings.json` 配置结构、`if` 匹配语法、stdin JSON 解析、`permissionDecision` 三态决策（allow/ask/deny），并能跑通一个"围栏内批量重构"的完整示例。

## 前置条件

- 已安装 Claude Code（v2.x，2026 年版本钩子事件已扩展至 30+ 种）
- 本机有 `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 生命周期钩子是不同层次，互不冲突，可叠加使用。

## 第二步：搭建项目骨架

新建一个测试目录并初始化钩子目录：

```bash
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 直接执行，必须**带可执行权限**，否则会被静默跳过：

```bash
chmod +x .claude/hooks/*.sh
```

## 第三步：注册钩子到 settings.json

在 `.claude/settings.json` 写入以下内容。注意 `matcher` 决定钩子作用于哪个工具，`command` 用 `$CLAUDE_PROJECT_DIR` 保证路径可移植（该变量由 Claude Code 在执行钩子时注入，指向项目根）：

```json
{
  "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`，让脚本内部用正则判断。

## 第四步：写 Bash 危险命令拦截器

`PreToolUse` 钩子启动时，Claude Code 把工具信息以 JSON 形式打到** stdin**，结构是 `{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}, ...}`。我们用 `jq` 取出命令再正则匹配。创建 `.claude/hooks/block-destructive.sh`：

```bash
#!/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"：

```bash
echo "⛔ 危险命令被拦截：$CMD" >&2
exit 2
```

两种写法效果近似，但 JSON 方式多了 `ask`（转人工确认）和 `allow`（直接放行免弹窗）两个选项，围栏场景更推荐 JSON。

**常见问题**

- **钩子没反应**：99% 是忘 `chmod +x`，或 `jq` 未安装（`jq` 在容器最小镜像里常缺）。
- **误杀正常命令**：把 `deny` 改成 `ask`，命中后弹确认框由你拍板，而不是直接挡。
- **正则太宽**：`rm -r` 也会拦掉正常的目录清理，按团队规范收紧。

## 第五步：写文件保护拦截器

危险命令之外，最常见的"翻车"是 Claude 改写了不该碰的文件：`.env`、生产配置、Terraform state、私钥、甚至 `settings.json` 自身。给 `Edit/Write/MultiEdit` 挂一个保护器。创建 `.claude/hooks/protect-files.sh`：

```bash
#!/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` 而非 `^` 锚定更稳。
- **想加白名单例外**：在 deny 分支前加一段 `if echo "$TARGET" | grep -q 'src/config.py'; then exit 0; fi`。
- **保护 `settings.json` 自己**：本例已包含，防止 Claude"自我降级"删掉围栏——这是防止钩子被绕过的关键一手。

## 第六步：写 PostToolUse 自动格式化器

围栏不只防破坏，还能"善后"。`PostToolUse` 在文件被编辑后触发，可读 `tool_input.file_path` 拿到刚改的文件，按扩展名跑对应格式化工具。创建 `.claude/hooks/auto-format.sh`：

```bash
#!/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` 无头模式验证它真的生效。先跑一个**正常的批量重构**：

```bash
claude -p "把 src/ 下所有 Python 文件里的全局变量加类型注解，保持逻辑不变" \
  --allowedTools "Edit,Read,Glob,Grep,Bash(black *)" \
  --permission-mode acceptEdits
```

尽管开了 `acceptEdits`（免编辑确认），但 `PostToolUse` 仍会触发 `black` 自动格式化，`PreToolUse` 仍在每次 Edit 前检查路径——围栏照常工作。

再故意触发拦截：

```bash
claude -p "清理项目：删除 src/legacy 整个目录" --permission-mode acceptEdits
```

**预期结果**：Claude 内部尝试构造 `rm -rf src/legacy` 时，`block-destructive.sh` 命中 `rm -r` 规则返回 `deny`。命令不执行，理由回传，Claude 看到后改用 `Bash` 逐个删除文件或 `Glob`+`Bash(rm <file>)` 安全方式，并在无法安全完成时停下来报告。

最后验证文件保护：

```bash
claude -p "把 .env 里的 DB_PASSWORD 改成 test123" --permission-mode acceptEdits
# 预期：protect-files.sh 命中 \.env$，deny，Claude 报告"受保护文件禁止改写"
```

这三条命令跑下来，围栏的"防破坏 / 防误改 / 善后格式化"三件事就都验证了。

## 实战案例：可一键复现的围栏套件

把上述三脚本 + settings.json 放进 `.claude/`，再写一个 `verify.sh` 自检脚本，方便团队任何人 clone 后一键验证围栏是否在线：

```bash
#!/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`，围栏规则一旦被改坏立刻红灯。这样"钩子配置"本身也成了受版本控制、可测试的代码资产——而不是某个开发者机器上的隐式约定。

## 整体工作流程

```mermaid
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/>改用安全方式或停下询问"]
```

## 延伸阅读

- **Prompt / Agent 型钩子**：当正则判断不了语义（如"这个 SQL 是否危险"），用 `"type":"prompt"` 让一个 Claude 子模型做单轮 yes/no 裁决，或 `"type":"agent"` 派子代理用 Read/Grep 多步核验后再决策。
- **`if` 权限规则语法**：用 `"if":"Bash(git push *)"` 做前置过滤，不匹配的调用零开销跳过，避免每次工具调用都起进程。
- **与子代理协作**：`PreToolUse/PostToolUse` 对子代理内的工具调用同样触发，围栏天然覆盖并行 Agent 场景，无需额外配置。
- **HTTP 钩子**：把 `type` 换成 `"http"`，把检查逻辑放到远端服务，可集中管理全公司的围栏策略并统一审计。

## 训练营适用性评估

- **适合方向**：AI 工程化 / DevOps 自动化 / 安全合规 / 大模型落地实战训练营。
- **教学时长**：90–120 分钟（含动手）。前 30 分钟讲拦截时机与配置结构，中间 45 分钟三脚本实操，最后 15–30 分钟跑无头批量与自检脚本。
- **难度定位**：中等。要求学员有命令行与正则基础，但不要求会写复杂后端。
- **延伸作业**：把 `deny` 升级为带 Slack 告警的 HTTP 钩子；或加一个 `UserPromptSubmit` 钩子，在用户提交"删库跑路"类指令时就先拦一道。

## 参考来源

1. [Hooks reference — Claude Code Docs](https://code.claude.com/docs/en/hooks) — 官方钩子权威参考，含 30+ 事件、JSON 输入输出、exit code 2 语义与 `permissionDecision` 决策字段
2. [Automate actions with hooks — Claude Code Docs](https://code.claude.com/docs/en/hooks-guide) — 官方快速上手指南，含 `block-rm.sh` 完整拦截示例与 `if` 匹配流程
3. [Claude Code Hooks: PreToolUse & PostToolUse Tutorial — paul-schick.com](https://paul-schick.com/posts/claude-code-hooks-pretooluse-posttooluse/) — 把钩子定位为"将 CLAUDE.md 变成强制规则"的执行层，配实战代码
4. [Claude Code Hooks: The Automation Layer Nobody Knows About — dev.to](https://dev.to/whoffagents/claude-code-hooks-the-automation-layer-nobody-knows-about-3po0) — settings.json 配置样例与 PreToolUse/PostToolUse/SessionStart 实践对比
5. [Chapter 5: Hooks — Claude Code Best Practices](https://www.zqcblog.com/claude-code-best-practices/stage2-intermediate/ch05-hooks/) — 系统化的钩子章节，覆盖 lint、测试、安全扫描等自动化编排思路

> [!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)
