让 Claude 自己管自己:Hooks 拦截 rm -rf 的那一次救场

上周同事问我:你让 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(会话开始,可以注入环境变量)、SessionEndStop(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 /"
    }
}

permissionDecisionallowdeny,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" }
        ]
      }
    ]
  }
}

matcherBash 只匹配命令执行,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 协议,可自由使用、修改、分发。

相关推荐
土土哥tutuge1 小时前
开源项目 Claude Code Notifier Plus:让 Claude Code 真正需要你时再提醒你
ai编程·visual studio code
玉宇夕落1 小时前
skills简单学习
ai编程
唐老板1 小时前
AI 时代,Code Review 的重点彻底变了
ai编程
潘锦2 小时前
从碎片检索到深度推理:如何重塑 AI Agent 的知识引擎
agent
潘锦2 小时前
如何打造 AI-Native 软件产品研发组织
ai编程
xcLeigh2 小时前
从搜索引擎到 AI 编程:开发者获取知识方式的范式转移
人工智能·ai·架构·ai编程·开发·范式转移
GeekArch2 小时前
第8讲:裸机单片机项目——Vibe快速迭代的最优边界
c语言·stm32·单片机·嵌入式硬件·mcu·物联网·ai编程
lxyker2 小时前
AI常用概念名词
llm·ai编程
Am-Chestnuts2 小时前
DeepSeek、豆包与Kimi多轮回答的批量导出和横向对比方法
aigc