接上回:40 个工具的统一抽象。
Claude Code 有一个 Auto Mode——让 AI 自动判断每个操作是否安全,无需人工逐一确认。
这个功能的安全分类器,叫 yoloClassifier。
名字很 meme。实现极其认真。
问题:权限确认的摩擦
正常使用 Claude Code 时,每个有风险的操作都需要用户确认。读文件没问题,但写文件、执行命令、git push——都需要点"允许"。
这是正确的安全设计。但当你给 Claude Code 一个复杂任务——比如"重构这个模块的测试框架"——它可能需要做几十次文件读写和命令执行。每次都确认,体验很差。
Auto Mode 的目标:自动放行安全操作,只拦截真正危险的操作。
核心挑战:怎么判断一个操作是否安全?
规则系统不够——rm 在大多数时候是危险的,但 rm build/ 在构建目录清理的上下文中完全合理。上下文决定一切。
答案:用另一个 LLM 来判断。
架构:两阶段分类
yoloClassifier 的核心是一个两阶段 XML 分类器:
Agent 要执行操作
│
▼
┌─── Stage 1: Fast ───┐
│ max_tokens = 64 │
│ stop_sequences │
│ "立即判断" │
│ │
│ <block>no</block> │──→ ✅ 放行(快速路径)
│ <block>yes</block> │──→ 进入 Stage 2
│ 解析失败 │──→ 进入 Stage 2
└──────────────────────┘
│
▼
┌─── Stage 2: Thinking ──┐
│ max_tokens = 4096 │
│ chain-of-thought │
│ "仔细分析后判断" │
│ │
│ <block>no</block> │──→ ✅ 放行(复核通过)
│ <block>yes</block> │──→ ❌ 拦截
│ 解析失败 │──→ ❌ 拦截(安全默认)
└─────────────────────────┘
为什么两阶段?
Stage 1 解决速度问题。 大多数操作是安全的——读文件、跑测试、搜索代码。Stage 1 只用 64 个 token 就能判断 <block>no</block>,延迟极低。
Stage 2 解决误报问题。 Stage 1 的指令是"err on the side of blocking"(宁可错杀不可放过)。但这会拦截很多合理操作。Stage 2 用 4096 token 的 chain-of-thought 复核被拦截的操作,减少误报。
关键洞察:Stage 1 允许的操作不需要 Stage 2。 只有被 Stage 1 拦截的操作才升级到 Stage 2。这意味着大多数请求只需要一次快速的 API 调用。
Prompt Cache 共享
两个阶段共享相同的系统提示词和对话 transcript——Stage 2 的 API 调用直接命中 Stage 1 建立的 prompt cache。这不是巧合,是刻意设计的:
// Place cache_control on the action block. In the two-stage classifier,
// stage 2 shares the same transcript+action prefix as stage 1 — the
// breakpoint here gives stage 2 a guaranteed cache hit on the full prefix.
userContentBlocks.push({
type: 'text' as const,
text: actionCompact,
cache_control: cacheControl,
})
Stage 2 的成本几乎只有输出 token(chain-of-thought 的推理过程)——输入全部走缓存。
Stage 1:64 Token 的快速裁决
Stage 1 的提示后缀很直接:
const XML_S1_SUFFIX = '\nErr on the side of blocking. <block> immediately.'
"宁可错杀,立即判断。"
配置上:
max_tokens: 64—— 只够输出<block>yes</block>或<block>no</block>stop_sequences: ['</block>']—— 一旦输出了判断就停止,不浪费 tokentemperature: 0—— 确定性输出,相同输入相同结果
如果 Stage 1 判断为 no(允许),直接放行:
if (stage1Block === false) {
return {
shouldBlock: false,
reason: 'Allowed by fast classifier',
stage: 'fast',
}
}
如果判断为 yes(拦截)或解析失败——升级到 Stage 2。
Stage 2:Chain-of-Thought 复核
Stage 2 的提示后缀要求仔细推理:
const XML_S2_SUFFIX =
'\nReview the classification process and follow it carefully, ' +
'making sure you deny actions that should be blocked. ' +
'As a reminder, explicit (not suggestive or implicit) user ' +
'confirmation is required to override blocks. ' +
'Use <thinking> before responding with <block>.'
关键措辞:"explicit (not suggestive or implicit) user confirmation is required to override blocks"——用户确认必须是明确的,不能是暗示性的。
比如用户说"帮我处理一下这些文件",这不算"明确确认删除"。用户说"是的,删掉 build 目录",这才算。
Stage 2 的输出格式:
<thinking>
用户要求清理构建产物。rm -rf build/ 是标准清理操作,
用户在 CLAUDE.md 中配置了允许 build 目录的清理操作。
这是可逆的(可以重新构建),blast radius 限于构建产物。
</thinking>
<block>no</block>
解析时,<thinking> 标签的内容会被提取但不影响判断——判断只看 <block> 标签。<thinking> 存在的意义是让模型"想清楚再回答"(chain-of-thought 提高准确率)和事后审计。
防注入:剥离 thinking 内容
一个精妙的安全细节:
function stripThinking(text: string): string {
return text
.replace(/<thinking>[\s\S]*?<\/thinking>/g, '')
.replace(/<thinking>[\s\S]*$/, '')
}
function parseXmlBlock(text: string): boolean | null {
const matches = [
...stripThinking(text).matchAll(/<block>(yes|no)\b/gi),
]
// ...
}
解析 <block> 之前先剥离 <thinking> 内容。为什么?因为模型在 thinking 过程中可能输出类似 <block>no</block> 的文本作为推理的一部分——如果不剥离,可能被错误匹配为最终判断。
Transcript:分类器看到的世界
分类器不是凭空判断——它看到的是整个对话的压缩 transcript。
构建 Transcript
function buildTranscriptEntries(messages: Message[]): TranscriptEntry[] {
// 只包含:
// 1. 用户的文本消息
// 2. Assistant 的 tool_use 块(不包括 assistant 的文本!)
}
注意:assistant 的文本输出不进 transcript。为什么?
Only include tool_use blocks — assistant text is model-authored and could be crafted to influence the classifier's decision.
Agent 的文本输出是模型生成的——如果把它放进分类器的输入,Agent 就可以在文本中"说服"分类器放行危险操作。只看 tool_use(客观操作记录)和 user text(用户意图),分类器才能做出独立判断。
每个工具自定义投影
Transcript 中的每个 tool_use 都经过 toAutoClassifierInput 投影:
function toCompactBlock(block, role, lookup): string {
if (block.type === 'tool_use') {
const tool = lookup.get(block.name)
const encoded = tool.toAutoClassifierInput(block.input)
if (encoded === '') return '' // 无安全相关性,跳过
return `${block.name} ${encoded}\n`
}
if (block.type === 'text' && role === 'user') {
return `User: ${block.text}\n`
}
}
返回空字符串意味着"这个工具调用没有安全相关性"——FileRead 总是返回 '',因为读文件不会造成任何破坏,分类器根本不需要看到它。
最终 transcript 看起来像这样:
User: 帮我重构 auth 模块的测试
Bash ls src/auth/
Bash cat src/auth/validate.ts
User: 把旧的测试删掉,重新写
Bash rm -rf src/auth/__tests__/
精简、紧凑、只包含安全相关信息。
CLAUDE.md:用户意图的信任锚点
分类器的输入中有一个特殊的前缀消息——CLAUDE.md 的内容:
function buildClaudeMdMessage(): Anthropic.MessageParam | null {
const claudeMd = getCachedClaudeMdContent()
if (claudeMd === null) return null
return {
role: 'user',
content: [{
type: 'text',
text:
`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.\n\n` +
`<user_claude_md>\n${claudeMd}\n</user_claude_md>`,
cache_control: getCacheControl({ querySource: 'auto_mode' }),
}],
}
}
这段话告诉分类器:CLAUDE.md 是用户配置——它代表用户意图。
如果 CLAUDE.md 里写了"允许删除 build 目录",那分类器在看到 rm -rf build/ 时就知道这是用户授权的操作。
CLAUDE.md 带有 cache_control,因为它在 session 内不变——所有分类器调用共享这个缓存。
用户可定制的规则
分类器的系统提示词不是铁板一块——用户可以通过 settings.autoMode 自定义三类规则:
type AutoModeRules = {
allow: string[] // 额外允许的操作
soft_deny: string[] // 额外拦截的操作
environment: string[] // 环境描述
}
模板中用 <user_*_to_replace> 标签标记可替换区域:
- 外部版:用户规则替换默认规则
- 内部版:用户规则追加到默认规则(内部安全策略不可被覆盖)
PowerShell 特殊规则
Windows 的 PowerShell 有自己的危险命令映射:
const POWERSHELL_DENY_GUIDANCE = [
// 下载执行:iex (iwr ...) → 等同于 curl | bash
'PowerShell Download-and-Execute: `iex (iwr ...)`',
// 不可逆删除:Remove-Item -Recurse -Force → 等同于 rm -rf
'PowerShell Irreversible Destruction: `Remove-Item -Recurse -Force`',
// 持久化:修改 $PROFILE → 等同于编辑 .bashrc
'PowerShell Persistence: modifying `$PROFILE`',
// 提权:Start-Process -Verb RunAs
'PowerShell Elevation: `Start-Process -Verb RunAs`',
]
每条规则都映射到已有的分类类别("Code from External"、"Irreversible Local Destruction" 等),让分类器不需要学新概念。
错误处理:安全默认
分类器可能失败——API 超时、返回格式异常、上下文超长。每种错误都有明确的处理:
| 错误 | 处理 | 原因 |
|---|---|---|
| Stage 1 解析失败 | 升级到 Stage 2 | 不确定就多想想 |
| Stage 2 解析失败 | 拦截 | 无法判断就拒绝 |
| API 错误 | 拦截 + unavailable: true | 分类器不可用 ≠ 操作安全 |
| 用户中断 | 拦截 | 中断意味着用户不想继续 |
| Prompt 过长 | 拦截 + transcriptTooLong: true | 超出分类器能力 |
核心原则:不确定时,拦截。 安全系统的默认行为永远是拒绝,不是允许。
// 分类器错误时的返回
return {
shouldBlock: true,
reason: 'Classifier unavailable - blocking for safety',
unavailable: true, // 标记为不可用,非主动拦截
}
unavailable: true 让调用方知道这是"分类器故障"而非"分类器判定危险"——UI 可以显示不同的提示信息。
模型选择:谁来当审查官
function getClassifierModel(): string {
// 优先级:
// 1. 环境变量覆盖(Ant only)
// 2. GrowthBook A/B 测试配置
// 3. 主循环使用的模型
const envModel = process.env.CLAUDE_CODE_AUTO_MODE_MODEL
if (envModel) return envModel
const config = getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_mode_config')
if (config?.model) return config.model
return getMainLoopModel()
}
默认情况下,分类器用跟主循环相同的模型。这有一个好处:系统提示词的 prompt cache 可以在主循环和分类器之间共享。
但 Anthropic 内部可以通过 GrowthBook 切换分类器模型——也许用一个更小更快的模型做分类就够了。
Thinking 模式的特殊处理
有些模型(标记为 alwaysOnThinking)会强制开启 thinking,不接受 thinking: disabled。对这些模型,分类器额外预留 2048 token 的 headroom:
function getClassifierThinkingConfig(model) {
if (resolveAntModel(model)?.alwaysOnThinking) {
return [undefined, 2048] // 不禁用 thinking,加 2048 headroom
}
return [false, 0] // 禁用 thinking,不加 headroom
}
注释里有具体数据:"observed 0–1114 tokens replaying go/ccshare/shawnm-20260310-202833"——实际观测到 thinking 最多消耗 1114 token,所以 2048 的 headroom 够用。
遥测:数据驱动的安全
分类器的每次调用都有详细的遥测:
logAutoModeOutcome('success', model, {
classifierType, // xml_2stage / xml_fast / xml_thinking
durationMs, // 延迟
mainLoopTokens, // 主循环的 token 数
classifierInputTokens, // 分类器的输入 token 数
classifierTokensEst, // 估算值(用于对比)
})
关键指标:classifierInputTokens / mainLoopTokens 的比值。
注释中说:
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).
分类器的输入应该是主循环的 60-80%(因为 transcript 是压缩过的)。如果超过 1.0——分类器比主循环还大,说明 transcript 投影出了问题。
还有错误时的 dump 功能——把系统提示、transcript、错误信息全部存到磁盘,用户可以通过 /share 上传给 Anthropic 分析:
async function dumpErrorPrompts(systemPrompt, userPrompt, error, contextInfo) {
const content =
`=== ERROR ===\n${errorMessage(error)}\n\n` +
`=== CONTEXT COMPARISON ===\n` +
`mainLoopTokens: ${contextInfo.mainLoopTokens}\n` +
`classifierTokensEst: ${contextInfo.classifierTokensEst}\n` +
`delta: ${contextInfo.classifierTokensEst - contextInfo.mainLoopTokens}\n`
// ...
}
设计原则提炼
yoloClassifier 教会我几件事:
1. 两阶段比一阶段好
一阶段做不到"又快又准"。Stage 1 追求快(64 token),Stage 2 追求准(4096 token chain-of-thought)。大多数请求走快速路径,只有被拦截的少数进入深度分析。这是一个乘法优化而不是加法优化。
2. 安全系统的默认行为是拒绝
解析失败?拒绝。API 超时?拒绝。不确定?拒绝。只有在分类器明确说 no(不应拦截)时才放行。单向安全门——只有"绿灯"才放行。
3. 不信任自己的文本
分类器只看 tool_use(客观操作)和 user text(用户意图),不看 assistant text(模型生成的内容)。因为如果 agent 能通过文本影响分类器,安全系统就形同虚设。
4. 用户意图需要显式表达
CLAUDE.md 作为"用户配置"被注入分类器,给它提供判断上下文。但只有明确的用户确认才能 override 拦截——"帮我处理一下"不算确认,"是的,删掉它"才算。
5. 安全系统需要遥测
你不知道分类器在生产中的表现,除非你测量它。延迟、准确率、误报率、上下文比例——每个维度都需要数据。
下一篇
安全层讲完了。下一篇进入 Claude Code 最优雅的设计之一——Agent 编排:Fork、Coordinator、Swarm 三种并行模式。
Fork 子进程继承父对话的完整上下文和 prompt cache,几乎零成本启动。Coordinator 模式把 Claude Code 变成纯调度器。Swarm 用 tmux 管理多个并行工作的 teammate。三种模式解决三种不同的并行场景。
📚 CC解构系列
| 篇目 | 日期 | 主题 |
|---|---|---|
| 序 | 2026-03-31 | 一个 .map 文件的蝴蝶效应 |
| 第 1 篇 | 2026-04-01 | 380K 行代码的骨架 |
| 第 2 篇 | 2026-04-02 | 灵魂工程:System Prompt 的七层设计 |
| 第 3 篇 | 2026-04-03 | 40 个工具的统一抽象 |
| 第 4 篇 | 2026-04-04 | 用 LLM 审查 LLM:yoloClassifier 的两阶段安全架构 |
| 第 5 篇 | 2026-04-05 | Agent 编排:Fork、Coordinator、Swarm 三种模式 |
| ... | ... | 持续更新 |

![[CC解构] 用 LLM 审查 LLM:yoloClassifier 两阶段安全架构](/claude-code-security-yolo-classifier/cover.jpg)