首页 › tutorials › 造双钩护栏拦危险命令并自动格式化

概述

本教程带你为 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.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% 的护栏只用两个:

钩子和工具之间,靠两个"信号"通信:

  1. stdin 上的 JSON:Claude Code 把这次工具调用的 tool_name、tool_input(命令/文件路径)等推给钩子脚本。
  2. 退出码 + stdout JSON: - 退出码 2 是"魔法数字"——表示阻止本次工具调用,并把 stderr 内容回喂给 Claude,让它知道为什么被拦。 - 也可以用结构化 JSON 输出做更精细的控制,对 PreToolUse 最重要的字段是:
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "rm -rf 已被安全护栏拦截"
  }
}

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

钩子执行时序

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:

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

赋予执行权限:

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

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

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

# 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:

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

同样独立测试:

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:

#!/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 内置的权限层,钩子是额外的脚本层,两者任一拦截都能挡住事故。

{
  "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": "正在格式化…"
          }
        ]
      }
    ]
  }
}

配置要点解读:

保存后重启 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,不如把危险命令改写成安全版再放行:

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

完整护栏工作流

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

延伸阅读

参考来源

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

训练营适用性评估

[!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学通信知识星球海报-扫码加入