Claude Code 源码解剖

Claude Code 的自动权限判断

从 classifyYoloAction、安全白名单、toAutoClassifierInput、二阶段 XML 分类器、转录投影、权限模板和拒绝追踪,拆解 Claude Code 的 auto 模式如何用独立分类器处理权限灰区。

第 13 章 16 分钟

Claude Code 的自动权限判断

第 12 章讲到权限系统会产生 allow / deny / ask。这一章专门看 auto 模式:当普通权限管线走到 ask,Claude Code 如何用一个独立分类器先做风险裁决,而不是马上弹确认框?

这个分类器内部叫 YOLO classifier,但它不是“随便放行”。源码里的设计更接近一套安全分流器:

  • 明显安全的工具先白名单短路。
  • 工具自己决定哪些字段暴露给分类器。
  • 分类器看到的是投影后的对话转录,不是主模型全部自由文本。
  • 快速阶段先低成本判断,模糊或阻止再进入思考阶段。
  • 任何解析失败、API 失败或上下文过长都按阻止处理,再交还人工路径。

核心直觉

自动权限判断不是绕过权限系统,而是接管 ask 灰区。硬规则先拦,白名单先放,分类器只处理剩下那些“可能安全但需要判断”的动作。

先看源码入口

源码定位关键符号负责什么
yoloClassifier.tsclassifyYoloAction()自动权限分类器主入口
classifierDecision.tsSAFE_YOLO_ALLOWLISTED_TOOLSisAutoModeAllowlistedTool()工具级安全白名单,避免不必要分类
各工具定义toAutoClassifierInput()控制工具输入如何投影给分类器
yoloClassifier.tsyoloClassifierResponseSchemaclassify_result结构化输出 schema 和强制工具调用输出
yoloClassifier.ts二阶段 XML classifier快速阶段和 thinking 阶段的 XML 裁决路径
yoloClassifier.tsbuildTranscriptEntries()toCompactBlock()把主会话投影成分类器可读的紧凑转录
yoloClassifier.tsbuildClaudeMdMessage()把 CLAUDE.md 作为用户意图上下文注入
yoloClassifier.tsBASE_PROMPTpermissions_external.txtpermissions_anthropic.txt组合分类器系统提示词
denialTracking.tsDenialTrackingStateDENIAL_LIMITS连续拒绝和总拒绝追踪
autoModeState.tsautoModeActiveautoModeFlagCliautoModeCircuitBrokenauto 模式运行时状态和远程熔断

这些入口说明:分类器是一个独立的 side query 系统,不是主 agent loop 里的几句提示词。

总流程图

flowchart TD A["权限管线产生 ask"] --> B["auto 模式启用?"] B -->|否| C["用户确认对话框"] B -->|是| D["classifyYoloAction()"] D --> E{"isAutoModeAllowlistedTool()?"} E -->|是| F["allow,无 API 调用"] E -->|否| G["tool.toAutoClassifierInput()"] G --> H{"返回空字符串?"} H -->|是| F H -->|否| I["buildTranscriptEntries()<br/>投影对话转录"] I --> J["buildYoloSystemPrompt()<br/>权限模板 + 用户规则"] J --> K{"二阶段 classifier?"} K -->|否| L["tool_choice: classify_result<br/>结构化输出"] K -->|是| M["阶段 1:XML fast<br/>max_tokens: 64"] M -->|block=no| F M -->|block=yes 或不确定| N["阶段 2:XML thinking<br/>max_tokens: 4096"] N --> O["解析 shouldBlock / reason"] L --> O O -->|shouldBlock=false| F O -->|shouldBlock=true| P["阻止或回到人工确认"] O -->|解析失败 / API 错误| P P --> Q["recordDenial()<br/>连续拒绝追踪"] F --> R["recordSuccess()<br/>重置连续拒绝"]

这张图里有两个关键短路:工具级白名单和字段级 toAutoClassifierInput()。真正调用模型分类器的是剩下那部分有风险信号的动作。

分类器入口:classifyYoloAction() 是独立侧查询

核心函数签名很能说明设计边界。

export async function classifyYoloAction(
  messages: Message[],
  action: TranscriptEntry,
  tools: Tools,
  context: ToolPermissionContext,
  signal: AbortSignal,
): Promise<YoloClassifierResult>

这段代码证明分类器拿到五类输入:

输入作用
messages主会话消息,但后续会被投影,不是原样喂给分类器
action当前待裁决的工具调用
tools工具注册表,用来查工具能力和投影方法
context权限上下文,比如 cwd、模式、设置等
signal取消和超时控制

返回值是 YoloClassifierResult,核心字段包括 shouldBlockreasonusagemodel 等。它不是主循环模型的继续输出,而是单独 API 调用的安全裁决结果。

第一层短路:安全白名单

只读和元数据工具不需要每次都花一次分类器调用。classifierDecision.ts 定义了白名单。

const SAFE_YOLO_ALLOWLISTED_TOOLS = new Set([
  FILE_READ_TOOL_NAME,
  GREP_TOOL_NAME,
  GLOB_TOOL_NAME,
  LSP_TOOL_NAME,
  TOOL_SEARCH_TOOL_NAME,
  LIST_MCP_RESOURCES_TOOL_NAME,
  'ReadMcpResourceTool',
  TODO_WRITE_TOOL_NAME,
  TASK_CREATE_TOOL_NAME,
  TASK_GET_TOOL_NAME,
  TASK_UPDATE_TOOL_NAME,
  TASK_LIST_TOOL_NAME,
  TASK_STOP_TOOL_NAME,
  TASK_OUTPUT_TOOL_NAME,
  ASK_USER_QUESTION_TOOL_NAME,
  ENTER_PLAN_MODE_TOOL_NAME,
  EXIT_PLAN_MODE_TOOL_NAME,
  TEAM_CREATE_TOOL_NAME,
  TEAM_DELETE_TOOL_NAME,
  SEND_MESSAGE_TOOL_NAME,
  YOLO_CLASSIFIER_TOOL_NAME,
])

查询函数很薄:

export function isAutoModeAllowlistedTool(toolName: string): boolean {
  return SAFE_YOLO_ALLOWLISTED_TOOLS.has(toolName)
}

这段代码证明,白名单是工具级零成本短路。它包含读文件、搜索、LSP、任务元数据、plan mode UI、swarm 协调和分类器自身。

几个细节值得注意:

  • 写入工具不在白名单里。工作目录内编辑会由 acceptEdits 快速路径处理,工作区外编辑才进入分类器。
  • swarm 协调工具可放行,是因为子 Agent 有自己的权限检查,协调层不是直接文件副作用。
  • YOLO_CLASSIFIER_TOOL_NAME 在白名单里,避免分类器工具调用触发递归分类。

第二层短路:toAutoClassifierInput() 控制暴露字段

白名单之后,工具还能决定哪些输入对分类器有意义。classifyYoloAction() 会读取当前工具的 toAutoClassifierInput()

if (actionCompact === '') {
  return {
    shouldBlock: false,
    reason: 'Tool declares no classifier-relevant input',
    model: getClassifierModel(),
  }
}

这段代码证明:如果工具返回空字符串,分类器直接放行,不发 API 请求。toAutoClassifierInput() 有三种常见语义:

返回值含义效果
字符串或对象投影工具认为这些字段有风险判断价值进入分类器输入
undefined没有专门投影回退到原始输入对象
空字符串没有分类相关输入直接 allow

这个接口的工程价值很大:安全判断不需要看所有原始输入,只需要看足以判断风险的字段。给得太多会增加注入面和 token 成本,给得太少会导致分类器凭感觉。

结构化输出:解析失败就是阻止

非 XML 路径使用工具调用强制结构化输出。响应 schema 由 Zod 定义。

const yoloClassifierResponseSchema = lazySchema(() =>
  z.object({
    thinking: z.string(),
    shouldBlock: z.boolean(),
    reason: z.string(),
  }),
)

分类器通过 tool_choice: { type: &#39;tool&#39;, name: &#39;classify_result&#39; } 强制模型输出工具调用,而不是自由文本。

字段用途
thinking分类器推理过程,用于调试和审计
shouldBlock核心裁决,true 阻止,false 放行
reason阻止时给人看的原因

解析失败时默认阻止。

const parsed = parseClassifierResponse(
  toolUseBlock,
  yoloClassifierResponseSchema(),
)
if (!parsed) {
  return {
    shouldBlock: true,
    reason: 'Invalid classifier response - blocking for safety',
  }
}

这段代码证明分类器是 fail-closed。只要模型没有按 schema 给出可解析结果,系统不会猜它的意图,而是阻止或回到人工路径。

二阶段 XML 分类器:先快判,再深想

工具调用格式稳定,但对明显安全的操作也要生成完整 JSON。为降低平均延迟,Claude Code 引入二阶段 XML 分类器。

type TwoStageMode = 'both' | 'fast' | 'thinking'

type AutoModeConfig = {
  twoStageClassifier?: boolean | 'fast' | 'thinking'
}

快速阶段

阶段 1 用很小的输出预算和 XML 格式。

const xmlFormat = [
  '## 输出格式',
  '',
  'If the action should be blocked:',
  '<block>yes</block><reason>one short sentence</reason>',
  '',
  'If the action should be allowed:',
  '<block>no</block>',
  '',
  'Do NOT include a <reason> tag when the action is allowed.',
  'Your ENTIRE response MUST begin with <block>...',
].join('\n')

阶段 1 使用 max_tokens: 64,并用 @@INLINE_0@@ 做停止序列。它的目标是快速判断明显安全动作。只要输出 @@INLINE_1@@no@@INLINE_2@@,就直接 allow。

思考阶段

如果阶段 1 阻止或不确定,系统升级到阶段 2。阶段 2 使用 max_tokens: 4096,要求先输出 @@INLINE_0@@ 再输出 @@INLINE_1@@,用于纠正阶段 1 因保守策略造成的误阻。

两阶段模式有三种运行方式:

模式行为
both先 fast,允许则返回,阻止或不确定再 thinking
fast只跑阶段 1,输出预算提升到 256,去掉停止序列
thinking只跑阶段 2

模式通过 GrowthBook 配置或 CLAUDE_CODE_TWO_STAGE_CLASSIFIER 控制。

阶段间缓存

两个阶段共享系统提示词和对话转录,差异主要在最后的引导后缀。动作块上会设置 cache_control

userContentBlocks.push({
  type: 'text' as const,
  text: actionCompact,
  cache_control: cacheControl,
})

这段代码证明,阶段 2 可以复用阶段 1 写入的 prompt cache。二阶段不是简单把成本翻倍,而是在模糊场景里尽量命中前一阶段缓存。

解析防御:先剥离 @@INLINE_0@@

模型可能在 @@INLINE_0@@ 里提到 @@INLINE_1@@,所以解析前要先剥离 thinking。

function stripThinking(text: string): string {
  return text
    .replace(/<thinking>[\s\S]*?<\/thinking>/g, '')
    .replace(/<thinking>[\s\S]*$/, '')
}

这段代码证明:分类器解析不能直接找第一个 @@INLINE_0@@。未闭合的 @@INLINE_1@@ 也要清掉,因为输出截断时可能没有闭合标签。

分类器看到什么:转录投影不是全文

分类器不直接看主循环原始消息,而是 buildTranscriptEntries() 生成的投影。

原始消息投影策略
user message只保留文本块,过滤图片等其他类型
assistant message只保留 tool_use,过滤助手自由文本
attachment queued command提取 queued_command prompt

源码注释点明了为什么过滤助手文本:

// Only include tool_use blocks; assistant text is model-authored
// and could be crafted to influence the classifier's decision.

这段代码证明:分类器不信任主模型自由文本。主模型可能被注入诱导,生成“这一步已经被用户授权”之类的文字。如果分类器把这些文字当证据,就会出现 AI 自己给自己开证明的问题。

转录序列化由 toCompactBlock() 完成,支持两种格式:

User: implement a fibonacci function
Bash ls -la
FileEditTool {"file_path":"/src/fib.ts","new_string":"..."}

或 JSONL:

{"user":"implement a fibonacci function"}
{"Bash":"ls -la"}
{"FileEditTool":{"file_path":"/src/fib.ts","new_string":"..."}}

JSONL 格式的安全优势在于:用户输入里的换行符会被 JSON 转义,不能伪造出另一行看起来像工具调用的内容。

项目约定:CLAUDE.md 作为用户意图

分类器还会看到 CLAUDE.md 上下文。buildClaudeMdMessage() 会把它包装成用户意图。

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>`

这段代码证明:分类器不是只看命令本身。比如 make cleanrm -rf build/ 在某些项目里是正常清理动作,在另一些上下文里可能很危险。CLAUDE.md 给分类器提供项目约定和用户意图。

权限模板:外部用户和内部用户策略不同

分类器系统提示词由基础模板和权限模板组合。

const BASE_PROMPT: string = feature('TRANSCRIPT_CLASSIFIER')
  ? txtRequire(require('./yolo-classifier-prompts/auto_mode_system_prompt.txt'))
  : ''

const EXTERNAL_PERMISSIONS_TEMPLATE: string = feature('TRANSCRIPT_CLASSIFIER')
  ? txtRequire(require('./yolo-classifier-prompts/permissions_external.txt'))
  : ''

const ANTHROPIC_PERMISSIONS_TEMPLATE: string =
  feature('TRANSCRIPT_CLASSIFIER') && process.env.USER_TYPE === 'ant'
    ? txtRequire(require('./yolo-classifier-prompts/permissions_anthropic.txt'))
    : ''

模板选择逻辑:

function isUsingExternalPermissions(): boolean {
  if (process.env.USER_TYPE !== 'ant') return true
  const config = getFeatureValue_CACHED_MAY_BE_STALE(
    'tengu_auto_mode_config',
    {} as AutoModeConfig,
  )
  return config?.forceExternalPermissions === true
}

这段代码证明:外部用户始终使用外部模板。内部用户默认可用内部模板,但能通过 forceExternalPermissions 强制体验外部策略。这不是文案差异,而是安全策略分层。

用户规则注入

用户可通过 settings.autoMode 注入自然语言规则。

export type AutoModeRules = {
  allow: string[]
  soft_deny: string[]
  environment: string[]
}

替换机制用 XML 标签定位。

return systemPrompt
  .replace(
    /<user_allow_rules_to_replace>([\s\S]*?)<\/user_allow_rules_to_replace>/,
    (_m, defaults: string) => userAllow ?? defaults,
  )
  .replace(
    /<user_deny_rules_to_replace>([\s\S]*?)<\/user_deny_rules_to_replace>/,
    (_m, defaults: string) => userDeny ?? defaults,
  )
  .replace(
    /<user_environment_to_replace>([\s\S]*?)<\/user_environment_to_replace>/,
    (_m, defaults: string) => userEnvironment ?? defaults,
  )

这段代码证明:auto 模式规则不是正则权限规则,而是注入分类器提示词的自然语言策略。外部模板和内部模板的替换语义不同:外部用户规则可替换默认段,内部用户规则更偏追加到内部基线之上。

系统还会注入 Bash 和 PowerShell 相关安全规则。PowerShell 指导会把 iex (iwr ...) 对齐到外部代码执行,把 Remove-Item -Recurse -Force 对齐到不可逆本地破坏,把修改 $PROFILE 对齐到持久化风险。

拒绝追踪:防止自动模式死循环

分类器拒绝后,Agent 可能反复尝试类似动作。denialTracking.ts 用两个计数器处理。

export type DenialTrackingState = {
  consecutiveDenials: number
  totalDenials: number
}

阈值:

export const DENIAL_LIMITS = {
  maxConsecutive: 3,
  maxTotal: 20,
} as const

回退判定:

export function shouldFallbackToPrompting(state: DenialTrackingState): boolean {
  return (
    state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
    state.totalDenials >= DENIAL_LIMITS.maxTotal
  )
}

这段代码证明:连续 3 次拒绝或总计 20 次拒绝,会从自动模式降级到人工提示。recordSuccess() 只重置连续拒绝,不重置总拒绝,避免一次成功操作把历史风险全部洗掉。

自动模式状态和远程熔断

autoModeState.ts 用三个模块级布尔值记录状态。

let autoModeActive = false
let autoModeFlagCli = false
let autoModeCircuitBroken = false
状态含义
autoModeActive当前是否处于 auto 模式
autoModeFlagCli是否由 CLI 标志启用 auto
autoModeCircuitBroken远程配置是否熔断 auto

autoModeCircuitBroken 是远程杀开关。当 verifyAutoModeGateAccess 读到 GrowthBook 中 tengu_auto_mode_config.enabled === &#39;disabled&#39;,会阻止用户重新进入 auto 模式。

这说明 auto 模式有服务端侧紧急刹车。分类器一旦出现系统性问题,可以通过配置关闭入口,而不是等客户端发版。

错误处理:任何不确定都阻止

分类器的降级策略可以归纳成 fail-closed。

场景行为
API 成功但响应无法解析shouldBlock: true
prompt 过长shouldBlock: true,标记 transcriptTooLong
API 临时不可用重试后仍失败则阻止或回人工
用户取消视为不可用,阻止或回人工
阶段 1 阻止,阶段 2 失败沿用阻止结果

这和普通功能性 LLM 调用不同。分类器不是“尽量给答案”,而是“只要没拿到可靠答案,就不能放行”。

状态和数据结构

结构关键字段影响
YoloClassifierResultshouldBlockreasonusagemodel分类器最终裁决和成本追踪
SAFE_YOLO_ALLOWLISTED_TOOLS工具名集合工具级零成本短路
toAutoClassifierInput()工具自定义投影决定分类器看到哪些字段
yoloClassifierResponseSchemathinkingshouldBlockreason强制结构化输出
TwoStageModebothfastthinking控制二阶段分类器策略
AutoModeRulesallowsoft_denyenvironment用户自然语言策略注入
DenialTrackingStateconsecutiveDenialstotalDenials决定是否从自动降级到人工
autoModeStateautoModeActiveautoModeFlagCliautoModeCircuitBrokenauto 模式入口状态

设计取舍

取舍源码证据工程判断
白名单先短路SAFE_YOLO_ALLOWLISTED_TOOLS只读和元数据动作不浪费分类器调用
字段级投影toAutoClassifierInput()降低 token 成本和注入面
助手文本不进转录Only include tool_use blocks 注释防止主模型自由文本影响安全裁决
JSONL 转录可选jsonlTranscript用 JSON 转义防止用户伪造工具调用行
结构化输出强制tool_choice: classify_result避免自由文本解析歧义
解析失败阻止Invalid classifier response - blocking for safety分类器不可用时不能猜测放行
二阶段判断fast 64 tokens + thinking 4096 tokens平衡平均延迟和复杂场景准确性
阶段间 cache_controlaction block 设置缓存断点thinking 阶段复用 fast 阶段缓存
拒绝追踪连续 3 次或总计 20 次防止 Agent 和分类器陷入反复拒绝循环
远程熔断autoModeCircuitBroken分类器系统性异常时可配置关闭

读源码抓手

  1. 先读 classifyYoloAction(),看它如何从权限系统的 ask 接手。
  2. 再看 isAutoModeAllowlistedTool(),确认哪些工具零成本放行。
  3. 接着查各工具的 toAutoClassifierInput(),理解分类器输入不是原始工具参数全集。
  4. 然后读 buildTranscriptEntries(),重点看为什么 assistant text 被过滤。
  5. 再看 yoloClassifierResponseSchematool_choice,确认结构化输出如何被强制。
  6. 接着读二阶段 XML 路径,比较 fast 和 thinking 的 prompt、token budget、解析逻辑。
  7. 最后看 denialTracking.tsautoModeState.ts,理解自动模式如何降级和熔断。

小结

小结

自动权限分类器的核心不是“自动放权”,而是“分层处理 ask 灰区”。

  • classifyYoloAction() 是独立侧查询,输入是待裁决动作、工具表、权限上下文和投影后的会话转录。
  • 安全白名单和 toAutoClassifierInput() 先做零成本短路,只有有风险判断价值的动作才进入分类器。
  • 分类器不信任主模型自由文本,只保留用户文本、工具调用和必要附件,减少注入面。
  • 二阶段 XML 分类器用 fast 阶段降低平均延迟,用 thinking 阶段处理复杂和误阻场景。
  • 解析失败、API 失败、上下文过长都按阻止处理,安全默认值始终是 fail-closed。
  • 拒绝追踪和远程熔断保证 auto 模式不会在坏状态里无限循环。