PreToolUse:阻断危险命令和高风险文件写入
TL;DR: PreToolUse 是 Claude Code 安全控制的核心关卡。它通过退出码 2 阻断工具调用,是唯一能物理阻止 AI 行为的机制。设计原则:只拦截明确危险的操作,不做复杂业务判断。
为什么 PreToolUse 是第一道防线
Claude Code 的工具权限系统是粗粒度的——它控制"这个工具能不能用",但不控制"这次调用安不安全"。Permission System 说 Bash 可以用,但不会区分 git status 和 rm -rf /。
PreToolUse 填补的就是这个间隙:在工具执行前,用确定性脚本检查这次具体调用的参数,判断是否安全。
Permission System: Bash ✓ (工具级别)
PreToolUse Hook: Bash "rm -rf /" ✗ (调用级别)
Bash "git status" ✓ (调用级别)
这种分层设计的好处:权限系统管理"能不能",Hook 管理"该不该"。两者独立运作,互不干扰。
stdin JSON Schema
Claude Code 运行时在每次工具调用前,将完整的调用上下文以 JSON 格式通过 stdin 传递给 Hook 脚本。理解这个 schema 是编写正确 Hook 的前提。
完整输入结构
{
"session_id": "abc123",
"transcript_path": "/Users/dev/.claude/projects/my-app/00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/dev/workspace/my-app",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/Users/dev/workspace/my-app/.env",
"content": "DATABASE_URL=postgres://..."
}
}
各工具的 tool_input 差异
不同工具的 tool_input 结构完全不同,Hook 脚本必须根据 tool_name 字段区分处理。
// Edit 工具
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "/Users/dev/workspace/my-app/src/auth.ts",
"old_string": "const token = ''",
"new_string": "const token = process.env.TOKEN"
}
}
// Write 工具
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/Users/dev/workspace/my-app/src/new-module.ts",
"content": "export function init() { ... }"
}
}
// Bash 工具
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build-artifacts",
"description": "Clean build artifacts"
}
}
// MCP 工具(如 GitHub MCP)
{
"session_id": "abc123",
"hook_event_name": "PreToolUse",
"tool_name": "mcp__github__create_issue",
"tool_input": {
"owner": "my-org",
"repo": "my-app",
"title": "Fix auth bug",
"body": "Description..."
}
}
Hook 脚本中的标准解析模式
INPUT=$(cat)
# 提取基础字段
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
case "$TOOL_NAME" in
"Edit"|"Write")
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 处理文件路径检查
;;
"Bash")
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# 处理命令检查
;;
*)
# 未知工具,放行
;;
esac
关键字段说明:
| 字段 | 类型 | 用途 |
|---|---|---|
session_id |
string | 当前会话标识,可用于日志关联 |
transcript_path |
string | 会话记录文件路径 |
cwd |
string | 当前工作目录 |
hook_event_name |
string | 事件名,固定为 PreToolUse |
tool_name |
string | 被调用的工具名称 |
tool_input |
object | 工具参数,结构因工具而异 |
适合拦截的操作
不是所有操作都值得用 PreToolUse 拦截。判断标准:后果不可逆 + 误判概率低。
高优先级(建议阻断)
| 操作 | 风险 | 误判概率 |
|---|---|---|
rm -rf 等批量删除命令 |
文件不可恢复 | 极低 |
修改 .env、.env.* |
密钥泄露或配置错误 | 极低 |
修改证书文件(*.pem、*.key) |
认证中断 | 极低 |
git push --force |
提交历史不可逆丢失 | 低 |
修改生产基础设施配置(infra/prod/**) |
生产环境故障 | 极低 |
数据库迁移文件(migrations/**) |
Schema 变更不可逆 | 低 |
| 对 main/master 的直接提交 | 违反分支保护 | 低 |
中优先级(建议提醒,不阻断)
| 操作 | 风险 | 误判概率 |
|---|---|---|
修改 package.json |
依赖版本变更 | 中 |
| 修改 CI 配置文件 | 构建流水线受影响 | 中 |
运行 npm publish、docker push |
包发布不可撤回 | 低 |
| 修改测试快照文件 | 测试基线漂移 | 中 |
不适合拦截
| 操作 | 原因 |
|---|---|
| 任意文件编辑 | 误判概率太高,会破坏正常开发 |
| 所有 Bash 命令 | 开发体验严重退化 |
| 基于代码语义的判断 | Hook 不应承担语义分析 |
Hook 脚本 1:文件保护
阻止对敏感配置文件和密钥文件的写入。
#!/bin/bash
# .claude/hooks/block-sensitive-files.sh
#
# PreToolUse Hook: 阻断对敏感文件的写入
# Matcher: Edit|Write
set -euo pipefail
# 敏感文件模式列表
# 格式: 模式 = 阻断原因
BLOCKED_PATTERNS=(
".env:环境变量文件,密钥可能泄露"
".env.:.env 变体文件,密钥可能泄露"
".pem:SSL/TLS 证书文件"
".key:私钥文件"
"infra/prod/:生产基础设施配置"
"migrations/:数据库迁移文件,Schema 变更不可逆"
"terraform/state:TF 状态文件"
".ssh/:SSH 配置目录"
"docker-compose.prod.yml:生产 Docker 配置"
)
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 没有文件路径,放行
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
for entry in "${BLOCKED_PATTERNS[@]}"; do
PATTERN="${entry%%:*}"
REASON="${entry##*:}"
if [[ "$FILE_PATH" == *"$PATTERN"* ]]; then
echo "BLOCK: 文件 $FILE_PATH 匹配受限模式 [$PATTERN]。原因: $REASON。如需修改请联系团队负责人。"
exit 2
fi
done
exit 0
脚本设计要点:
set -euo pipefail。脚本出错时立即退出,不静默继续。结合"异常时放行"的退出码语义,脚本出错不会阻断。- 模式与原因绑定。
BLOCKED_PATTERNS数组中每个条目包含匹配模式和人类可读的原因。阻断消息中展示原因,让开发者知道为什么被拦截。 - 空路径放行。如果工具输入中没有
file_path字段(如某些 Bash 调用),直接放行。 - 匹配逻辑简单。只有
*pattern*通配符匹配,没有正则表达式。简单意味着可预测。
Hook 脚本 2:分支保护
阻止在 main/master 分支上的直接文件修改。
#!/bin/bash
# .claude/hooks/block-main-branch.sh
#
# PreToolUse Hook: 阻止在 main/master 分支上编辑文件
# Matcher: Edit|Write
#
# 设计意图: 强制使用 feature 分支 + PR 流程,
# 防止 AI 直接在受保护分支上修改代码
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 没有文件路径,放行(非文件操作工具)
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
# 从 cwd 或 git 获取当前分支
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
if [[ -z "$CWD" ]]; then
CWD="$(pwd)"
fi
BRANCH=""
BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
# 无法获取分支信息,放行(宁可漏过也不要阻断)
if [[ -z "$BRANCH" ]]; then
exit 0
fi
case "$BRANCH" in
main|master)
echo "BLOCK: 当前分支为 $BRANCH,禁止直接编辑文件。请切换到 feature 分支: git checkout -b feat/your-feature"
exit 2
;;
*)
# 其他分支,放行
;;
esac
exit 0
设计要点:
- 分支检测用 git 命令。
git rev-parse --abbrev-ref HEAD是获取当前分支名的标准方法,输出稳定。 - 检测失败时放行。如果不在 git 仓库中(
git命令失败),或cwd无法获取,脚本不阻断。宁可漏判也不误判。 - 仅保护 main/master。release 分支、develop 分支等不在保护范围内,避免过度限制。
- 阻断消息包含操作指引。直接给出
git checkout -b命令,Claude 可以在阻断后立即执行分支切换。
Hook 脚本 3:命令过滤
拦截 Bash 工具中的危险命令模式。
#!/bin/bash
# .claude/hooks/block-dangerous-commands.sh
#
# PreToolUse Hook: 阻断危险的 Bash 命令
# Matcher: Bash
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# 没有命令,放行
if [[ -z "$COMMAND" ]]; then
exit 0
fi
# 危险命令检查(从高到低排列,先匹配最危险的)
# 1. 批量删除
if echo "$COMMAND" | grep -qE '(rm\s+-rf\s+/|rm\s+-rf\s+~|rm\s+-rf\s+\*)'; then
echo "BLOCK: 命令包含危险的批量删除: rm -rf /, ~, 或 *。这会删除系统或用户文件。"
exit 2
fi
# 2. 强制推送
if echo "$COMMAND" | grep -qE 'git\s+push.*--force'; then
echo "BLOCK: git push --force 会不可逆地覆盖远程提交历史。使用 --force-with-lease 替代。"
exit 2
fi
# 3. 直接向 main/master 推送
if echo "$COMMAND" | grep -qE 'git\s+push\s+(origin\s+)?main\b'; then
echo "BLOCK: 直接推送到 main 分支。请使用 feature 分支和 PR 流程。"
exit 2
fi
if echo "$COMMAND" | grep -qE 'git\s+push\s+(origin\s+)?master\b'; then
echo "BLOCK: 直接推送到 master 分支。请使用 feature 分支和 PR 流程。"
exit 2
fi
# 4. 生产部署命令
if echo "$COMMAND" | grep -qE '(kubectl\s+apply.*--prod|ansible-playbook.*prod|terraform\s+apply.*prod)'; then
echo "BLOCK: 生产部署命令。部署应通过 CI/CD 流水线执行,不应由 AI 直接运行。"
exit 2
fi
# 5. 数据库写操作
if echo "$COMMAND" | grep -qE '(psql.*DELETE|psql.*DROP|psql.*TRUNCATE|mysql.*DELETE|mysql.*DROP)'; then
echo "BLOCK: 数据库写操作(DELETE/DROP/TRUNCATE)。数据库变更应通过迁移脚本和 review 流程。"
exit 2
fi
# 6. 权限变更
if echo "$COMMAND" | grep -qE '(chmod\s+777|chmod\s+-R\s+777|chown\s+.*root)'; then
echo "BLOCK: 不安全的权限变更(chmod 777)。"
exit 2
fi
exit 0
设计要点:
- 检查顺序。最危险的模式优先匹配。
rm -rf /比git push --force更严重,应该先检查。 - 提供替代方案。阻断消息不仅说明为什么被拦截,还告诉 Claude 正确的做法(如"使用 --force-with-lease 替代")。这让 Claude 可以在被阻断后自我修正。
- 正则表达式保持简单。每个模式只匹配一类命令。不做模糊匹配,不做上下文推断。
- 不尝试理解命令意图。Hook 只做字符串模式匹配,不解析命令语义。语义分析是不可靠的,模式匹配是确定的。
Matcher 配置深入
Matcher 决定 Hook 对哪些工具生效。它是 settings.json 中 Hook 配置的第一道过滤——只有 tool_name 匹配 Matcher 的工具调用才会触发 Hook 脚本。错误的 Matcher 配置是 Hook 误判的首要原因。
Matcher 匹配规则
Matcher 的值是一个正则表达式,对 tool_name 字段做完整匹配(即隐含 ^ 和 $ 锚点)。| 表示或,.* 匹配任意字符序列。
"Edit|Write" → 精确匹配 Edit 或 Write
"Bash" → 精确匹配 Bash
"mcp__.*" → 匹配所有 MCP 工具
"mcp__github__.*" → 匹配 GitHub MCP server 的所有工具
".*" → 匹配所有工具(慎用)
常见 Matcher 模式
文件写入拦截——只拦截 Edit 和 Write,Read 等只读工具不受影响:
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/check-writes.sh" }]
}
命令过滤——只拦截 Bash 工具,检查 tool_input.command 字段:
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/check-bash.sh" }]
}
全量 MCP 拦截——拦截所有 MCP 工具调用。MCP 工具名格式为 mcp__server_name__tool_name:
{
"matcher": "mcp__.*",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/check-mcp.sh" }]
}
特定 MCP server——只拦截 database MCP server 的工具,比全量拦截更精确:
{
"matcher": "mcp__database__.*",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/check-db-mcp.sh" }]
}
特定 MCP 操作——拦截所有 MCP server 的写操作类工具:
{
"matcher": "mcp__.*__write.*",
"hooks": [{ "type": "command", "command": "bash .claude/hooks/check-mcp-writes.sh" }]
}
Matcher 与 Hook 脚本的关系
Matcher 决定了哪些工具调用会到达 Hook 脚本。如果一个 Hook 的 Matcher 是 Edit|Write,那么当 Bash 工具被调用时,这个 Hook 根本不会执行。这有两层含义:
- Matcher 是第一道过滤,脚本逻辑是第二道。一个精确的 Matcher 可以简化脚本逻辑——脚本不需要处理不相关的工具。
- Matcher 太宽泛会浪费资源。
.*会让每次工具调用都触发脚本,包括 Read、Grep 等无害操作。
完整的 settings.json 配置
将三个 Hook 组合到完整的配置中:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/block-sensitive-files.sh"
},
{
"type": "command",
"command": "bash .claude/hooks/block-main-branch.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/block-dangerous-commands.sh"
}
]
},
{
"matcher": "mcp__.*",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/audit-mcp-calls.sh"
}
]
}
]
}
}
这个配置覆盖了三个维度:
- Edit/Write 工具:文件路径保护 + 分支保护(两个 Hook 串行执行)
- Bash 工具:命令过滤
- MCP 工具:审计日志(记录但不阻断)
注意同一个 Matcher 下可以配置多个 Hook,它们按配置顺序串行执行。任何一个 Hook 返回 exit 2 都会阻断工具调用,后续 Hook 不再执行。
渐进式部署策略
PreToolUse Hook 的误判会直接阻断正常工作。渐进式部署是降低风险的关键。核心思路:先观察,再提醒,最后阻断。
阶段 1:记录模式(第 1 周)
只记录匹配数据,不阻断任何操作。目标:收集统计信息,判断误判率和遗漏率。
#!/bin/bash
# .claude/hooks/guard-files.sh — 阶段 1: 只记录,不阻断
#
# settings.json 中 Matcher 为 Edit|Write
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
PATTERNS=(".env" ".pem" ".key" "infra/prod/" "migrations/" "terraform/state")
for pattern in "${PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "[$(date -Iseconds)] WOULD_BLOCK: $FILE_PATH matches [$pattern]" >> .claude/hooks/hook-log.txt
# 阶段 1: 仍然放行
exit 0
fi
done
exit 0
对应的 settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/guard-files.sh"
}
]
}
]
}
}
一周后检查 hook-log.txt,统计匹配频率和误判样本。
阶段 2:提醒模式(第 2 周)
输出警告消息但不阻断。目标:验证提示的可读性,观察 Claude 对提醒的响应行为。
#!/bin/bash
# .claude/hooks/guard-files.sh — 阶段 2: 提醒但不阻断
#
# settings.json 不变,只改脚本逻辑
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
PATTERNS=(
".env:环境变量文件,密钥可能泄露"
".pem:SSL/TLS 证书文件"
".key:私钥文件"
"infra/prod/:生产基础设施配置"
"migrations/:数据库迁移文件"
)
for entry in "${PATTERNS[@]}"; do
PATTERN="${entry%%:*}"
REASON="${entry##*:}"
if [[ "$FILE_PATH" == *"$PATTERN"* ]]; then
echo "WARN: 文件 $FILE_PATH 匹配敏感模式 [$PATTERN]。原因: $REASON。建议确认修改必要性。"
echo "[$(date -Iseconds)] WARN: $FILE_PATH" >> .claude/hooks/hook-log.txt
# 阶段 2: exit 0 放行,但 Claude 会看到警告消息
exit 0
fi
done
exit 0
阶段 3:阻断模式(第 3 周起)
对确认的高风险模式启用阻断,中风险模式保持提醒。
#!/bin/bash
# .claude/hooks/guard-files.sh — 阶段 3: 高风险阻断,中风险提醒
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
# 高风险模式: 阻断
BLOCK_PATTERNS=(
".env:环境变量文件,密钥泄露风险"
".pem:SSL/TLS 证书文件"
".key:私钥文件"
"infra/prod/:生产基础设施配置"
"terraform/state:TF 状态文件"
)
for entry in "${BLOCK_PATTERNS[@]}"; do
PATTERN="${entry%%:*}"
REASON="${entry##*:}"
if [[ "$FILE_PATH" == *"$PATTERN"* ]]; then
echo "BLOCK: $FILE_PATH 匹配高风险模式 [$PATTERN]。原因: $REASON。如需修改请联系团队负责人。"
echo "[$(date -Iseconds)] BLOCK: $FILE_PATH [$PATTERN]" >> .claude/hooks/hook-log.txt
exit 2
fi
done
# 中风险模式: 提醒
WARN_PATTERNS=(
"migrations/:数据库迁移文件"
"docker-compose.prod.yml:生产 Docker 配置"
)
for entry in "${WARN_PATTERNS[@]}"; do
PATTERN="${entry%%:*}"
REASON="${entry##*:}"
if [[ "$FILE_PATH" == *"$PATTERN"* ]]; then
echo "WARN: $FILE_PATH 匹配中风险模式 [$PATTERN]。原因: $REASON。请确认修改必要性。"
echo "[$(date -Iseconds)] WARN: $FILE_PATH [$PATTERN]" >> .claude/hooks/hook-log.txt
exit 0
fi
done
exit 0
部署检查清单
上线前检查:
├─ [ ] Matcher 是否正确限定工具范围?
├─ [ ] 是否测试了应该阻断的输入?
├─ [ ] 是否测试了不应该阻断的输入?
├─ [ ] 脚本出错时是否 exit 0(放行)?
├─ [ ] 阻断消息是否包含原因和替代方案?
├─ [ ] 是否有日志记录(即使阻断模式也保留日志)?
└─ [ ] 是否有快速禁用的方法(移除配置或注释脚本)?
误判诊断工作流
当 PreToolUse Hook 阻断了不应该阻断的操作时,按以下步骤系统化诊断。
步骤 1:确认阻断来源
检查 Hook 的 stdout 输出。阻断消息中包含匹配的模式和原因。
BLOCK: 文件 src/config/production.ts 匹配受限模式 [infra/prod/]。
原因: 生产基础设施配置。如需修改请联系团队。
如果没有看到阻断消息,检查是否有多个 Hook 配置——可能是另一个 Matcher 的 Hook 触发了阻断。
步骤 2:分析匹配逻辑
定位匹配命中。上面这个例子中,src/config/production.ts 并不在 infra/prod/ 目录下,但仍然被拦截——说明匹配逻辑有问题。
如果 Hook 脚本中的匹配逻辑是:
if [[ "$FILE_PATH" == *"prod"* ]]; then ...
那么任何路径中包含 "prod" 子串的文件都会被拦截——包括 production.ts、product-service.ts、src/reproduce-bug.ts。这是典型的过度宽泛匹配。
步骤 3:复现并确认
用 echo 模拟 stdin 输入,直接测试 Hook 脚本:
# 构造模拟输入
echo '{"tool_name":"Edit","tool_input":{"file_path":"/src/config/production.ts"}}' \
| bash .claude/hooks/block-sensitive-files.sh
# 检查退出码
echo "exit code: $?"
如果退出码是 2,确认了匹配命中。如果退出码是 0,问题可能不在脚本本身——检查 Matcher 配置是否正确。
步骤 4:修复匹配规则
将子串匹配改为精确路径匹配:
# 错误:匹配任何包含 "prod" 的路径
if [[ "$FILE_PATH" == *"prod"* ]]; then ...
# 正确:只匹配 infra/prod/ 目录下的文件
if [[ "$FILE_PATH" == *"infra/prod/"* ]]; then ...
步骤 5:增加白名单
如果某些路径确实包含敏感关键词但不应被拦截(如测试 fixture),添加白名单:
# 白名单:这些路径虽然包含敏感模式但允许修改
ALLOWED_PATHS=(
"test/fixtures/"
"src/mocks/"
"docs/examples/"
)
is_allowed() {
local path="$1"
for allowed in "${ALLOWED_PATHS[@]}"; do
if [[ "$path" == *"$allowed"* ]]; then
return 0
fi
done
return 1
}
# 在阻断逻辑前检查白名单
if is_allowed "$FILE_PATH"; then
exit 0
fi
步骤 6:回归测试
修复后,用两组输入验证:
# 应该被阻断
echo '{"tool_name":"Write","tool_input":{"file_path":"/infra/prod/config.yml"}}' \
| bash .claude/hooks/block-sensitive-files.sh
# 期望: exit 2
# 不应该被阻断
echo '{"tool_name":"Edit","tool_input":{"file_path":"/src/config/production.ts"}}' \
| bash .claude/hooks/block-sensitive-files.sh
# 期望: exit 0
echo '{"tool_name":"Edit","tool_input":{"file_path":"/src/services/product-service.ts"}}' \
| bash .claude/hooks/block-sensitive-files.sh
# 期望: exit 0
生产环境反模式
以下是从实际项目中观察到的 PreToolUse Hook 失败模式。
反模式 1:过度宽泛的正则阻断了测试 fixture
经过。团队配置了一个 Hook,阻止对任何"生产相关"文件的修改。正则表达式设计为匹配所有包含 prod 关键词的路径:
#!/bin/bash
# .claude/hooks/block-prod.sh (有问题的版本)
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE_PATH" =~ prod ]]; then
echo "BLOCK: 生产配置文件,禁止修改"
exit 2
fi
exit 0
上线后,以下文件全部被拦截:
src/config/production.ts— 合法的生产配置模块test/fixtures/production-data.json— 测试 fixturesrc/services/product-service.ts— 产品服务,和生产环境无关docs/production-deployment.md— 部署文档src/reproduce-production-bug.ts— bug 复现脚本
Claude Code 几乎无法正常编辑项目。每次涉及这些文件的修改都被拦截,Claude 不得不绕过这些文件或请求人工操作。
根因。正则 =~ prod 匹配了任何路径中包含 "prod" 子串的文件。这不是"生产配置保护",这是"任何文件名包含 prod 四个字母的保护"。
修复。改为精确路径模式列表:
#!/bin/bash
# .claude/hooks/block-prod.sh (修复版本)
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROD_CONFIG_PATTERNS=(
"infra/prod/"
"config/production.yml"
"config/production.yaml"
".env.production"
"docker-compose.prod.yml"
)
for pattern in "${PROD_CONFIG_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "BLOCK: 文件匹配生产配置模式 [$pattern]。生产配置变更需通过 PR review。"
exit 2
fi
done
exit 0
反模式 2:脚本依赖外部服务导致超时
经过。团队写了一个 Hook,在每次文件写入前调用内部 API 检查文件是否受保护:
#!/bin/bash
# .claude/hooks/block-via-api.sh (有问题的版本)
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# 调用内部 API 检查文件保护状态
RESPONSE=$(curl -s -m 5 "http://internal-api/protection?path=$FILE_PATH")
if [[ "$RESPONSE" == *"protected"* ]]; then
echo "BLOCK: 此文件受保护"
exit 2
fi
exit 0
上线后出现两个问题:
- API 服务偶尔响应慢(>5 秒),导致 Claude Code 长时间无响应。用户以为卡死,强制退出。
- VPN 断开后 API 不可达,curl 返回非零退出码,
set -e导致脚本直接退出,退出码非 0 也非 2。Claude Code 将其视为 Hook 执行错误,跳过 Hook 执行了操作——保护完全失效。
根因。Hook 引入了网络依赖,违反了"确定性"原则。Hook 应该是本地、同步、无外部依赖的脚本。
修复。将保护规则硬编码在脚本中,不依赖外部服务:
#!/bin/bash
# .claude/hooks/block-no-api.sh (修复版本)
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
# 保护规则硬编码,不依赖外部服务
PROTECTED=(
"infra/prod/"
".env"
"config/production.yml"
)
for pattern in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "BLOCK: 文件匹配保护模式 [$pattern]"
exit 2
fi
done
exit 0
如果确实需要动态规则,用 cron 定时同步规则文件到本地,Hook 读取本地文件而非调用 API。
反模式 3:exit code 处理错误导致全量阻断
经过。团队写了一个 Hook,但遗漏了 set -e 和错误处理:
#!/bin/bash
# .claude/hooks/broken-exit.sh (有问题的版本)
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# jq 不存在或版本不兼容时,FILE_PATH 可能为空或出错
# 但脚本继续执行...
if [[ "$FILE_PATH" == *".env"* ]]; then
echo "BLOCK: .env 文件"
exit 2
fi
# 遗漏了显式 exit 0
# Shell 默认返回最后一条命令的退出码
这个脚本有两个隐患:
- 如果
jq未安装,FILE_PATH变量赋值失败但没有set -e,脚本继续执行。后续逻辑基于可能不正确的FILE_PATH值运行。 - 脚本末尾遗漏了
exit 0。Shell 的默认退出码是最后一条命令的退出码。如果最后一条命令返回非零,Claude Code 可能将其解读为阻断或错误。
修复。遵循三个硬性规则:
- 始终
set -euo pipefail。 - 始终在脚本末尾显式写
exit 0。 - 始终检查关键变量是否为空后再使用。
阻断后 Claude 的行为
当 PreToolUse 返回 exit 2 时,Claude Code 的行为是:
- 工具调用被取消,不执行
- Hook 的 stdout 消息作为系统消息注入到 Claude 的上下文中
- Claude 根据阻断消息调整后续行为
这意味着阻断消息的质量直接影响 Claude 的恢复能力。一个好的阻断消息应该:
- 说明原因:"文件匹配敏感模式 .env"
- 提供替代方案:"使用环境变量管理工具修改"
- 明确操作指引:"如需修改请联系团队负责人"
坏的阻断消息:"BLOCK"——没有原因,Claude 不知道该怎么调整。
JSON 输出格式(高级控制)
除了纯文本 stdout,PreToolUse Hook 还支持 JSON 格式输出,提供更精细的控制:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "此文件包含生产密钥,禁止修改"
}
}
三种 permissionDecision 值:
| 值 | 行为 | 消息可见性 |
|---|---|---|
allow |
绕过权限系统,自动批准 | 原因对用户可见,对 Claude 不可见 |
deny |
阻断工具调用 | 原因对 Claude 可见 |
ask |
弹出确认对话框 | 原因对用户可见,对 Claude 不可见 |
allow 的典型用途是自动批准低风险操作,减少权限弹窗。deny 等同于 exit 2 但可以携带结构化原因。ask 将决策权交给用户,适用于需要人工判断的灰色地带。
与其他 Hook 的配合
PreToolUse 通常不是独立使用的。一个完整的防护体系包含多层 Hook:
PreToolUse (阻断层)
├─ 检查工具输入是否安全
├─ 不安全 → exit 2 阻断
└─ 安全 → 放行
↓ 工具执行
PostToolUse (验证层)
├─ 记录这次工具调用的参数和结果
├─ 如果是文件修改 → 记录到变更日志
└─ 如果是测试运行 → 记录通过/失败
↓ 会话继续
Stop (总结层)
├─ 汇总本次会话的所有工具调用
├─ 生成验证报告
└─ 记录未完成的验证项
三层 Hook 的职责不重叠:PreToolUse 管"能不能做",PostToolUse 管"做了什么",Stop 管"做完了没有"。
交叉参考
- 22 Hooks 入门:Hook 系统的完整架构、事件列表和执行流程
- 24 PostToolUse / Stop 验证:工具执行后的自动验证和会话总结
- 26 Hook 设计原则:小、确定、可解释、可回滚的四条设计原则
- 17 MCP 心智模型:理解 MCP 工具的风险特征和权限模型
权衡
PreToolUse 的阻断能力是双刃剑。正确的阻断保护了系统安全,错误的阻断了正常的开发流程。
误拦截会破坏开发体验。先记录,再提醒,最后阻断,是更稳的落地顺序。不要在第一天就把所有规则设为阻断模式——先让规则在观察模式下运行足够长的时间,确认误判率可以接受后再升级。
评论
还没有评论
欢迎留下第一条评论,帮助这篇内容更快形成讨论。