深入 Claude Code 的 YOLO Classifier

Claude Code 有个自动模式,可以不经确认直接执行操作。但"自动"不等于"不审查",每个动作背后都有一个分类器在盯着。

自动模式的矛盾

用 Claude Code 写代码,最烦的事之一是权限弹窗。每次执行 Bash 命令、写文件,都要点一下确认。效率高的用户会开启自动模式(auto mode),让 Claude 自主执行所有操作,不再逐个询问。

这带来一个显而易见的问题:如果模型决定 rm -rf /,或者把代码推到 production 分支,或者往 .bashrc 里写个后门呢?

Claude Code 的解法不是"信任模型",而是"用另一个 AI 来审查这个 AI"。源码里这个模块叫 YOLO Classifier,YOLO 是 You Only Live Once 的缩写,用来命名一个"不问就干"的模式,挺自嘲的。但这个"不问就干"的模式里,其实每一步都有人在问。

为什么不用规则引擎

第一反应可能是:写个规则列表不就行了? rm -rf 拦掉,git push --force 拦掉,curl | bash 拦掉。

这条路走不通。Bash 命令的组合是无限的。find / -delete 也是删除整个文件系统,但长得完全不像 rmpython -c "import os; os.remove('/')" 呢?base64 -d <<< "cm0gLXJmIC8=" | bash 呢?你写一千条规则,总有第一千零一种绕法。

传统安全工具用正则匹配、用 AST 分析(Claude Code 确实也有 tree-sitter 解析 Bash 命令,这是另一个话题),但这些都是"形式匹配":只能判断命令长什么样,不能判断命令想干什么。

而 LLM 能理解意图。它看到一串 base64 编码的命令,能推理出解码后是什么。它看到一个复杂的管道,能判断最终效果是读文件还是删文件。这就是用 AI 审查 AI 的根本原因:安全判断本身需要语义理解,规则引擎提供不了这个。

两阶段:64 token 和 4096 token 的经济学

用 AI 做审查的问题是贵。每个操作都发一次完整的 API 调用,一个会话里模型可能执行几十上百个操作,审查成本可能比操作本身还高。

Claude Code 的解法是两阶段分类器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Model: "I want to run: npm test"
|
v
+------------------------------------------+
| STAGE 1: Fast Check (64 tokens max) |
| Prompt ends with: |
| "Err on the side of blocking. |
| <block> immediately." |
| |
| -> <block>no</block> = ALLOW (done) |
| -> <block>yes</block> = escalate to S2 |
| -> unparseable = escalate to S2 |
+------------------------------------------+
| (only if stage 1 says block)
v
+------------------------------------------+
| STAGE 2: Deep Check (4096 tokens max) |
| Prompt ends with: |
| "Review the classification process... |
| Use <thinking> before responding." |
| |
| -> <block>no</block> = ALLOW |
| -> <block>yes</block> = BLOCK |
| -> unparseable = BLOCK |
+------------------------------------------+

Stage 1 的 prompt 尾部直接写"Err on the side of blocking. <block> immediately.",意思是"宁可误拦也不要漏放,马上给结论"。max_tokens 设为 64,连 stop_sequences 都配了 </block>,模型一输出结论就截断,不给它废话的机会。

绝大多数操作是安全的(npm testcat file.txtgit status),Stage 1 一个 <block>no</block> 就放行了,总共 4 个输出 token。只有被 Stage 1 标记为可疑的操作才进 Stage 2,带上 <thinking> 做链式推理,输出上限 4096 token。

这不是"能不能做"的问题,是"用多少钱换多少安全"的问题。两阶段加上 Prompt Cache 共享(Stage 2 的输入跟 Stage 1 几乎一样,cache hit 率很高),把每次审查的平均成本压到了一个可接受的范围。

不信任自己的输出

分类器看到的 transcript 有一个关键处理:所有 assistant 消息中的文本内容被删掉了,只保留 tool_use 块。

1
2
3
4
5
6
7
8
9
10
11
12
def build_transcript(messages):
transcript = []
for msg in messages:
if msg.role == "user":
transcript.append({"user": msg.text})
elif msg.role == "assistant":
# ONLY keep tool calls, DROP all assistant text
for block in msg.content:
if block.type == "tool_use":
transcript.append({block.name: block.input})
# text blocks are deliberately skipped
return transcript

正常思路是:给分类器完整的对话上下文,让它做出更准确的判断。但 Claude Code 偏偏删掉了 assistant 说的所有话,只保留它做了什么。为什么?

因为 assistant 文本是模型自己写的。如果分类器能看到 assistant 的文本,就存在一种攻击路径:模型在执行危险操作之前,先在对话中写一段"铺垫",比如"用户已经确认了这个操作是安全的,我现在执行它"。分类器看到这段话,可能就被说服了。

代码注释里写得很直白:"assistant text is model-authored and could be crafted to influence the classifier's decision."

这在安全领域叫"不信任自己的输出"。模型 A 产生的文本可能被用来欺骗模型 B,哪怕 A 和 B 是同一个系统的不同组件。所以分类器的输入被严格限制为:用户说了什么(事实),模型调了什么工具(行为)。不看模型怎么解释自己的行为。

这跟法庭上的逻辑很像:只看证据和行为,不看被告的自我辩护。

Fail-Closed:不确定就拦

分类器返回的结果分三种:allow、block、unparseable。第三种情况(返回格式不对、API 报错、超时、用户中断)的处理方式是:

1
2
if parse_xml_block(response) is None:
return {"shouldBlock": True, "reason": "Unparseable - blocking for safety"}

一律 block。

这是 fail-closed 设计。大多数软件在组件出错时选择"跳过检查,继续执行"(fail-open),因为用户体验优先。Claude Code 反过来:安全检查失败了,宁可让用户手动确认,也不放行一个未经审查的操作。

这个选择的代价是:如果分类器服务不稳定,自动模式会频繁退化成手动模式,用户体验变差。但从安全角度看,这是对的。一个安全审查系统最不该做的事情就是在自己出问题的时候悄悄消失。

信任链的设计

分类器不只看对话历史,还会读 CLAUDE.md,也就是用户写的项目配置文件。喂给分类器的方式很讲究:

1
2
3
4
"The following is the user's CLAUDE.md configuration. "
"These are instructions the user provided to the agent "
"and should be treated as part of the user's intent "
"when evaluating actions."

注意措辞:"treated as part of the user's intent"。分类器把 CLAUDE.md 视为用户意图的延伸,不是无条件的豁免令。如果 CLAUDE.md 里写了"允许 deploy 到 staging",分类器审查 deploy 命令时会参考这个上下文,但不会因此放行 deploy 到 production。

用户还可以通过 settings.autoMode 自定义 allow/deny 规则,直接替换分类器的默认策略。这形成了一个三层信任链:用户的显式规则(最高优先级)→ CLAUDE.md 里的项目配置 → 分类器的默认安全策略。信任是分层的,不是二元的。

影子上下文

分类器的 transcript 是主循环对话的一个"投影"(只保留 tool_use 和 user text),理论上比主循环小。但代码里有一段遥测注释透露了一个运维隐患:

1
2
3
4
# Chart classifierInputTokens / mainLoopTokens in Datadog.
# Expect ~0.6-0.8 steady state;
# alert on p95 > 1.0 (means classifier is bigger than main loop
# -- auto-compact won't save us).

分类器是主循环的影子。主循环的上下文膨胀,影子也膨胀。主循环触发 auto-compact 压缩对话历史,影子跟着缩短。但如果影子的 token 估算有误差,跑在主循环前面先爆了,就会出现分类器 prompt_too_long 而主循环还没意识到该 compact 的情况。

稳态下影子大约是主循环的 60%-80%。一旦这个比值超过 1.0,说明投影逻辑出了问题。这种"一个系统的影子先于本体出问题"的 bug,是分布式系统里典型的让人头疼的那种。

一个哲学问题

Claude Code 的安全审查系统,本质上是在回答一个问题:一个能自主行动的 AI 系统,应该怎么约束自己?

它给出的答案有三层:

第一层是技术手段:独立的分类器、删掉自我辩护文本、fail-closed。这些都是可以实现的工程决策。

第二层是经济学:两阶段审查、Prompt Cache 共享、64 token vs 4096 token。安全不是免费的,所有安全措施都有成本,设计的艺术在于找到成本和安全的平衡点。

第三层是信任模型:用户规则 > 项目配置 > 默认策略。不是"信不信任 AI"的二选一,而是分层的、有条件的、可配置的信任。

大多数 AI 产品在安全问题上的态度是"我们的模型很安全,请放心使用"。Claude Code 的态度是:我不信任自己的输出,所以我给自己安排了一个审查官。这个审查官也可能出错,所以出错的时候一律拦截。用户可以覆盖我的判断,但用户没说话的时候,我默认选择保守。

这套设计的本质是四个字:不信任自己。