Claude Code 的自动权限判断
第 12 章讲到权限系统会产生 allow / deny / ask。这一章专门看 auto 模式:当普通权限管线走到 ask,Claude Code 如何用一个独立分类器先做风险裁决,而不是马上弹确认框?
这个分类器内部叫 YOLO classifier,但它不是“随便放行”。源码里的设计更接近一套安全分流器:
- 明显安全的工具先白名单短路。
- 工具自己决定哪些字段暴露给分类器。
- 分类器看到的是投影后的对话转录,不是主模型全部自由文本。
- 快速阶段先低成本判断,模糊或阻止再进入思考阶段。
- 任何解析失败、API 失败或上下文过长都按阻止处理,再交还人工路径。
核心直觉
自动权限判断不是绕过权限系统,而是接管 ask 灰区。硬规则先拦,白名单先放,分类器只处理剩下那些“可能安全但需要判断”的动作。
先看源码入口
| 源码定位 | 关键符号 | 负责什么 |
|---|---|---|
yoloClassifier.ts | classifyYoloAction() | 自动权限分类器主入口 |
classifierDecision.ts | SAFE_YOLO_ALLOWLISTED_TOOLS、isAutoModeAllowlistedTool() | 工具级安全白名单,避免不必要分类 |
| 各工具定义 | toAutoClassifierInput() | 控制工具输入如何投影给分类器 |
yoloClassifier.ts | yoloClassifierResponseSchema、classify_result | 结构化输出 schema 和强制工具调用输出 |
yoloClassifier.ts | 二阶段 XML classifier | 快速阶段和 thinking 阶段的 XML 裁决路径 |
yoloClassifier.ts | buildTranscriptEntries()、toCompactBlock() | 把主会话投影成分类器可读的紧凑转录 |
yoloClassifier.ts | buildClaudeMdMessage() | 把 CLAUDE.md 作为用户意图上下文注入 |
yoloClassifier.ts | BASE_PROMPT、permissions_external.txt、permissions_anthropic.txt | 组合分类器系统提示词 |
denialTracking.ts | DenialTrackingState、DENIAL_LIMITS | 连续拒绝和总拒绝追踪 |
autoModeState.ts | autoModeActive、autoModeFlagCli、autoModeCircuitBroken | auto 模式运行时状态和远程熔断 |
这些入口说明:分类器是一个独立的 side query 系统,不是主 agent loop 里的几句提示词。
总流程图
这张图里有两个关键短路:工具级白名单和字段级 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,核心字段包括 shouldBlock、reason、usage、model 等。它不是主循环模型的继续输出,而是单独 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: 'tool', name: 'classify_result' } 强制模型输出工具调用,而不是自由文本。
| 字段 | 用途 |
|---|---|
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 clean、rm -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 === 'disabled',会阻止用户重新进入 auto 模式。
这说明 auto 模式有服务端侧紧急刹车。分类器一旦出现系统性问题,可以通过配置关闭入口,而不是等客户端发版。
错误处理:任何不确定都阻止
分类器的降级策略可以归纳成 fail-closed。
| 场景 | 行为 |
|---|---|
| API 成功但响应无法解析 | shouldBlock: true |
| prompt 过长 | shouldBlock: true,标记 transcriptTooLong |
| API 临时不可用 | 重试后仍失败则阻止或回人工 |
| 用户取消 | 视为不可用,阻止或回人工 |
| 阶段 1 阻止,阶段 2 失败 | 沿用阻止结果 |
这和普通功能性 LLM 调用不同。分类器不是“尽量给答案”,而是“只要没拿到可靠答案,就不能放行”。
状态和数据结构
| 结构 | 关键字段 | 影响 |
|---|---|---|
YoloClassifierResult | shouldBlock、reason、usage、model | 分类器最终裁决和成本追踪 |
SAFE_YOLO_ALLOWLISTED_TOOLS | 工具名集合 | 工具级零成本短路 |
toAutoClassifierInput() | 工具自定义投影 | 决定分类器看到哪些字段 |
yoloClassifierResponseSchema | thinking、shouldBlock、reason | 强制结构化输出 |
TwoStageMode | both、fast、thinking | 控制二阶段分类器策略 |
AutoModeRules | allow、soft_deny、environment | 用户自然语言策略注入 |
DenialTrackingState | consecutiveDenials、totalDenials | 决定是否从自动降级到人工 |
autoModeState | autoModeActive、autoModeFlagCli、autoModeCircuitBroken | auto 模式入口状态 |
设计取舍
| 取舍 | 源码证据 | 工程判断 |
|---|---|---|
| 白名单先短路 | 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_control | action block 设置缓存断点 | thinking 阶段复用 fast 阶段缓存 |
| 拒绝追踪 | 连续 3 次或总计 20 次 | 防止 Agent 和分类器陷入反复拒绝循环 |
| 远程熔断 | autoModeCircuitBroken | 分类器系统性异常时可配置关闭 |
读源码抓手
- 先读
classifyYoloAction(),看它如何从权限系统的ask接手。 - 再看
isAutoModeAllowlistedTool(),确认哪些工具零成本放行。 - 接着查各工具的
toAutoClassifierInput(),理解分类器输入不是原始工具参数全集。 - 然后读
buildTranscriptEntries(),重点看为什么 assistant text 被过滤。 - 再看
yoloClassifierResponseSchema和tool_choice,确认结构化输出如何被强制。 - 接着读二阶段 XML 路径,比较 fast 和 thinking 的 prompt、token budget、解析逻辑。
- 最后看
denialTracking.ts和autoModeState.ts,理解自动模式如何降级和熔断。
小结
小结
自动权限分类器的核心不是“自动放权”,而是“分层处理 ask 灰区”。
classifyYoloAction()是独立侧查询,输入是待裁决动作、工具表、权限上下文和投影后的会话转录。- 安全白名单和
toAutoClassifierInput()先做零成本短路,只有有风险判断价值的动作才进入分类器。 - 分类器不信任主模型自由文本,只保留用户文本、工具调用和必要附件,减少注入面。
- 二阶段 XML 分类器用 fast 阶段降低平均延迟,用 thinking 阶段处理复杂和误阻场景。
- 解析失败、API 失败、上下文过长都按阻止处理,安全默认值始终是 fail-closed。
- 拒绝追踪和远程熔断保证 auto 模式不会在坏状态里无限循环。