<!-- title: 造双钩护栏拦危险命令并自动格式化 -->

## 概述

本教程带你为 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 Code（`claude --version` 可正常输出，建议 2.0.10 及以上，以支持 `updatedInput` 改写工具输入）
- 终端里有 `jq`（钩子解析 JSON 的核心依赖，`jq --version` 验证）
- 一个用于练手的工程目录，能跑 `npx prettier` 或 `python -m black` 任一即可
- 读过 CLAUDE.md 基本写法——本教程讲的是 CLAUDE.md 之外的**强制层**

## 一、为什么 CLAUDE.md 不够用：从"建议"到"强制"

很多人在 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`**：工具**执行后**触发。能跑格式化、能回传反馈。

钩子和工具之间，靠两个"信号"通信：

1. **stdin 上的 JSON**：Claude Code 把这次工具调用的 `tool_name`、`tool_input`（命令/文件路径）等推给钩子脚本。
2. **退出码 + stdout JSON**：
   - **退出码 2** 是"魔法数字"——表示**阻止本次工具调用**，并把 stderr 内容回喂给 Claude，让它知道为什么被拦。
   - 也可以用**结构化 JSON 输出**做更精细的控制，对 `PreToolUse` 最重要的字段是：

```json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "rm -rf 已被安全护栏拦截"
  }
}
```

`permissionDecision` 有三个取值：`deny`（直接拒绝）、`allow`（直接放行，跳过权限询问）、`ask`（仍走正常权限询问）。这正是我们造护栏的"决策器"。

### 钩子执行时序

```mermaid
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` 解析项目根路径，跨平台更稳。

## 四、第一步：拦截危险命令的 PreToolUse 钩子

新建 `.claude/hooks/block-dangerous.sh`：

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

赋予执行权限：

```bash
chmod +x .claude/hooks/block-dangerous.sh
```

## 五、验证钩子：5 组真实测试

钩子本质是个**吃 JSON、吐 JSON 的命令行程序**，完全可以在不启动 Claude 的情况下独立测试。把模拟的工具调用喂给它，看决策对不对。下面是我实际跑出来的结果：

```bash
# 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` 命令可列出当前所有已加载钩子及其来源，确认你的配置被识别。

## 六、第二步：保护敏感文件的 PreToolUse 钩子

危险命令之外，第二大类事故是 AI 直接改写了不该碰的文件：环境变量、密钥、锁文件、CI 配置、Terraform 状态。第二个钩子盯住 `Edit|Write|MultiEdit` 三类写操作。

新建 `.claude/hooks/protect-files.sh`：

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

同样独立测试：

```bash
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   （放行）
```

## 七、第三步：写完自动格式化的 PostToolUse 钩子

格式化靠"提醒 AI"是最不靠谱的——它可能忘、可能用错工具、可能只格式化了一半。`PostToolUse` 让"每次写文件必格式化"变成物理事实。

新建 `.claude/hooks/format-file.sh`：

```bash
#!/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` 保证工具缺失时也不中断流程。

## 八、第四步：把双钩接入 settings.json

在 `.claude/settings.json` 写入完整配置。注意我们同时配了 `permissions.deny` 清单——它和钩子是**双保险**：deny 清单是 Claude Code 内置的权限层，钩子是额外的脚本层，两者任一拦截都能挡住事故。

```json
{
  "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，不如把危险命令改写成安全版再放行：

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

## 完整护栏工作流

```mermaid
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/>主动给出安全替代方案]
```

## 延伸阅读

- **官方 Hooks 文档**：https://docs.claude.com/en/docs/claude-code/hooks —— 17 个事件的完整契约，写钩子前必读
- **`updatedInput` 与权限决策**：官方 JSON Schema 参考，理解 `permissionDecision` 三态与输入改写
- **PostToolUse 进阶（lint + test）**：在格式化钩子基础上叠加 `eslint`、`pytest`，做成"改完即验"的流水线
- **Prompt/Agent 类型钩子**：超出 `command` 之外的两种处理器，适合"判断式"校验（如让 LLM 评审 diff）
- **`--dangerously-skip-permissions` 安全实践**：本教程的护栏正是让该模式可用的前提，配合沙箱进一步降低风险

## 参考来源

1. [Claude Code: block dangerous commands — Thiago Perrotta](https://perrotta.dev/2025/12/claude-code-block-dangerous-commands/) — 完整的 `block-dangerous-commands.sh` 脚本与 deny 清单 `Bash(rm -rf:*)` 写法，本文拦截钩子的直接蓝本
2. [Claude Code Hooks: PreToolUse & PostToolUse Tutorial — Paul Schick](https://paul-schick.com/posts/claude-code-hooks-pretooluse-posttooluse/) —— 钩子生命周期、退出码 2、`updatedInput`(v2.0.10)、async、17 事件演进的系统讲解
3. [Claude Code Hooks — the complete guide to guardrails — Jakub Kontra](https://jakubkontra.com/en/blog/claude-code-hooks-complete-guide) —— PreToolUse/PostToolUse/Notification 的实战配置指南
4. [Claude Code Hooks 官方文档](https://docs.claude.com/en/docs/claude-code/hooks) —— 权威事件契约、matcher 语法、`$CLAUDE_PROJECT_DIR`、`/hooks` 调试
5. [How to Add a Claude Code PostToolUse Formatter Hook — how2.sh](https://how2.sh/posts/how-to-add-a-claude-code-posttooluse-formatter-hook/) —— prettier/black/gofmt 自动格式化钩子的具体接线

## 训练营适用性评估

- **适用训练营类型**：面向"AI 工程化落地""AI 安全与规范""DevOps + AI"主题的中级训练营；也适合作为"自主 Agent 开发"模块的安全前置课。不建议纯入门营（需先有第一课的安装与权限基础）。
- **教学时长**：讲授 + 动手约 **1.5–2 小时**（含三个钩子的独立测试）。若学员基础好，可压缩到 60 分钟；若要扩展到 lint/test 流水线和 `updatedInput`，建议 3 小时工作坊。
- **难度等级**：⭐⭐⭐（中级）。难点在正则编写与 JSON 决策格式的调试，建议提供"喂 JSON 验证"的练习模板。
- **考核点**：① 能独立让 5 组测试用例全部通过；② 能新增一条自定义危险模式（如 `> /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 等场景全覆盖。

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