Harness 从"建议"到"强制":CLAUDE.md 为什么不够,Hooks 才是底线

从"建议"到"强制":CLAUDE.md 为什么不够,Hooks 才是底线

系列第 4 篇 · 前置:第 1 篇第 2 篇第 3 篇


前三篇讲了 Harness 是什么、Guides vs Sensors、七层解剖。这一篇专门讲第六层 Hooks------因为它是整个 Harness 里唯一能保证确定性执行的地方,也是大多数人最容易忽视的一层。

如果你只记住 Harness 系列的一个知识点,记住这个:CLAUDE.md 是建议,Hook 是强制。


一、一个真实的坑

第一次认真对待 Hooks,是因为踩了这个坑:

bash 复制代码
在 CLAUDE.md 里写了:
  "禁止删除项目目录外的任何文件。所有删除操作必须先确认。"

然后让 AI 重构一个模块。
AI 为了"清理冗余文件",执行了 rm -rf /tmp/build-cache/*
→ /tmp 不在项目目录里,但它觉得那是"构建缓存,删了没事"
→ 我没在旁边盯着,等发现的时候已经删完了

问它:"CLAUDE.md 里不是写了禁止删项目外的文件吗?"
它说:"抱歉,我在重构过程中注意力集中在代码结构上,
      没有注意到这条规则。下次我会更小心。"

"下次会更小心"------这就是问题所在。

你不能把安全规则寄托在"模型下次会更小心"上。 它这次忘了,下次还会忘。上下文越长、任务越复杂,忘的概率越高。


二、CLAUDE.md 的本质:Context,不是 Configuration

Anthropic 官方文档里写得很清楚:

CLAUDE.md 是作为上下文(context)加载的,不是作为配置(configuration)强制执行的。

这意味着:

objectivec 复制代码
CLAUDE.md 里的规则:
  → 模型读了
  → 通常会遵守
  → 但永远不保证遵守
  → 上下文窗口塞满时,routinely 忽略规则

这不是 bug,是 LLM 的本质。模型是概率性的,它"倾向于"遵守上下文里的规则,但没有任何机制保证它一定遵守。

objectivec 复制代码
你写了 100 条规则在 CLAUDE.md 里:
  第 1-10 条:模型大概率遵守
  第 11-50 条:模型可能遵守,可能忽略
  第 51-100 条:模型大概率忽略(上下文注意力衰减)

而且你不知道它忽略了哪条。

所以:任何合规、安全、硬性规则,不能只靠 CLAUDE.md 你需要一个模型无法绕过的机制------Hook。


三、Hook 是什么:确定性执行的脚本

Hook 是在 Agent 生命周期的特定节点,由 Harness(不是模型) 确定性执行的脚本。

vbnet 复制代码
常见 Hook 节点:
  PreToolUse    → 工具调用前执行(最常用、最强大)
  PostToolUse   → 工具调用后执行
  UserPromptSubmit → 用户提交消息后执行
  Stop          → Agent 停止时执行
  SubagentStart → 子 Agent 启动时
  SubagentStop  → 子 Agent 停止时

最关键的是 PreToolUse:

javascript 复制代码
AI 想执行一个工具调用(比如 rm -rf /tmp/xxx)
  ↓
Harness 拦截这个调用,把事件 JSON 通过 stdin 传给 Hook 脚本
  ↓
Hook 脚本(Shell,确定性)读取 JSON,做检查
  ↓
没命中规则 → 不输出任何内容,正常退出 → 允许执行
命中规则   → 向 stdout 输出一段 JSON 决策
             {"permissionDecision": "deny", "permissionDecisionReason": "..."}
             → Harness 阻止执行,并把原因返回给 AI

模型无法幻觉绕过 Hook。 因为 Hook 是 Harness 层面的代码,在模型的控制之外。模型可以"想"执行 rm,但 Harness 在执行前会跑你的脚本,脚本说不行就是不行。


四、Compact Test:判断你的 Harness 成不成熟

Harness 领域有一个判断标准,叫 Compact Test

不成熟的 Harness:靠模型"记住"纪律。

成熟的 Harness:让纪律成为环境的一部分。

什么意思?

bash 复制代码
不成熟的做法:
  CLAUDE.md 写:"不要删除项目外的文件"
  → 靠模型记住并遵守
  → 模型忘了 = 出事

成熟的做法:
  PreToolUse Hook 写:
    if 命令包含 rm 且 路径不在项目目录内 → 拒绝
  → 不管模型记不记得,环境强制拦截
  → 模型永远删不了

再举几个例子:

规则 不成熟(靠模型记) 成熟(靠环境强制)
禁止删项目外文件 CLAUDE.md 写禁令 PreToolUse 检查路径
代码必须过 linter CLAUDE.md 写"请跑 linter" PostToolUse 自动跑 linter
禁止提交到 main CLAUDE.md 写"不要 push 到 main" PreToolUse 检查 git 目标分支
敏感信息不能外传 CLAUDE.md 写"注意保密" PreToolUse 扫描输出内容

判断标准很简单:如果模型忘了这条规则,会不会出事?会 → 用 Hook 强制。不会 → 写 CLAUDE.md 就行。


五、实战:三个有用的 Hook

先用 .claude/settings.json 把脚本注册到对应事件上(这一步别漏,脚本不会自己生效):

json 复制代码
// 项目根目录 .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write|Edit|Delete",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/pre-tool-use/use-files.sh" },
          { "type": "command", "command": ".claude/hooks/pre-tool-use/bash-guard.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/post-tool-use/auto-lint.sh" }
        ]
      }
    ]
  }
}

Hook 1:路径白名单(禁止操作项目外文件)

bash 复制代码
#!/bin/bash
# .claude/hooks/pre-tool-use/use-files.sh
# 只允许操作项目目录内的文件
# 事件 JSON 从 stdin 读入;拒绝时向 stdout 输出 JSON 决策

PROJECT_ROOT="/Users/file/Desktop/myproject"

INPUT=$(cat)                                   # 读取事件 JSON(stdin)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# 检查文件操作类工具
if [[ "$TOOL_NAME" == "Delete" || "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then
    if [[ -n "$FILE_PATH" && "$FILE_PATH" != "$PROJECT_ROOT"/* ]]; then
        # 拒绝:输出 PreToolUse 决策 JSON
        jq -n --arg p "$FILE_PATH" --arg root "$PROJECT_ROOT" '{
            hookSpecificOutput: {
                hookEventName: "PreToolUse",
                permissionDecision: "deny",
                permissionDecisionReason: ("拒绝:文件路径 " + $p + " 不在项目目录 " + $root + " 内")
            }
        }'
        exit 0
    fi
fi

exit 0

效果: 不管模型怎么想,项目外的文件它删不了、改不了。

Hook 2:命令黑名单(禁止危险 Shell 命令)

bash 复制代码
#!/bin/bash
# .claude/hooks/pre-tool-use/bash-guard.sh
# 禁止危险的 Shell 命令

INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')

if [[ "$TOOL_NAME" == "Bash" ]]; then
    COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

    # 危险模式
    if echo "$COMMAND" | grep -qE 'rm -rf /|sudo |chmod 777 /|dd if='; then
        jq -n --arg c "$COMMAND" '{
            hookSpecificOutput: {
                hookEventName: "PreToolUse",
                permissionDecision: "deny",
                permissionDecisionReason: ("拒绝:检测到危险命令 " + $c)
            }
        }'
        exit 0
    fi

    # rm 必须指定具体路径,不允许通配符删除
    if echo "$COMMAND" | grep -qE 'rm .**'; then
        jq -n '{
            hookSpecificOutput: {
                hookEventName: "PreToolUse",
                permissionDecision: "deny",
                permissionDecisionReason: "拒绝:不允许使用通配符删除,请指定具体文件"
            }
        }'
        exit 0
    fi
fi

exit 0

效果: 模型想执行 rm -rf /sudo rm -rf /,直接被拦。

Hook 3:自动 linter(写完代码自动检查)

bash 复制代码
#!/bin/bash
# .claude/hooks/post-tool-use/auto-lint.sh

INPUT=$(cat)

# 检查 jq
if ! command -v jq >/dev/null 2>&1; then
    echo "jq not found, skip auto-lint" >&2
    exit 0
fi

TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')

if [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "MultiEdit" ]]; then
    FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')

    if [[ "$FILE_PATH" == *.kt ]]; then
        # 检查 ktlint
        if ! command -v ktlint >/dev/null 2>&1; then
            jq -n '{
                hookSpecificOutput: {
                    hookEventName: "PostToolUse",
                    additionalContext: "ktlint 未安装,跳过检查"
                }
            }'
            exit 0
        fi

        # 跑 ktlint,不因非零退出码中断
        RESULT=$(NO_COLOR=1 ktlint "$FILE_PATH" 2>&1 || true)

        if [[ -n "$RESULT" ]]; then
            jq -n --arg msg "$RESULT" '{
                hookSpecificOutput: {
                    hookEventName: "PostToolUse",
                    additionalContext: ("ktlint 检查结果:" + $msg)
                }
            }'
        fi
    fi
fi

exit 0

效果: 模型改完 Kotlin 文件,Harness 自动跑 ktlint,有问题直接反馈。这是 Computational Sensor------确定性、便宜、每次都跑。


六、Hook 的设计原则

objectivec 复制代码
原则 1:Hook 要轻量
  → PreToolUse 在每次工具调用前跑,太慢会拖死整个 Agent
  → 简单检查(路径、命令黑名单)毫秒级完成
  → 复杂逻辑不要放 Hook 里,放 Agent 或独立服务里

原则 2:拒绝时要给明确原因
  → 不要只返回"拒绝"
  → 返回:"禁止删除项目外文件,你尝试删除的路径是 /tmp/xxx"
  → AI 知道为什么被拒,才能修正行为

原则 3:Hook 是最后一道防线,不是唯一防线
  → CLAUDE.md 仍然要写规则(Guides)
  → Hook 是强制兜底(Sensors + Guard)
  → 两者配合:Guides 告诉它"别这么做",Hook 确保"做不了"

原则 4:不要过度拦截
  → 每个命令都弹确认,Agent 根本跑不动
  → 只拦截真正危险的操作(删除、对外发送、权限变更)
  → 普通操作不要拦

七、一个常见误区:Hook 能解决一切问题

不能。Hook 能解决的是确定性规则 的强制执行,它解决不了判断性问题

bash 复制代码
Hook 能解决:
  → 不能删项目外的文件(路径检查,确定性)
  → 不能执行 rm -rf /(命令匹配,确定性)
  → 改完代码必须跑 linter(自动触发,确定性)

Hook 解决不了:
  → 这段代码架构合不合理(需要判断)
  → 这个函数命名好不好(需要判断)
  → 这个 PR 能不能合并(需要综合判断)
→ 判断性问题靠 Inferential Sensor(独立评审 Agent、LLM-as-judge)

Hook 管"能不能做",评审管"做得好不好"。 两者是互补的,不是替代的。


八、这一篇总结

markdown 复制代码
1. CLAUDE.md 是 context 不是 configuration------模型可以忽略
2. 上下文越长,模型越容易忽略规则,这是 LLM 本质
3. Hook 是 Harness 层面的确定性脚本,模型无法幻觉绕过
4. PreToolUse 是最强大的 Hook:工具调用前拦截,输出 deny 决策 JSON 即阻止
5. Compact Test:不成熟靠模型记纪律,成熟让纪律成为环境的一部分
6. 安全/合规/硬规则必须用 Hook,不能只靠 CLAUDE.md
7. Hook 要轻量、拒绝给原因、不过度拦截
8. Hook 管"能不能做"(确定性),评审管"做得好不好"(判断性)

下篇讲 Harness 的棘轮原理(Ratchet Principle)------怎么让你的 Harness 越用越强,以及一个完整的实战例子。

相关推荐
海边捡石子2 小时前
用 Vue 3 + FastAPI 跑通 LangChain DeepAgent:任务规划、双轨 Skills,以及可复现的本地 Agent 工作台
langchain·aigc·agent
名不经传的养虾人2 小时前
从0到1:企业级AI项目迭代日记 Vol.110|协作有了预算,延迟有了归因,知识有了保真
大数据·人工智能·机器人·ai编程·企业ai
AIGCmagic社区2 小时前
灵巧手VLA真机均分71%,北大DeCAL用接触门控接入触觉
人工智能·算法·aigc·ai多模态
FEF前端团队2 小时前
03# Claude Code实战:终端里的逻辑引擎
前端·ai编程·claude
FEF前端团队3 小时前
04# Codex CLI实战:OpenAI的编程代理
前端·chatgpt·ai编程
颜进强3 小时前
从零跑通一套 WorkBuddy Skill 骨架:【能跑通+代码】生成HTML报告实战
前端·后端·ai编程
晨航3 小时前
首尾帧、参考图、参考视频:想控制 AI 视频,到底该给它什么素材?
人工智能·aigc·音视频
OpsEye3 小时前
从代理转发到效能分析,企业 AI 网关正在发生什么样的变化
javascript·ai编程