学习目标:读完本文,你能用 Claude Code 的
PreToolUse/PostToolUse拦截式钩子,搭出一套"危险命令必拦、密钥泄露必挡、写完文件自动格式化、全部操作留痕"的自动化安全门禁,并把它接到-p批量模式与Setup钩子上,让无人值守的批量任务也跑在护栏之内。适用人群:已经用过 Claude Code 做过几个项目、想让它在 CI/批处理场景里"撒手跑"却不敢的开发者。假设你了解
settings.json的基本结构,但不熟悉钩子的拦截机制。
当你在交互模式下用 Claude Code,每一次 rm -rf、每一次写入 .env、每一次 git push --force,你都能在终端里看见并手动放行或拒绝。可一旦切到 -p 批量模式——比如让它一夜之间重构 100 个文件——你就失去了这层人眼监督。模型一旦"自作主张"执行了危险命令,可能造成的损失往往要到事后才被发现。
Claude Code 的权限系统(permissions.allow/deny)能做静态规则匹配,比如"永远禁止 Bash(rm -rf:*)"。但真实项目里的危险操作常常是动态的:
AKIA... 形态的 AWS 密钥——静态规则无法预判内容;rm -rf $BUILD_DIR,$BUILD_DIR 是否指向 / 取决于运行环境;这些场景正是拦截式钩子(PreToolUse / PostToolUse)的用武之地。与"上下文注入式"钩子(SessionStart、UserPromptSubmit,用于往对话里塞背景信息)不同,拦截式钩子的核心能力是在工具执行前/后做判断并返回决策——既能挡,也能放行,还能在放行后顺手补一刀(格式化、留痕、扫描)。
本文要造的"自动化安全门禁"由四道关卡组成,全部用钩子实现:
| 关卡 | 钩子事件 | 职责 |
|---|---|---|
| 命令护栏 | PreToolUse (matcher=Bash) |
拦 rm -rf /、git push -f、curl | sh 等危险命令 |
| 密钥门神 | PreToolUse (matcher=Edit|Write) |
写入前扫描 diff,发现疑似密钥则 deny |
| 自动修复 | PostToolUse (matcher=Edit|Write) |
写完自动跑 prettier/eslint --fix |
| 全量留痕 | PostToolUse (matcher=*) |
每次工具调用追加到审计日志 |
| CI 预检 | Setup (matcher=maintenance) |
-p --maintenance 模式下做一次性环境检查 |
Setup 钩子需要较新版本)。用 claude --version 确认。.js 或 .ts 文件,已装好 prettier、eslint(npm i -D prettier eslint)。jq(钩子脚本要用它解析 stdin 上的 JSON)。jq --version 确认;没有就 brew install jq 或 apt install jq。在动手前,先建立一个心智模型。Claude Code 把钩子事件按触发频率分成三档,理解这三档是判断"用哪个事件"的前提:
flowchart TD
A[会话开始] --> B[SessionStart 一次/会话]
B --> C[你提交一条 prompt]
C --> D[UserPromptSubmit 一次/轮]
D --> E[智能体循环 每轮可能多次]
E --> F{PreToolUse 工具执行前}
F -->|deny| G[阻断 该工具不执行]
F -->|放行| H[工具真正执行]
H --> I[PostToolUse 执行后]
I --> J[同一轮内继续调用工具?]
J -->|是| F
J -->|否| K[Stop 本轮结束]
K --> L[你下一条 prompt]
L --> D
style G fill:#fdd,stroke:#c00
style H fill:#dfd,stroke:#0a0
关键点:只有 PreToolUse 能在工具真正执行前把它拦下来。PostToolUse 是"事后"——文件已经写完了、命令已经跑完了,你能做的是格式化、留痕、告警,但无法阻止已发生的事。所以"挡"的活儿必须交给 PreToolUse,"补救"的活儿才交给 PostToolUse。这是整条门禁的设计基石。
钩子的配置统一写在 .claude/settings.json 的 hooks 字段下,三层嵌套:事件名 → matcher 组 → handler 列表。下面我们逐关搭建。
rm -rf /先建钩子脚本。在项目根目录创建 .claude/hooks/block-dangerous.sh:
#!/usr/bin/env bash
# .claude/hooks/block-dangerous.sh
# 从 stdin 读取钩子输入 JSON,提取 Bash 命令,命中黑名单则 deny
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# 危险命令黑名单:递归删根、强制推送到主分支、管道执行远程脚本
deny_patterns=(
'rm[[:space:]]+-rf[[:space:]]+/?($|[[:space:]])' # rm -rf / 或 rm -rf / ...
'git[[:space:]]+push.*--force' # 强制推送
'curl[[:space:]].*\|[[:space:]]*sh' # curl ... | sh
'>[[:space:]]*/dev/sda' # 直接写裸设备
)
for pat in "${deny_patterns[@]}"; do
if echo "$COMMAND" | grep -Eq "$pat"; then
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "命令命中安全门禁黑名单: $pat。请改用更安全的等价操作,例如限制作用域或分步确认。"
}
}
EOF
exit 0
fi
done
exit 0 # 静默放行:exit 0 + 无输出 = 不干预,走正常权限流程
给它执行权限:chmod +x .claude/hooks/block-dangerous.sh。
决策返回的两种姿势:脚本通过 stdout 打印一段 JSON,里面
permissionDecision设为"deny"即可阻断。"deny"是硬挡(直接拒绝且把 reason 喂给模型);此外还有"allow"(直接放行,绕过后续权限弹窗)。如果脚本什么都不输出、exit 0,表示"我不表态",工具调用会继续走默认权限流程——保持沉默不会批准它。另一个姿势是直接exit 2,这也会阻断,但 reason 只能来自 stderr 文本,不如 JSON 灵活,本文统一用 JSON。
然后在 .claude/settings.json 注册它:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *) || Bash(git push *) || Bash(curl *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh"
}
]
}
]
}
}
注意 if 字段。它用权限规则语法对工具名+参数一起过滤,比 matcher(只匹配工具名)更细。Bash(rm *) 的意思是"只有当 Bash 命令的某个子命令以 rm 开头时,这个 handler 才真正 spawn"。
if 的妙处在于省进程:模型跑 npm test 时,Bash(rm *) 不匹配,脚本根本不会启动。只有当命令形如 rm -rf dist 时脚本才会被唤起。这是官方文档反复强调的性能要点——if 是"最佳努力"过滤器,能过滤掉绝大多数无关调用。
你可以手动喂数据测试脚本逻辑,不必真去触发:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
| .claude/hooks/block-dangerous.sh
应输出一段含 "permissionDecision": "deny" 的 JSON。当模型在会话里真的想跑 rm -rf /tmp/build 时,工具调用会被阻断,模型会收到你写的中文 reason,并据此换一种写法重试。
settings.json 没被读到。Claude Code 只读取四个位置(~/.claude/settings.json、项目 .claude/settings.json、.claude/settings.local.json、托管策略)。检查文件路径和 JSON 语法(jq . .claude/settings.json 能否正常解析)。if 语法报错或永远不触发:if 只接受单条权限规则,不支持 &&/|| 列表。上面示例里的 || 是为了演示——真实配置里要么拆成多个 handler,要么干脆不要 if、在脚本里用 grep 兜底(更简单可靠)。这是踩坑高发点:安全拦截宁可多跑几次脚本,也不要依赖 if 做精确判断。deny 的 reason 会被当成系统提示喂给模型,只要 reason 写得够清楚("改用 rm -rf ./dist 限定目录"),模型通常会自己纠正。若反复卡住,说明 reason 不够可执行。静态 deny 规则挡不住"内容里的密钥",因为权限系统看的是命令/路径,看不到即将写入的字符。这一关用 PreToolUse 拦截 Edit/Write,把待写内容过一遍密钥正则。
新建 .claude/hooks/secret-scan.sh:
#!/usr/bin/env bash
# .claude/hooks/secret-scan.sh
# 扫描 Edit/Write 的待写内容,命中常见密钥形态则 deny
set -euo pipefail
INPUT=$(cat)
# Write 的内容在 tool_input.content;Edit 的新内容在 tool_input.new_string
CONTENT=$(echo "$INPUT" | jq -r '
.tool_input.content // .tool_input.new_string // ""')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // "?"')
# 常见密钥指纹:AWS Access Key、GitHub PAT、Google API Key、私钥头
patterns=(
'AKIA[0-9A-Z]{16}' # AWS Access Key ID
'gh[pousr]_[A-Za-z0-9]{36}' # GitHub fine-grained / classic PAT
'AIza[0-9A-Za-z_-]{35}' # Google API Key
'-----BEGIN (RSA |EC |)PRIVATE KEY-----'
)
for pat in "${patterns[@]}"; do
if echo "$CONTENT" | grep -Eq "$pat"; then
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "检测到疑似密钥 ($pat) 将写入 $FILE_PATH。请改用环境变量或密钥管理服务,不要把密钥硬编码进文件。"
}
}
EOF
exit 0
fi
done
exit 0
chmod +x 后注册:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/secret-scan.sh"
}
]
}
]
}
}
当模型试图 Write 一个含 AKIAIOSFODNN7EXAMPLE 的配置文件时,写入会被拦在执行前,模型收到密钥告警。这一关的价值在批量场景里尤其明显:让模型重构 100 个文件时,它会遍历读取并改写,过程中很容易把某个 .env.example 的真实密钥"顺手"复制到代码里——门神会在那一刻喊停。
AKIA[0-9A-Z]{16} 已经相当精确;若项目里有合法的长 token,可以在脚本里加一个白名单文件 .claude/secret-allowlist.txt,命中后跳过。jq 解析 content 失败:Edit 工具的内容字段是 new_string,Write 是 content,MultiEdit 是 edits 数组。脚本里用 // 提供 fallback,但 MultiEdit 要单独处理(遍历 .edits[].new_string)。permissions.deny 里的 Read(.env) 从源头禁止读取,双管齐下。"挡"是防守,"修"是进攻。模型写完文件后,代码风格未必符合团队规范。用 PostToolUse 在写入成功后自动跑格式化,既省了模型一次工具调用,又保证落盘的代码永远是干净的。
新建 .claude/hooks/auto-format.sh:
#!/usr/bin/env bash
# .claude/hooks/auto-format.sh
# 写入成功后,对目标文件跑 prettier(若项目里有)
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[ -z "$FILE_PATH" ] && exit 0
[ -f "$FILE_PATH" ] || exit 0
# 只处理 JS/TS,且项目装了 prettier 才跑
case "$FILE_PATH" in
*.js|*.ts|*.jsx|*.tsx|*.json|*.css|*.md)
if [ -x "node_modules/.bin/prettier" ]; then
node_modules/.bin/prettier --write "$FILE_PATH" >/dev/null 2>&1 || true
fi
;;
esac
exit 0
注册到 PostToolUse:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-format.sh"
}
]
}
]
}
}
每次模型写完一个 .js 文件,prettier 立刻在该文件上跑一次。模型下次读这个文件时看到的就是格式化后的版本,避免"格式漂移"累积。
关键陷阱:PostToolUse 的脚本如果输出到 stderr 或非零退出,可能会把错误信息当成"工具执行异常"反馈给模型,导致模型困惑地"修复"一个根本不存在的问题。所以脚本结尾务必 exit 0,内部命令加 || true 吞掉错误。PostToolUse 永远不要 deny——它没有 permissionDecision 这回事,工具已经执行完了。
合规场景要求"模型动过什么,全记下来"。一个 matcher 为 * 的 PostToolUse 钩子,能把每次工具调用追加进日志文件。因为"记日志"不影响主流程,这里正好用上 async 字段——后台运行,不阻塞智能体循环。
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"async": true,
"command": "jq -c '{ts: now|todateiso8601, tool: .tool_name, input: .tool_input}' >> ${CLAUDE_PROJECT_DIR}/.claude/audit.jsonl"
}
]
}
]
}
}
.claude/audit.jsonl 会逐行累积形如 {"ts":"2026-07-01T08:14:22Z","tool":"Edit","input":{...}} 的记录。async: true 让写日志在后台进行,模型不会停下来等磁盘 I/O。把 audit.jsonl 加进 .gitignore,避免把审计日志本身提交进仓库。
asyncvsasyncRewake:普通async是"发了就忘",钩子结果不影响模型。asyncRewake: true则在钩子以退出码 2 结束时唤醒模型,把 stderr 当系统提醒塞回去——适合"后台长任务失败时让模型知道"的场景,但写审计日志用不到。
Setup 钩子——给 -p 批量任务做一次性预检前三关是"运行中"的护栏。但批量任务开始前,你还想做一次环境体检:依赖装好了吗?.env 没被误提交吗?测试能跑通吗?Setup 事件专为此设计——它只在 claude --init-only 或 -p 模式带 --init/--maintenance 时触发,做一次性的 CI/脚本准备。
新建 .claude/hooks/preflight.sh:
#!/usr/bin/env bash
# .claude/hooks/preflight.sh
# 批量任务启动前的一次性体检
set -uo pipefail
fail=0
echo "[preflight] 检查依赖..." >&2
[ -d node_modules ] || { echo "✗ node_modules 缺失,先 npm ci" >&2; fail=1; }
echo "[preflight] 检查密钥是否误提交..." >&2
if git ls-files | grep -Eq '\.env$'; then
echo "✗ .env 已被 git 跟踪,立即处理" >&2; fail=1
fi
echo "[preflight] 检查测试基线..." >&2
if ! npm test --silent >/dev/null 2>&1; then
echo "✗ 测试基线未通过,先修红再开工" >&2; fail=1
fi
[ $fail -eq 0 ] && echo "[preflight] 通过" >&2
exit 0
注册:
{
"hooks": {
"Setup": [
{
"matcher": "maintenance",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/preflight.sh" }
]
}
]
}
}
claude -p --maintenance "检查环境" 2>&1 | grep preflight
会看到三步体检的输出。--maintenance 模式下 Claude Code 跑完 Setup 钩子就做收尾,不进入完整智能体循环——非常适合"只体检不干活"的 CI 步骤。体检不过时,脚本的 stderr 会出现在日志里,CI 据此决定是否继续。
-p 批量流水线现在把所有钩子合并进同一个 settings.json(同名事件下可以放多个 matcher 组),再写一个批量驱动脚本。完整配置:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh" }
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/secret-scan.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-format.sh" }
]
},
{
"matcher": "*",
"hooks": [
{ "type": "command", "async": true,
"command": "jq -c '{ts: now|todateiso8601, tool: .tool_name, input: .tool_input}' >> ${CLAUDE_PROJECT_DIR}/.claude/audit.jsonl" }
]
}
],
"Setup": [
{
"matcher": "maintenance",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/preflight.sh" }
]
}
]
}
}
驱动脚本 .claude/run-batch.sh:先体检,再以 stream-json 输出跑批量任务,最后扫审计日志统计拦截次数。
#!/usr/bin/env bash
# .claude/run-batch.sh <prompt-file>
set -euo pipefail
PROMPT_FILE="${1:?用法: run-batch.sh <prompt-file>}"
echo "=== 1. 预检 ==="
claude -p --maintenance "preflight" || { echo "预检失败,中止"; exit 1; }
echo "=== 2. 批量执行(护栏已激活)==="
claude -p "$(cat "$PROMPT_FILE")" \
--output-format stream-json \
--verbose \
| tee .claude/run-stream.jsonl
echo "=== 3. 拦截统计 ==="
grep -c '"deny"' .claude/audit.jsonl 2>/dev/null \
&& echo "次危险操作被门禁拦截" \
|| echo "本次无拦截"
--output-format stream-json 把每个工具调用、每次拦截都吐成结构化 JSON 行,方便事后用 jq 复盘模型在批量任务里到底做了什么、被挡了几次。这就是一条"预检 → 护栏内批量 → 审计复盘"的完整闭环。
sequenceDiagram
autonumber
participant U as 你的脚本 run-batch.sh
participant CC as Claude Code (-p)
participant H as 钩子处理器
participant FS as 文件系统
U->>CC: claude -p --maintenance (预检)
CC->>H: Setup 事件 → preflight.sh
H-->>CC: 通过 / 失败
alt 预检失败
CC-->>U: 中止
else 预检通过
U->>CC: claude -p "批量重构" --output-format stream-json
loop 每个工具调用
CC->>H: PreToolUse (Bash?) → block-dangerous.sh
alt 命中黑名单
H-->>CC: deny + 中文理由
CC->>CC: 模型据理由换写法
else 放行
H-->>CC: 静默 (exit 0)
CC->>H: PreToolUse (Edit?) → secret-scan.sh
alt 命中密钥
H-->>CC: deny
else 放行
CC->>FS: 真正执行写入
CC->>H: PostToolUse → auto-format.sh
H->>FS: prettier --write
H-->>CC: (async) 审计日志追加
end
end
end
CC-->>U: stream-json 流(含每次拦截)
U->>U: grep deny 统计拦截次数
end
settings.json 要重开会话;-p 模式每次都是新进程,无需担心。可用 claude --debug hooks(若版本支持)观察钩子是否被加载与触发。* 会匹配所有工具)。把审计日志这类"全量"钩子设 async: true;把格式化这类"按需"钩子用精确 matcher(Edit|Write|MultiEdit)。if 字段没按预期过滤:记住 if 是"最佳努力",遇到无法解析的 Bash 命令会fail open(照样跑钩子)。所以 if 用来省进程可以,用来做安全判定不行——安全判定必须在脚本内部用 grep/正则兜死。官方文档原话:硬性的允许/拒绝要交给权限系统,钩子做不了百分百保证。sh -c(Git Bash)。.sh 脚本在原生 PowerShell 环境跑不了,需在 handler 里加 "shell": "powershell" 并改写脚本,或确保装了 Git Bash。exec form(带 args)能避免 shell 差异,建议优先用 "command": "node", "args": ["..."] 跑跨平台脚本。.jsonl 会一直长。定期 logrotate,或在脚本里加"超过 N MB 就轮转"的逻辑。async 钩子即便慢也不会拖累主流程,但磁盘满了仍会出问题。| 维度 | 评估 |
|---|---|
| 适合的训练营类型 | CI/CD 自动化营、DevSecOps 营、AI 工程化营。本教程的核心收益是"让 AI 无人值守也安全",正是这些方向的关键能力。 |
| 不太适合 | 纯入门营。需要先理解权限系统与会话生命周期,零基础建议先学"权限配置入门"。 |
| 预计教学时长 | 3–4 小时。其中 1 小时讲钩子三种节奏与决策返回机制,2 小时动手搭五道关,0.5–1 小时跑批量流水线并复盘审计日志。 |
| 学员产出 | 一个可提交进仓库的 .claude/ 目录(settings.json + 5 个钩子脚本 + run-batch.sh),能在自己的项目里直接复用。 |
| 进阶延伸 | 学完可接 prompt/agent 类型钩子(让另一个 Claude 实例做内容审查),或把 HTTP 钩子接到企业 SIEM 做集中式审计。 |
permissionDecision 决策机制、if/matcher 语法、async/Setup 等,本文所有技术细节的权威出处。rm -rf、保护 .env、用 PostToolUse 接 Prettier 自动格式化。[!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 等场景全覆盖。
