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

文章来源声明: 原文作者:plainGeek; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686352376152850474; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把安全底线从“提示词祈祷”移到“环境强制”,是 Agent 工程化落地的关键认知。适合正在用 Claude Code 搭建团队级 Harness 的开发者,尤其是需要硬性合规与防误删场景。

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

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


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

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


一、一个真实的坑

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

在 CLAUDE.md 里写了:
  <span>"禁止删除项目目录外的任何文件。所有删除操作必须先确认。"</span>

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

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

<span>"下次会更小心"</span>——这就是问题所在。

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


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

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

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

这意味着:

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

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

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

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

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


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

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

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

最关键的是 PreToolUse:

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

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


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

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

不成熟的 Harness:靠模型"记住"纪律。
成熟的 Harness:让纪律成为环境的一部分。

什么意思?

不成熟的做法:
  CLAUDE.md 写:<span>"不要删除项目外的文件"</span>
  → 靠模型记住并遵守
  → 模型忘了 = 出事

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

再举几个例子:

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

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


五、实战:三个有用的 Hook

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

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

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

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

PROJECT_ROOT=<span>"/Users/file/Desktop/myproject"</span>

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

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

<span>exit</span> 0

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

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

<span>#!/bin/bash</span>
<span># .claude/hooks/pre-tool-use/bash-guard.sh</span>
<span># 禁止危险的 Shell 命令</span>

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

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

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

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

<span>exit</span> 0

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

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

<span>#!/bin/bash</span>
<span># .claude/hooks/post-tool-use/auto-lint.sh</span>

INPUT=$(<span>cat</span>)

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

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

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

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

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

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

<span>exit</span> 0

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


六、Hook 的设计原则

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

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

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

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


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

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

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

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

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


八、这一篇总结

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

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