上周同事问我:你让 Claude 跑命令不怕它 rm -rf?我愣了几秒。
不是没想过,是真被它坑过。我那会儿在 CLAUDE.md 里白纸黑字写着"禁止执行 rm -rf、禁止动 .git 目录",模型读得也挺欢,回我"好的,我会注意"。结果某次让它清理 /tmp 下的测试产物,它顺手把 rm -rf /tmp/* 扩成了 rm -rf /*,我这坑躺了三天才把项目从 git 历史里捞回来。
提示词拦不住,这事我搜了 2 小时 issue 才想明白为什么:提示词是自然语言约束,本质是"拜托模型别这么做"。模型理解了,但它在生成 token 那一刻并不保证遵守。更别说有些危险是它自己组合出来的------单看每条命令都人畜无害,串起来就是灾难。确定性的事,必须交给确定性机制。这就是 Hooks 要解决的问题。
等一下,这里我漏说一个前提------Hooks 不是模型能力,是 Harness 能力。它发生在工具真正执行之前/之后,由 Claude Code 的运行时触发,模型根本碰不到这一层。你在提示词里写一万遍"别 rm -rf",不如一个 PreToolUse Hook 管用。
事件:会话级和工具调用级两大家族
Hooks 挂的事件分两类。会话级事件管生命周期:SessionStart(会话开始,可以注入环境变量)、SessionEnd、Stop(Claude 准备停下来时触发,适合做质量门控)。工具调用级事件管每一次工具执行:PreToolUse 在工具执行前拦截(能 allow/deny)、PostToolUse 在执行后回调(适合审计、格式化)、还有 SubagentStart/SubagentStop 管子智能体。
我这次救场用的就是 PreToolUse------在 Bash 命令真正敲进 shell 之前,先过一遍我的脚本。
配置:六个位置,六种用途
Hooks 配置分散在六个层级:企业级策略 → 用户级 ~/.claude/settings.json → 项目级 .claude/settings.json → 本地级 .claude/settings.local.json → Skill 的 frontmatter → 命令行参数。越具体优先级越高。我这次把危险命令拦截放在项目级,团队共享;把本地敏感路径放在 local,不上库。
三种处理器:确定性优先
处理器类型有三档,按"确定性 vs 灵活性"权衡:
- command:跑个 shell 脚本,规则硬编码,最快最确定。拦 rm -rf 这种就用它。
- prompt:单次 LLM 评估,传一段 prompt 让小模型判断。灵活,但有延迟、有不确定性。适合"这段代码有没有安全漏洞"这种模糊判断。
- agent:起一个子智能体多轮验证,最强但最慢。适合跑测试套件、验证复杂输出。
我的原则是:能用 command 解决的绝不请 LLM。rm -rf 这种字符串匹配就能搞定的,没必要请模型,请了反而引入不确定性------你拦危险命令总不希望拦截器自己抽风吧。
hookSpecificOutput:和 Claude 说话的协议
Hook 通过 stdout 输出 JSON 跟 Claude Code 通信,核心是这个结构:
json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "拦截危险命令模式: rm -rf /"
}
}
permissionDecision 取 allow 或 deny,deny 时 permissionDecisionReason 会喂回给 Claude,让它知道为什么被拦。还有一个 additionalContext 字段,可以塞额外上下文。关键:exit code 2 表示阻断,0 表示放行。
救场脚本:PreToolUse 危险命令拦截
这是我那次的完整脚本,放在 .claude/hooks/block-dangerous.sh:
bash
#!/bin/bash
# .claude/hooks/block-dangerous.sh
set -e
INPUT=$(cat)
# 提取命令(调试信息输出到 stderr)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
echo "DEBUG: Checking command: $COMMAND" >&2
# 危险命令模式列表
DANGEROUS_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"rm -rf \$HOME"
"> /dev/sd"
"mkfs."
":(){:|:&};:" # Fork bomb
"chmod -R 777 /"
"git push --force origin main"
"git push --force origin master"
"git reset --hard origin"
"DROP DATABASE"
"DROP TABLE"
"TRUNCATE"
"curl.*| sh" # 危险的管道执行
"curl.*| bash"
)
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if [[ "$COMMAND" == *"$pattern"* ]]; then
echo "BLOCKED: $pattern" >&2
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "拦截危险命令模式: $pattern"
}
}
EOF
exit 2
fi
done
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
exit 0
几个踩坑点想说一下。第一,调试信息一定要输出到 stderr(>&2),别输出到 stdout,否则会被当成 hook 返回值解析,JSON 解析失败整条命令就静默放行了------这个坑我也躺过半天,明明拦了却没生效,排查半天才发现是 debug 日志污染了 stdout。第二,set -e 配合 exit 2 的语义:exit 2 是阻断,Claude Code 看到这个码就知道要拦。第三,fork bomb 那个 :(){:|:&};: 别在终端里手贱试,写进模式列表就行。
配置里挂上这个脚本:
json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/block-dangerous.sh" }
]
},
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/protect-files.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "./.claude/hooks/audit-log.sh" }
]
}
]
}
}
matcher 用 Bash 只匹配命令执行,Write|Edit 匹配文件写入,* 是通配。
PostToolUse:审计日志,事后留痕
拦是一回事,查是另一回事。PostToolUse 挂个审计日志,所有工具调用都记一笔:
bash
#!/bin/bash
# .claude/hooks/audit-log.sh
INPUT=$(cat)
LOG_DIR="${CLAUDE_PROJECT_DIR:-.}/.claude/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/audit-$(date +%Y-%m-%d).log"
TIMESTAMP=$(date -Iseconds)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
echo "[$TIMESTAMP] $TOOL_NAME: $TOOL_INPUT" >> "$LOG_FILE"
echo '{}'
exit 0
CLAUDE_PROJECT_DIR 是 Claude Code 注入的环境变量,指向项目根。日志按天切分,出事了一 grep 就能定位是哪条命令干的。我习惯把 .claude/logs/ 加进 .gitignore,不然每天 diff 里全是日志噪音。
Stop Hook:测试质量门控
这个我一开始没加,后来吃亏。Claude 说"我做完了"就停,结果测试是红的。Stop Hook 在 Claude 准备结束时触发,可以 block 回去让它继续修:
bash
#!/bin/bash
# .claude/hooks/run-tests.sh
INPUT=$(cat)
# 防止无限循环:检查 stop_hook_active
STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$STOP_ACTIVE" = "true" ]; then
exit 0 # 已经重试过一次,这次让 Claude 停下来
fi
if [ -n "$CLAUDE_PROJECT_DIR" ]; then
cd "$CLAUDE_PROJECT_DIR"
fi
TEST_PASSED=true
TEST_RESULT=""
if [ -f "package.json" ] && grep -q '"test"' package.json; then
TEST_RESULT=$(npm test 2>&1) || TEST_PASSED=false
elif [ -f "pyproject.toml" ] || [ -f "pytest.ini" ]; then
TEST_RESULT=$(pytest 2>&1) || TEST_PASSED=false
elif [ -f "go.mod" ]; then
TEST_RESULT=$(go test ./... 2>&1) || TEST_PASSED=false
else
echo '{"hookSpecificOutput":{"additionalContext":"未检测到测试框架"}}'
exit 0
fi
if [ "$TEST_PASSED" = true ]; then
echo '{"hookSpecificOutput":{"additionalContext":"所有测试通过"}}'
else
TEST_ESCAPED=$(echo "$TEST_RESULT" | head -50 | jq -Rs '.')
cat <<EOF
{
"decision": "block",
"reason": "测试失败,请修复后再停止",
"hookSpecificOutput": {
"additionalContext": $TEST_ESCAPED
}
}
EOF
fi
exit 0
这段有个必须强调的细节------stop_hook_active 检查。Stop Hook block 之后 Claude 会继续干,干完又触发 Stop,又 block,死循环。Claude Code 在第二次触发时会带 stop_hook_active: true,这时必须放行让它停。这个防死循环的逻辑漏了,我机器风扇转了二十分钟才发现,CPU 满载跑测试套件跑了一轮又一轮。
顺便一提,雷达鸭鸿蒙版上架那会儿,华为审核反馈隐私协议字段缺失,我后来给提审流程接了个 PreToolUse Hook 自动校验 manifest 里的权限声明和隐私政策一致性,省了来回被打回的工夫。Hook 这东西一旦用顺,会忍不住往各种流程上挂。
写到这里回头看,提示词和 Hooks 不是二选一,是分工。提示词管"该做什么"(意图层),Hooks 管"不能做什么"(执行层)。把确定性的红线交给确定性的机制,模型再聪明也越不过去------这才是 Harness 该干的事。
那么问题来了:当你的 Hook 脚本本身写错了,谁来拦 Hook?
个人介绍
雷达鸭 App 独立开发者,10+ 年软件开发,软件设计师、人工智能应用工程师,专注鸿蒙 ArkTS 与 Web 前端,正在探索 AI 自动化工程化。雷达鸭收录中国一人公司赚钱案例。
MIT 声明
本文代码示例基于《Claude Code 实战:Harness 工程之道》(黄佳著)第 5 章改写,遵循 MIT 协议,可自由使用、修改、分发。