前三篇讲了 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 检查路径 |
| 代码必须过 linter | CLAUDE.md 写"请跑 linter" | PostToolUse 自动跑 linter |
| 禁止提交到 main | CLAUDE.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 越用越强,以及一个完整的实战例子。
把安全底线从“提示词祈祷”移到“环境强制”,是 Agent 工程化落地的关键认知。适合正在用 Claude Code 搭建团队级 Harness 的开发者,尤其是需要硬性合规与防误删场景。