<!-- title: 用Hooks造确定性护栏 -->

## 概述

Claude Code 本质是概率性的——同一提示跑两次结果可能不同。但 **Hooks（钩子）** 是其中罕见的**确定性**机制：由 harness 在固定生命周期点强制执行 shell 命令，模型无法绕过。本教程带你用 PreToolUse、PostToolUse、UserPromptSubmit、Stop 四类钩子构建一套"自动拦截危险命令 + 自动格式化 + 自动注入上下文 + 完工通知"的护栏系统。适合已熟悉 Claude Code 日常用法、想把它放进团队/生产流水线的开发者。

## 前置条件

- Claude Code 已安装并能正常对话（v1.0.30+ 的 hooks 语义最完整）
- 一台 Linux/macOS 机器，会写简单 bash 脚本；Windows 用户建议在 WSL2 下操作
- 一个带 `package.json` 的前端项目（用于演示 Prettier 自动格式化），或任意 Python 项目（演示 black）
- 了解 `settings.json` 的三层优先级：项目 `.claude/settings.json` > 用户 `~/.claude/settings.json`

---

## 第一步：理解 Hooks 的执行模型与四类核心钩子

### 操作说明

Hooks 不是提示词，也不是 Skill，而是**由 harness 在工具调用生命周期里插入的 shell 命令**。关键在于：它们由 harness 执行，**模型自己改不了、也跳不过**。这正是它比"在 CLAUDE.md 里写一句『别删数据库』"可靠得多的根本原因。

四类最常用的钩子事件：

| 钩子事件 | 触发时机 | 典型用途 | 能否阻断流程 |
|---|---|---|---|
| `PreToolUse` | 工具**执行前** | 拦截危险命令、保护敏感文件、二次确认 | 能（deny/block） |
| `PostToolUse` | 工具**执行后** | 自动格式化、跑 lint、记录日志 | 否（只做副作用） |
| `UserPromptSubmit` | 用户提交提示词**后、送模型前** | 注入上下文、拦截敏感提问 | 能（exit 2） |
| `Stop` | 模型结束本轮回复 | 完工通知、自动跑测试、归档 | 能（让模型继续） |

每个钩子执行时，harness 会通过 **stdin 喂一段 JSON**，包含 `session_id`、`transcript_path`、`cwd`、`hook_event_name`，以及工具相关的 `tool_name` / `tool_input` / `tool_response`。你的脚本读这段 JSON 做判断，再通过**退出码或 stdout 的 JSON** 反向控制 harness。

### 预期结果

输入 `/hooks` 回车，能看到一个交互式管理界面，列出当前生效的所有钩子及其来源（user / project / local）。这就是你后续所有配置的"真值表"。

### 常见问题

- **问题**：在 CLAUDE.md 里写"不要执行 `rm -rf`"，模型偶尔还是执行。
- **根因**：CLAUDE.md 只是软约束，模型可能"忘记"。必须用 `PreToolUse` 钩子做硬拦截。
- **解决**：见第二步。

---

## 第二步：PreToolUse——硬拦截危险命令与敏感文件

### 操作说明

这是整个护栏系统的心脏。在项目根目录创建 `.claude/settings.json`：

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/guard-bash.sh" }
        ]
      },
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          { "type": "command", "command": "bash .claude/hooks/guard-write.sh" }
        ]
      }
    ]
  }
}
```

`matcher` 是**正则**，对工具名做匹配，多条 matcher 之间是**或**关系。注意 `Write|Edit|MultiEdit` 必须用一条正则覆盖三种写入工具，漏掉 `MultiEdit` 是最常见的配置漏洞。

创建 `.claude/hooks/guard-bash.sh`：

```bash
#!/usr/bin/env bash
# 读取 harness 注入的 JSON
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')

# 1. 拦截递归删除根目录/家目录
if echo "$cmd" | grep -qE 'rm\s+-rf?\s+(/|~|\$HOME|\*)'; then
  echo "⛔ BLOCKED: 检测到递归删除根/家目录命令: $cmd" >&2
  exit 2
fi

# 2. 拦截强制推送主分支
if echo "$cmd" | grep -qE 'git\s+push.*--force.*main|git\s+push.*-f.*master'; then
  echo "⛔ BLOCKED: 禁止强制推送主分支" >&2
  exit 2
fi

# 3. 拦截管道执行远程脚本
if echo "$cmd" | grep -qE 'curl.*\|\s*(sh|bash)|wget.*\|\s*(sh|bash)'; then
  echo "⛔ BLOCKED: 禁止管道执行远程脚本" >&2
  exit 2
fi

# 放行
exit 0
```

控制协议要记牢两条：**退出码 `2` = 阻断工具并把 stderr 回喂给模型**（模型会"看到"被拦并改道）；**退出码 `0` = 放行**。退出码 `2` 是最常用、最可靠的阻断方式，几乎所有版本都兼容。

### 预期结果

当模型尝试 `rm -rf /`，你会看到红色拦截信息，工具**根本不会执行**，模型随后会自行道歉并换方案。日志里看不到任何 `rm` 真实执行痕迹。

### 常见问题

- **问题**：钩子写了但没触发。
- **排查**：① `jq` 是否安装（钩子静默失败常因缺 `jq`）；② `matcher` 写成 `"bash"` 而非 `"Bash"`——**大小写敏感**，工具名首字母大写；③ `/hooks` 里看不到该条，说明 `settings.json` 路径或 JSON 语法错了。
- **问题**：模型被拦后陷入"反复尝试同一命令"死循环。
- **解决**：在 stderr 里给出**替代建议**而非纯拒绝，例如 `exit 2` 前输出 `建议改用 git revert <commit>`，模型会照做。

---

## 第三步：PostToolUse——每次写入后自动格式化

### 操作说明

`PreToolUse` 负责"不让坏事发生"，`PostToolUse` 负责"让好事自动发生"。配置：

```json
"PostToolUse": [
  {
    "matcher": "Write|Edit|MultiEdit",
    "hooks": [
      { "type": "command", "command": "bash .claude/hooks/auto-format.sh" }
    ]
  }
]
```

`.claude/hooks/auto-format.sh`：

```bash
#!/usr/bin/env bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // ""')
[ -z "$file" ] || [ ! -f "$file" ] && exit 0

case "$file" in
  *.js|*.ts|*.jsx|*.tsx|*.json|*.css)
    npx --no-install prettier --write "$file" 2>/dev/null ;;
  *.py)
    python -m black "$file" 2>/dev/null
    python -m isort "$file" 2>/dev/null ;;
  *.go)
    gofmt -w "$file" 2>/dev/null ;;
esac
exit 0
```

`PostToolUse` 的 stdin 还多了 `tool_response`，你可以据此判断工具是否成功。注意钩子**始终在项目根目录执行**（cwd 固定），所以脚本里用相对路径引用工具时要小心，引用文件本身则用 stdin 给的绝对 `file_path`。

### 预期结果

模型每次 `Edit` 完一个文件，下一次 `Read` 或 `git diff` 时，代码已经自动按 Prettier/Black 规范排好版。你不再需要手动 `npx prettier --write .`，也不用在 CLAUDE.md 里反复叮嘱"写完要格式化"——它**每次都执行，零遗漏**。

### 常见问题

- **问题**：格式化后文件变了，但模型"不知道"，继续按旧内容推理。
- **根因**：`PostToolUse` 改文件发生在工具返回之后，模型的上下文里还是改之前的版本。
- **解决**：这是已知 trade-off。可接受（格式化通常只动空白），或在关键流程里让模型改完显式 `Read` 一次。不要试图在钩子里改模型上下文——钩子是副作用通道，不是对话通道。

---

## 第四步：UserPromptSubmit——提交时注入上下文与拦截

### 操作说明

这是最被低估的钩子。它在**用户回车之后、送给模型之前**触发，能在不修改用户输入的前提下**追加上下文**，或拦截不合规提问。

```json
"UserPromptSubmit": [
  {
    "hooks": [
      { "type": "command", "command": "bash .claude/hooks/enrich-prompt.sh" }
    ]
  }
]
```

`.claude/hooks/enrich-prompt.sh`：

```bash
#!/usr/bin/env bash
input=$(cat)
prompt=$(echo "$input" | jq -r '.prompt // ""')

# 拦截：提示词里带真实密钥
if echo "$prompt" | grep -qE 'sk-ant-[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16}'; then
  echo "⛔ 检测到提示词含疑似 API 密钥，已拒绝提交。请先脱敏。" >&2
  exit 2
fi

# 注入：当前 git 分支与未提交文件数，让模型始终知晓上下文
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')

cat <<EOF
{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "[护栏注入] 当前分支: $branch | 未提交改动: $dirty 个文件。修改前请确认是否需要先 stash。"
  }
}
EOF
exit 0
```

这里用到了 **JSON stdout 控制**：返回 `hookSpecificOutput.additionalContext` 会被 harness 追加到这一轮上下文里，**用户看不到但模型看得到**。退出码 `2` 则拦截整次提交，stderr 提示给用户。

### 预期结果

你问"帮我把这个函数重构一下"，模型在动手前会自己先确认分支、先提示有未提交改动——因为它每次都被注入了 git 状态。你不写一行 CLAUDE.md 就获得了"团队级上下文感知"。

### 常见问题

- **问题**：注入的上下文太啰嗦，每轮都出现。
- **解决**：`additionalContext` 要**短、结构化、按需**。本例只两行。别注入整个 README。
- **问题**：拦截太严，正常提问也被挡。
- **解决**：`UserPromptSubmit` 的拦截要保守，只挡**确定性危险**（明文密钥、生产库 DDL），别用模糊正则误伤。

---

## 第五步：Stop——完工通知与自动收尾质检

### 操作说明

`Stop` 在模型结束本轮、把控制权交还用户时触发。最常见的两种用法：**桌面通知**（长任务跑完提醒）和**自动跑测试**（每次完工自检）。

```json
"Stop": [
  {
    "hooks": [
      { "type": "command", "command": "bash .claude/hooks/on-stop.sh" }
    ]
  }
]
```

`.claude/hooks/on-stop.sh`：

```bash
#!/usr/bin/env bash
input=$(cat)
# Stop hook 若返回 {"decision":"block","reason":"..."}，模型会被迫继续，适合"没跑测试不许停"
# 这里只做通知，不阻断

os=$(uname -s)
if [ "$os" = "Darwin" ]; then
  osascript -e 'display notification "Claude 已完成本轮任务" with title "Claude Code" sound name "Glass"'
else
  # Linux: 通用终端响铃 + 可选 libnotify
  printf '\a'
  command -v notify-send >/dev/null && notify-send "Claude Code" "已完成本轮任务"
fi
exit 0
```

进阶用法：如果想让"未通过测试就不许停"，可在脚本末尾返回：

```bash
if ! npm test --silent >/dev/null 2>&1; then
  echo '{"decision":"block","reason":"测试未通过，请修复后再结束。"}'
  # 模型会继续工作直到测试绿
fi
```

注意 `Stop` 的 block 是**强约束但有风险**——若测试环境本身坏掉，模型会陷入无限循环。务必设超时与重试上限。

### 预期结果

长任务（重构、批量改造）跑完，桌面弹出通知；如果你启用了 block 模式，模型在测试转绿前根本"停不下来"，相当于一个免费的 QA 把关人。

### 常见问题

- **问题**：macOS 通知不弹。
- **解决**：系统设置里给运行终端的 App 开"通知"权限，`osascript` 才能发声。
- **问题**：block 模式把模型卡死。
- **解决**：永远给 block 配一个"逃生出口"——脚本里计数，超过 N 次失败就放行并在 reason 里写明"测试环境异常，请人工介入"。

---

## 实战案例：构建一套"零信任"护栏套件

把上面四个钩子组装成一个可分发的项目级护栏套件，目录结构：

```
.claude/
├── settings.json          # 钩子注册
└── hooks/
    ├── guard-bash.sh      # PreToolUse: 拦危险命令
    ├── guard-write.sh     # PreToolUse: 保护 .env/密钥
    ├── auto-format.sh     # PostToolUse: 自动格式化
    ├── enrich-prompt.sh   # UserPromptSubmit: 注入+拦截
    └── on-stop.sh         # Stop: 通知+质检
```

`guard-write.sh` 补上文件保护：

```bash
#!/usr/bin/env bash
input=$(cat)
file=$(echo "$input" | jq -r '.tool_input.file_path // ""')

# 禁止写入/覆盖敏感文件
case "$file" in
  *.env|.env*|*secrets*|*credentials*|id_rsa|*.pem)
    echo "⛔ BLOCKED: 禁止直接修改敏感文件: $file" >&2
    exit 2 ;;
esac

# 禁止写入项目根之外的路径（防越权）
proj_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
case "$file" in
  "$proj_root"/*) exit 0 ;;   # 在项目内，放行
  *) echo "⛔ BLOCKED: 禁止写入项目目录之外: $file" >&2; exit 2 ;;
esac
```

完整的 `settings.json`：

```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/guard-bash.sh" }] },
      { "matcher": "Write|Edit|MultiEdit",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/guard-write.sh" }] }
    ],
    "PostToolUse": [
      { "matcher": "Write|Edit|MultiEdit",
        "hooks": [{ "type": "command", "command": "bash .claude/hooks/auto-format.sh" }] }
    ],
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command", "command": "bash .claude/hooks/enrich-prompt.sh" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "bash .claude/hooks/on-stop.sh" }] }
    ]
  }
}
```

部署后效果：任何 teammate `cd` 进这个项目，Claude Code 立刻"穿上护甲"——危险命令挡掉、敏感文件锁死、代码自动排版、提示词自带 git 上下文、完工自动通知。整套配置随仓库走，无需逐人培训。

### 验收清单

- 让模型执行 `rm -rf node_modules`（应放行）vs `rm -rf /`（应阻断）——验证正则颗粒度
- 让模型编辑 `.env`（应阻断）vs `.env.example`（看你正则怎么定）——验证白名单
- 模型改完 `app.tsx` 后 `git diff` 应已格式化——验证 PostToolUse
- 新会话首条提问，模型回答里隐含分支信息——验证 UserPromptSubmit 注入

---

## 工作流程图

```mermaid
flowchart TD
    U[用户输入提示词] --> UPS{UserPromptSubmit<br/>钩子}
    UPS -->|exit 2 拦截| UR[拒绝并提示用户]
    UPS -->|exit 0 + 注入 ctx| M[送入模型]
    M -->|决定调用工具| PTU{PreToolUse<br/>钩子}
    PTU -->|exit 2 阻断| BLK[工具不执行<br/>原因回喂模型]
    PTU -->|exit 0 放行| RUN[工具真实执行]
    RUN --> PTU2{PostToolUse<br/>钩子}
    PTU2 -->|格式化/lint| SIDE[副作用: 改文件/记录]
    SIDE --> M2[模型继续推理]
    M2 -->|本轮结束| STOP{Stop 钩子}
    STOP -->|通知| NTF[桌面/终端通知]
    STOP -->|decision block| M2B[强制继续: 未过测试不许停]
    STOP -->|放行| DONE[交还控制权给用户]
```

整条链路的关键在于：**模型永远在"被护栏包围"的沙箱里工作**。`PreToolUse` 是入口闸门，`PostToolUse` 是出口美化，`UserPromptSubmit` 是输入增强，`Stop` 是出口质检——四者闭环，构成确定性护栏。

---

## 关键避坑要点（来自生产环境踩坑）

1. **退出码语义优先于 JSON**：`exit 2` 跨版本最稳，能用退出码解决的就别上 JSON。需要细粒度控制（如 `permissionDecision: ask` 走二次确认弹窗）才用 JSON stdout。
2. **matcher 是正则、大小写敏感**：`Bash` 不是 `bash`，`Write|Edit|MultiEdit` 漏 `MultiEdit` 等于漏一半写入。
3. **钩子静默失败最致命**：缺 `jq`、脚本没 `chmod +x`、路径错——harness 不会报错，钩子直接"消失"。务必 `/hooks` 里逐条确认，并给每个脚本加 `set -euo pipefail` 之外的**宽松默认放行**（catch-all `exit 0`），避免护栏自身把正常工作流卡死。
4. **三层 settings 不要打架**：用户级 `~/.claude/settings.json` 放通用偏好，项目级放团队护栏，`.claude/settings.local.json` 放个人实验。项目级会**覆盖**而非合并同名 matcher，部署前理清优先级。
5. **拦而不教等于没拦**：每次 `exit 2` 都在 stderr 给出**为什么 + 怎么改**，模型才能优雅降级，而不是硬刚死循环。

---

## 延伸阅读

- 官方 Hooks 文档 `code.claude.com/docs` —— 退出码、JSON schema、全部 9 个钩子事件的权威定义
- `/hooks` 交互命令 —— 不用改 JSON 就能临时增删启停钩子，调试利器
- Permission Decision JSON —— `allow/deny/ask` 三态，`ask` 会弹原生确认框，适合"高危操作要人确认"场景
- SessionStart / SessionEnd / PreCompact —— 进阶钩子，可用于会话级埋点、压缩前保存关键上下文

---

## 训练营适用性评估

- **适合**：企业级 AI 编码训练营、DevOps 自动化训练营、团队工程规范落地工作坊。本主题把"AI 安全使用"从口号变成可执行代码，是少数能让学员**当场看到拦截生效**的高反馈主题。
- **预计时长**：理论 30 分钟 + 动手搭建四钩子 60 分钟 + 验收排错 30 分钟，合计约 2 小时。
- **难度**：中。要求学员能写 20 行 bash、看得懂 `jq` 和正则。
- **延伸作业**：让学员为本团队最常出事的命令（如误删表、误推大文件）各写一条 `guard-bash.sh` 规则，并在课堂上"攻防演练"——一人当模型尝试破坏，一人当护栏，看谁先被拦。

---

## 参考来源

1. [Claude Code Hooks: PreToolUse & PostToolUse Tutorial — Paul Schick](https://paul-schick.com/posts/claude-code-hooks-pretooluse-posttooluse/) — 用 PreToolUse 阻断 `rm -rf`、PostToolUse 接 Prettier、保护 `.env` 的确定性护栏实战，本文拦截与格式化脚本的主要范式来源。
2. [Intercept and control agent behavior with hooks — Claude Code 官方文档](https://code.claude.com/docs/en/agent-sdk/hooks) — 钩子生命周期事件、matcher 正则语法、stdin JSON 输入与退出码/JSON 控制协议的权威定义。
3. [Claude Code Hooks: 12 Production Configs I Run Daily — Bruce](https://www.heyuan110.com/posts/ai/2026-02-28-claude-code-hooks-guide/) — 12 个生产级钩子配置与 4 个静默失效故障模式，本文"避坑要点"与静默失败排查的主要依据。
4. [Claude Code Hooks Guide: PreToolUse, PostToolUse, Stop — AI Productivity](https://aiproductivity.ai/guides/claude-code-hooks-deep-dive/) — 各钩子事件的 matcher 配置与 Stop 钩子通知/质检用法的图解指南。
5. [Claude Code Hooks: Complete Guide with 20+ Examples — AIorg](https://aiorg.dev/blog/claude-code-hooks) — 20+ 可复制粘贴的钩子配置，覆盖 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)
