Claude Code 源码深度解析
第9章 多模式权限模型
开篇引言
在一个能够读写文件、执行 Shell 命令、启动子进程的 AI 编程助手中,权限控制是安全的最后一道防线。Claude Code 面临的核心挑战是:如何在保障安全的前提下,尽可能减少用户的审批疲劳?
传统的权限模型往往走向两个极端:要么对每一步操作都弹出确认对话框,导致用户体验极度割裂;要么一旦授权就不再过问,留下巨大的安全隐患。Claude Code 选择了第三条路径——一个多层次、多维度的权限决策框架。它融合了静态规则匹配、动态 AI 分类器、多种权限模式、以及对拒绝行为的智能追踪,构建出一套既灵活又安全的权限治理体系。
本章将从源码层面深入剖析这套权限模型的完整设计。我们将看到,一个看似简单的"是否允许执行"的判断,背后是一条精心编排的多阶段决策流水线。从静态规则的解析匹配,到动态分类器的语义推理,再到多通道竞争的用户交互,每一个环节都有明确的分工。
理解这套权限模型,不仅有助于我们更好地使用 Claude Code,也为我们在其他 AI 系统中设计类似的安全治理机制提供了一个可供参考的工程范本。
本章要点
- Claude Code 定义了七种权限模式:六种可由用户选择(default、acceptEdits、plan、bypassPermissions、dontAsk、auto,即 2.1.89
--permission-mode的可选值),外加一种只在内部使用的 bubble,每种模式对应不同的安全与效率平衡点 useCanUseToolHook 是权限系统的中枢调度器,将静态规则判定、动态分类器评估、用户交互三个阶段串联为一条完整的决策流水线- 静态规则系统采用三类规则(allowRules、denyRules、alwaysAskRules)与多层来源优先级机制,实现细粒度的权限声明
- Bash 分类器按用户写的自然语言规则对命令做语义匹配(公开构建中已剥离),Transcript 分类器(auto mode)基于完整对话上下文进行 AI 推理决策
- 拒绝追踪机制通过连续拒绝计数和总拒绝计数,在自动模式下实现"安全降级"——超过阈值后自动回退到人工审批
- 多 Agent 场景下,swarm worker 通过邮箱机制将权限请求转发给 leader,coordinator 则在显示交互对话框前先行运行自动化检查
9.1 权限模式总览
Claude Code 的七种权限模式构成了一个从最严格到最宽松的安全频谱,下图展示了各模式之间的信任层级关系:
flowchart LR
subgraph External["用户可选模式 (--permission-mode)"]
plan["plan\n只读/规划"]
default["default\n逐一确认"]
acceptEdits["acceptEdits\n自动批准编辑"]
dontAsk["dontAsk\n静默拒绝"]
bypass["bypassPermissions\n绕过权限"]
auto["auto\nAI 分类器决策"]
end
subgraph Internal["内部模式"]
bubble["bubble\n冒泡到父 Agent"]
end
plan -->|"信任递增"| default
default -->|"信任递增"| acceptEdits
acceptEdits -->|"信任递增"| bypass
default -.->|"auto mode"| auto
default -.->|"子 Agent"| bubble
dontAsk -.->|"ask 转 deny"| default
9.1.1 模式类型定义
Claude Code 的权限模式定义在 src/types/permissions.ts 中,分为外部模式和内部模式两个层次:
// 文件: src/types/permissions.ts
export const EXTERNAL_PERMISSION_MODES = [
'acceptEdits',
'bypassPermissions',
'default',
'dontAsk',
'plan',
] as const
export type ExternalPermissionMode = (typeof EXTERNAL_PERMISSION_MODES)[number]
// 内部模式 = 外部模式 + auto + bubble
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
export type PermissionMode = InternalPermissionMode
类型层面,内部模式(InternalPermissionMode)在外部模式之外多了 auto 和 bubble。但"内部类型"不等于"用户不可用",要以公开包的运行时数组为准。2.1.89 的 cli.js 中是:
// 2.1.89 cli.js(压缩后变量名还原为可读名)
EXTERNAL_PERMISSION_MODES = ["acceptEdits", "bypassPermissions", "default", "dontAsk", "plan"]
INTERNAL_PERMISSION_MODES = [...EXTERNAL_PERMISSION_MODES, "auto"]
PERMISSION_MODES = INTERNAL_PERMISSION_MODES
也就是说,auto 在公开构建里是真实可用的模式:claude --help 中 --permission-mode 的可选值是 acceptEdits、bypassPermissions、default、dontAsk、plan、auto,permissions.defaultMode 设置项也接受 auto,模式配置里有标题为 "Auto mode" 的条目;CHANGELOG 2.1.89 还有"Auto mode: denied commands now show a notification and appear in /permissions → Recent tab"等条目。auto 的门控发生在运行时而非构建时:能否进入取决于 isAutoModeAvailable 以及一个综合判断(未被 disableAutoMode 设置禁用、当前模型支持、未触发熔断),远程配置 tengu_auto_mode_config 也参与决定(见 9.1.3)。bubble 则不在任何用户可选列表里,只作为 Agent 定义的 permissionMode 由系统使用(2.1.89 中内置的隐式 fork Agent 即 permissionMode: "bubble",见 9.1.2)。
为什么要在类型上区分内部模式和外部模式?与功能门控(feature gating)和构建时消除(dead code elimination)有关:还原源码中 auto 相关的数组项和配置项都包在 feature('TRANSCRIPT_CLASSIFIER') 条件里,Bun 的打包器会在构建时评估 feature() 调用,被关闭的开关对应的代码会从外部构建中移除。从 2.1.89 的 cli.js 看,auto 模式相关代码(模式配置、分类器、拒绝追踪)都保留在包内,说明对应开关在外部构建中是打开的(与第 3 章的结论一致)。
9.1.2 各模式的行为语义
每种权限模式在 src/utils/permissions/PermissionMode.ts 中配置了对应的显示信息:
// 文件: src/utils/permissions/PermissionMode.ts
const PERMISSION_MODE_CONFIG: Partial<
Record<PermissionMode, PermissionModeConfig>
> = {
default: {
title: 'Default',
shortTitle: 'Default',
symbol: '',
color: 'text',
external: 'default',
},
plan: {
title: 'Plan Mode',
shortTitle: 'Plan',
symbol: PAUSE_ICON,
color: 'planMode',
external: 'plan',
},
bypassPermissions: {
title: 'Bypass Permissions',
shortTitle: 'Bypass',
symbol: '⏵⏵',
color: 'error',
external: 'bypassPermissions',
},
// ... acceptEdits、dontAsk 同理;auto 的 title 为 'Auto mode',
// 其 external 字段为 'default'(换算成外部模式时 auto 对应 default)
}
以下是各模式的核心行为差异:
default 模式 是最标准的工作模式,也是大多数用户日常使用的模式。每次工具调用都会经过完整的权限检查流程:先匹配静态规则(deny/ask/allow),然后根据工具自身的 checkPermissions 方法判断,最后对未匹配的操作弹出用户确认对话框。在这个模式下,用户对每一个未被规则覆盖的操作都拥有完整的审批权。系统会展示操作详情、提供"始终允许"选项(生成持久化的 allow 规则),以及"拒绝并提供反馈"选项。这是安全性与可用性的基础平衡点。
acceptEdits 模式 是 default 的宽松变体,专为代码编写密集型任务设计。它在 default 的基础上,自动放行文件编辑类操作(Edit、Write、NotebookEdit 工具在工作目录内的修改)以及特定的文件系统 Bash 命令。这大幅减少了日常编码场景中的权限弹窗数量,同时仍然对网络请求、进程管理等高风险操作保持审批要求。在 src/tools/BashTool/modeValidation.ts 中,可以看到被自动允许的命令列表:
// 文件: src/tools/BashTool/modeValidation.ts
const ACCEPT_EDITS_ALLOWED_COMMANDS = [
'mkdir', 'touch', 'rm', 'rmdir', 'mv', 'cp', 'sed',
] as const
plan 模式 是一种"只读规划"模式。在此模式下,Claude 只进行思考和规划,不实际执行任何修改操作。这对于复杂任务的前期分析阶段非常有用——用户可以先让 Claude 输出一个详细的执行计划,确认方案合理后再切换到执行模式。需要注意,2.1.89 中若当前会话 bypassPermissions 可用(isBypassPermissionsModeAvailable),plan 模式在权限判定上与 bypassPermissions 一样直接放行(见 9.3.3 步骤 2a),此时"只规划不修改"靠的是 plan 模式给模型的指令约束,而不是权限拦截。plan 模式还有一个特殊行为:如果用户进入 plan 模式之前是 bypassPermissions 模式,那么退出 plan 模式时会恢复到 bypassPermissions 模式而非 default 模式,这通过 prePlanMode 字段来记录。
bypassPermissions 模式 跳过绝大多数权限检查,使 Claude 能够不受中断地连续执行操作。然而,"绕过"并非"无视"——它仍然尊重三类不可绕过的安全约束:deny 规则(步骤 1a 的明确拒绝)、content-specific ask 规则(步骤 1f,如 Bash(npm publish:*) 这类用户刻意设置的审批点)、以及安全检查(步骤 1g,对 .git/、.claude/、.vscode/、shell 配置文件等敏感路径的保护)。这一设计确保了即使在最宽松的模式下,核心安全底线仍然不可突破(以上为 2.1.89 的行为;截至 2026-09-24 的官方文档,bypassPermissions 已改为连受保护路径的写入也直接放行)。此模式需要用户显式启用:--dangerously-skip-permissions、--permission-mode bypassPermissions,或用 --allow-dangerously-skip-permissions 只把它加入可切换的选项而不立即启用;名称中的"dangerously"前缀本身就是一种风险提示。
dontAsk 模式 将所有本应弹出用户确认的 ask 决策自动转换为 deny。这意味着 Claude 不会中断用户的工作流,但凡是本需审批、又未被规则明确允许的操作都不会执行。这个模式非常适合 CI/CD 等自动化管道场景——在这些场景中没有人可以回答权限弹窗,与其让进程挂起等待,不如安全地拒绝并让 Claude 尝试其他方案。
auto 模式 是权限系统中最复杂也最具创新性的模式,它用 AI 分类器完全替代人工审批。当权限检查的初步结果为 ask 时,auto 模式不会弹出对话框,而是调用 Transcript 分类器对当前操作在完整对话上下文中进行安全评估。分类器批准则自动执行,拒绝则直接拒绝(不弹框),并把原因反馈给模型。这种设计的核心理念是:AI 可以理解操作的意图和上下文,比简单的规则匹配做出更精准的安全判断。在 2.1.89 中,auto 模式可通过 --permission-mode auto、permissions.defaultMode: "auto" 或 Shift+Tab 进入,前提是运行时判定可用(见 9.1.3)。不过,auto 模式也配备了多层安全网,包括拒绝追踪、危险权限剥离、以及不可分类器审批的安全检查,这些将在后续章节详述。
bubble 模式 是内部使用的特殊模式,用于多 Agent 架构中权限请求的向上传递场景。当子 Agent 遇到需要权限审批的操作时,可以将请求"冒泡"到父 Agent 或最终的用户界面。
9.1.3 模式切换机制
用户可以通过 Shift+Tab 快捷键循环切换权限模式。切换逻辑定义在 src/utils/permissions/getNextPermissionMode.ts 中:
// 文件: src/utils/permissions/getNextPermissionMode.ts
export function getNextPermissionMode(
toolPermissionContext: ToolPermissionContext,
): PermissionMode {
switch (toolPermissionContext.mode) {
case 'default':
return 'acceptEdits'
case 'acceptEdits':
return 'plan'
case 'plan':
if (toolPermissionContext.isBypassPermissionsModeAvailable) {
return 'bypassPermissions'
}
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
case 'bypassPermissions':
if (canCycleToAuto(toolPermissionContext)) {
return 'auto'
}
return 'default'
case 'dontAsk':
return 'default'
default:
return 'default'
}
}
注意两个关键的条件守卫:isBypassPermissionsModeAvailable 确保 bypassPermissions 只在用户主动启用时可用,防止普通用户意外进入高风险模式;canCycleToAuto 同时检查上下文中的 isAutoModeAvailable 标志和一个运行时门控(2.1.89 中该门控要求 auto 模式未被 disableAutoMode 设置禁用、当前模型受支持、且未触发熔断),两者都满足才允许切换到 auto。dontAsk 不在循环里:它只能通过 --permission-mode dontAsk 或设置进入,从 dontAsk 按 Shift+Tab 回到 default。模式切换不是简单的循环列表,而是一个基于当前上下文动态计算的有向图——每个节点的出边取决于当前的运行环境配置。
据还原源码,Anthropic 内部用户(USER_TYPE === 'ant')另有一条切换路径:从 default 直接跳到 bypassPermissions 或 auto,跳过 acceptEdits 和 plan。这一分支在公开构建中已被剥离,2.1.89 的 cli.js 里只剩上面的路径。
切换到 auto 模式时,还需要执行一个关键的安全操作——剥离危险权限规则:
// 文件: src/utils/permissions/getNextPermissionMode.ts
export function cyclePermissionMode(
toolPermissionContext: ToolPermissionContext,
): { nextMode: PermissionMode; context: ToolPermissionContext } {
const nextMode = getNextPermissionMode(toolPermissionContext)
return {
nextMode,
context: transitionPermissionMode(
toolPermissionContext.mode,
nextMode,
toolPermissionContext,
),
}
}
transitionPermissionMode 会调用 stripDangerousPermissionsForAutoMode,移除诸如 Bash(*)、Bash(python:*) 等过于宽泛的允许规则。这些规则在 default 模式下是合理的——用户已经通过手动审批明确表达了信任意图。但在 auto 模式下,这些规则会在 AI 分类器评估之前就自动放行相关操作,等于为任意代码执行打开了一条绕过分类器的通道,因此必须剥离。被剥离的规则会记录在 strippedDangerousRules 字段中,以便在 UI 中向用户展示"哪些规则被临时禁用了"。当用户切换回其他模式时,这些规则会自动恢复。
9.2 useCanUseTool Hook 深度剖析
useCanUseTool 作为权限系统的中枢调度器,内部包含三层处理器的优先级链。下图展示了从工具调用请求到最终权限决策的完整时序:
sequenceDiagram
participant Tool as 工具执行管线
participant Hook as useCanUseTool
participant Rules as 静态规则匹配
participant Classifier as 动态分类器
participant UI as 用户交互 UI
participant Bridge as Bridge/Channel
Tool->>Hook: canUseTool(tool, input)
Hook->>Rules: hasPermissionsToUseToolInner()
alt deny 规则命中
Rules-->>Hook: DENY
else allow 规则命中
Rules-->>Hook: ALLOW
else ask (需人工/AI 审批)
Rules-->>Hook: ASK
alt auto 模式
Hook->>Classifier: Transcript 分类器
Classifier-->>Hook: shouldBlock / allow
else default 模式
Hook->>UI: 推入权限请求队列
par 多通道竞争
UI-->>Hook: 用户点击决策
and
Bridge-->>Hook: claude.ai 远程审批
and
Hook-->>Hook: PermissionRequest Hook 自动审批
end
else dontAsk 模式
Hook-->>Hook: ask 转 DENY
end
end
Hook-->>Tool: PermissionResult {allow/deny}
9.2.1 Hook 的角色定位
useCanUseTool 是整个权限系统的中枢调度器,定义在 src/hooks/useCanUseTool.tsx 中。它是一个 React Hook,将异步的权限决策流程包装为可在 React 组件树中使用的回调函数。
// 文件: src/hooks/useCanUseTool.tsx
export type CanUseToolFn<
Input extends Record<string, unknown> = Record<string, unknown>
> = (
tool: ToolType,
input: Input,
toolUseContext: ToolUseContext,
assistantMessage: AssistantMessage,
toolUseID: string,
forceDecision?: PermissionDecision<Input>,
) => Promise<PermissionDecision<Input>>
这个类型签名揭示了几个重要的设计决策:
- tool 和 input 分离:工具定义与本次调用的具体输入分开传递,允许同一工具的不同调用有不同的权限判定
- toolUseContext 提供全局上下文:包含消息历史、应用状态、中止控制器等,使权限判定可以感知完整的会话语境
- forceDecision 支持强制覆盖:某些场景下(如重试、恢复)可以跳过权限检查直接使用预设的决策结果
- 返回 Promise:权限判定是异步的,因为它可能需要等待用户交互、分类器 API 调用、或 Hook 执行
9.2.2 核心决策流程
Hook 内部的实现遵循一条清晰的三阶段管线:
// 文件: src/hooks/useCanUseTool.tsx(简化)
function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) {
return async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) =>
new Promise(resolve => {
const ctx = createPermissionContext(
tool, input, toolUseContext, assistantMessage,
toolUseID, setToolPermissionContext,
createPermissionQueueOps(setToolUseConfirmQueue)
);
// 阶段零: 中止检查
if (ctx.resolveIfAborted(resolve)) return;
// 阶段一: 静态规则 + 模式判定
const decisionPromise = forceDecision !== undefined
? Promise.resolve(forceDecision)
: hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID);
return decisionPromise.then(async result => {
// 阶段二: 根据 result.behavior 分发
if (result.behavior === "allow") {
// 直接放行
resolve(ctx.buildAllow(result.updatedInput ?? input, {...}));
return;
}
if (result.behavior === "deny") {
// 直接拒绝
resolve(result);
return;
}
// 阶段三: behavior === "ask" -> 交互决策
// 尝试 coordinator handler -> swarm worker handler -> interactive handler
});
});
}
当静态规则判定结果为 ask 时,进入最复杂的交互决策阶段。此阶段按优先级依次尝试三个处理器:
9.2.3 三层处理器架构
Coordinator Handler(src/hooks/toolPermission/handlers/coordinatorHandler.ts):当权限上下文带有 awaitAutomatedChecksBeforeDialog 标志时(2.1.89 中,异步运行但仍允许弹出权限框的子 Agent 会被设置该标志),先串行地运行自动化检查(Hook 和分类器),再回退到交互对话框。这确保了自动化通道优先于人工审批:
// 文件: src/hooks/toolPermission/handlers/coordinatorHandler.ts
async function handleCoordinatorPermission(
params: CoordinatorPermissionParams,
): Promise<PermissionDecision | null> {
const { ctx, updatedInput, suggestions, permissionMode } = params
try {
// 1. 先尝试权限 Hook(快速、本地)
const hookResult = await ctx.runHooks(permissionMode, suggestions, updatedInput)
if (hookResult) return hookResult
// 2. 再尝试分类器(慢、需要推理)
const classifierResult = feature('BASH_CLASSIFIER')
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
: null
if (classifierResult) return classifierResult
} catch (error) {
logError(error instanceof Error ? error : new Error(`...`))
}
// 3. 都未决定 -> 回退到交互对话框
return null
}
这里和下面 swarm worker 中的分类器步骤都受 BASH_CLASSIFIER 开关控制。该开关在公开构建中是关闭的:2.1.89 的 cli.js 里这一步被编译成恒为 null 的空操作,实际只剩 Hook 检查(见 9.4.1)。
Swarm Worker Handler(src/hooks/toolPermission/handlers/swarmWorkerHandler.ts):当运行在 swarm worker 模式时,先尝试分类器自动批准,否则将权限请求通过邮箱转发给 leader agent:
// 文件: src/hooks/toolPermission/handlers/swarmWorkerHandler.ts(简化)
async function handleSwarmWorkerPermission(
params: SwarmWorkerPermissionParams,
): Promise<PermissionDecision | null> {
if (!isAgentSwarmsEnabled() || !isSwarmWorker()) return null
// 对 bash 命令先尝试分类器自动批准
const classifierResult = feature('BASH_CLASSIFIER')
? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
: null
if (classifierResult) return classifierResult
// 将权限请求转发给 leader
const request = createPermissionRequest({
toolName: ctx.tool.name,
toolUseId: ctx.toolUseID,
input: ctx.input,
description,
})
registerPermissionCallback({ requestId: request.id, ... })
await sendPermissionRequestViaMailbox(request)
// ...等待 leader 响应
}
Interactive Handler(src/hooks/toolPermission/handlers/interactiveHandler.ts):最终的兜底处理器,负责向用户展示权限确认对话框,并发起多路竞争:
// 文件: src/hooks/toolPermission/handlers/interactiveHandler.ts(简化结构)
function handleInteractivePermission(params, resolve) {
const { resolve: resolveOnce, claim } = createResolveOnce(resolve)
let userInteracted = false
// 向 UI 队列推送确认项
ctx.pushToQueue({
onAbort() { /* 用户中止 */ },
onAllow(updatedInput, permissionUpdates) { /* 用户批准 */ },
onReject(feedback) { /* 用户拒绝 */ },
recheckPermission() { },
onUserInteraction() { /* 用户开始交互,取消自动批准 */ },
})
// 竞争通道 1: Bridge 远程响应(来自 claude.ai)
// 竞争通道 2: Channel 远程响应(Telegram/iMessage 等)
// 竞争通道 3: PermissionRequest Hook 异步执行
// 竞争通道 4: Bash 分类器异步检查(BASH_CLASSIFIER,公开构建中已剥离)
}
这里的设计精华在于 createResolveOnce 机制——它确保无论哪个竞争通道先到达,Promise 只会被 resolve 一次。claim() 方法提供原子性的"声明-检查"操作,避免了异步回调之间的竞态条件。
9.3 静态规则匹配
9.3.1 三类规则体系
Claude Code 的静态权限规则分为三类,定义在 ToolPermissionContext 类型中:
// 文件: src/types/permissions.ts
export type ToolPermissionContext = {
readonly mode: PermissionMode
readonly alwaysAllowRules: ToolPermissionRulesBySource
readonly alwaysDenyRules: ToolPermissionRulesBySource
readonly alwaysAskRules: ToolPermissionRulesBySource
// ...
}
export type ToolPermissionRulesBySource = {
[T in PermissionRuleSource]?: string[]
}
每类规则按来源(source)分组存储。这种按来源分组的设计使得规则的溯源、管理和冲突解决变得清晰可控。来源包括以下八种:
- userSettings:用户全局配置文件
~/.claude/settings.json中的规则,跨所有项目生效 - projectSettings:项目级配置文件
.claude/settings.json中的规则,仅在当前项目中生效,可以提交到版本库中与团队共享 - localSettings:本地配置文件
.claude/settings.local.json中的规则,项目级别但不应提交到版本库,适用于个人偏好 - flagSettings:通过
--settings <文件或 JSON 字符串>命令行参数额外加载的设置文件中的规则(2.1.89 的cli.js把这一来源显示为 "Command line arguments"/"cli flag"),不是远程下发的 feature flag - policySettings:企业管理策略(managed settings)配置的规则,不可被用户修改或删除
- cliArg:通过
--allowed-tools、--disallowed-tools等命令行参数传入的规则,仅当次会话有效 - command:斜杠命令或 Skill 在 frontmatter 中声明的
allowed-tools,在该命令/Skill 执行期间临时加入的允许规则(不是/permissions写入的规则——后者写进设置文件) - session:会话级别的临时规则,用户在权限对话框中选择"本次会话允许"时创建
这种多来源架构的一个重要特性是策略不可篡改性:policySettings 来源的规则代表组织级的安全策略,无法被用户删除,其中的 deny 也不会被任何层级的 allow 推翻。这为企业部署提供了必要的合规保障。
9.3.2 规则的定义格式
每条规则由工具名和可选的内容限定组成,解析逻辑在 src/utils/permissions/permissionRuleParser.ts 中:
// 文件: src/utils/permissions/permissionRuleParser.ts
// 解析示例:
// 'Bash' => { toolName: 'Bash' }
// 'Bash(npm install)' => { toolName: 'Bash', ruleContent: 'npm install' }
// 'Bash(npm:*)' => { toolName: 'Bash', ruleContent: 'npm:*' }
// 'mcp__server1__tool1' => { toolName: 'mcp__server1__tool1' }
规则内容支持三种匹配模式(定义在 src/utils/permissions/shellRuleMatching.ts):
- 精确匹配:
Bash(npm install)仅匹配npm install命令 - 前缀匹配:
Bash(git:*)匹配git本身以及git后接空格与参数的命令(使用:*语法;按词边界匹配,不会匹配gitk) - 通配符匹配:
Bash(npm run *)匹配符合通配符模式的命令(使用*语法)
// 文件: src/utils/permissions/shellRuleMatching.ts
export type ShellPermissionRule =
| { type: 'exact'; command: string }
| { type: 'prefix'; prefix: string }
| { type: 'wildcard'; pattern: string }
9.3.3 匹配算法与优先级
规则的匹配遵循严格的优先级顺序,体现在 hasPermissionsToUseToolInner 函数的步骤编排中:
优先级(从高到低):
1a. deny 规则(工具级别) -> 立即拒绝
1b. ask 规则(工具级别) -> 要求确认
1c. 工具自身的 checkPermissions(命令级别规则匹配)
1d. 工具实现拒绝 -> 立即拒绝
1e. 工具要求用户交互 -> 要求确认
1f. 内容级 ask 规则 -> 要求确认(不可被 bypass 覆盖)
1g. 安全检查 -> 要求确认(不可被 bypass 覆盖)
2a. 模式判定(bypassPermissions,或 bypass 可用时的 plan) -> 自动放行
2b. allow 规则(工具级别) -> 自动放行
3. passthrough 转 ask -> 要求确认
这个优先级设计的关键原则是:deny 永远优先于 allow,安全检查不可绕过。即使在 bypassPermissions 模式下(步骤 2a),步骤 1d、1f、1g 中的拒绝和安全检查仍然生效。
工具级别的规则匹配逻辑如下:
// 文件: src/utils/permissions/permissions.ts
function toolMatchesRule(
tool: Pick<Tool, 'name' | 'mcpInfo'>,
rule: PermissionRule,
): boolean {
// 规则必须没有 content 才能匹配整个工具
if (rule.ruleValue.ruleContent !== undefined) return false
const nameForRuleMatch = getToolNameForPermissionCheck(tool)
// 直接名称匹配
if (rule.ruleValue.toolName === nameForRuleMatch) return true
// MCP 服务器级别匹配: "mcp__server1" 匹配 "mcp__server1__tool1"
const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName)
const toolInfo = mcpInfoFromString(nameForRuleMatch)
return (
ruleInfo !== null && toolInfo !== null &&
(ruleInfo.toolName === undefined || ruleInfo.toolName === '*') &&
ruleInfo.serverName === toolInfo.serverName
)
}
对于 MCP 工具,规则支持服务器级别的批量匹配——mcp__server1 会匹配该服务器下的所有工具,mcp__server1__* 通过通配符实现同样效果。这是一个面向 MCP 生态的扩展性设计——当一个 MCP 服务器提供了数十个工具时,逐一配置权限显然不现实,服务器级别的规则提供了必要的批量管理能力。
值得注意的是,在 CLAUDE_AGENT_SDK_MCP_NO_PREFIX 模式下(跳过前缀模式),MCP 工具使用无前缀的显示名称(如"Write"),可能与内建工具名称冲突。规则匹配通过 getToolNameForPermissionCheck 函数始终使用完全限定名进行匹配,确保即使在显示名称冲突时也不会出现规则误匹配。
9.3.4 规则来源与聚合
规则从多个来源加载并聚合到 ToolPermissionContext 中。getAllowRules、getDenyRules、getAskRules 三个函数遍历所有来源,将字符串形式的规则解析为结构化的 PermissionRule 对象:
// 文件: src/utils/permissions/permissions.ts
export function getAllowRules(context: ToolPermissionContext): PermissionRule[] {
return PERMISSION_RULE_SOURCES.flatMap(source =>
(context.alwaysAllowRules[source] || []).map(ruleString => ({
source,
ruleBehavior: 'allow',
ruleValue: permissionRuleValueFromString(ruleString),
})),
)
}
注意,判定时来源之间没有"高优先级覆盖低优先级"的关系:各来源的规则汇总到一起,按 9.3.3 的 deny → ask → allow 顺序判定,任何一个来源里的 deny 都不会被别处的 allow 推翻(与第 2 章一致)。来源的差别体现在生命周期与可写性上:policySettings(企业策略)不可被用户删除,session 级别的规则只在当前会话中有效。在规则的持久化写入方面,只有 userSettings、projectSettings 和 localSettings 三种来源支持磁盘持久化;cliArg 和 session 是纯内存规则,policySettings、flagSettings 和 command 是只读来源(2.1.89 中试图删除这三种来源的规则会报 "Cannot delete permission rules from read-only settings")。当用户在权限对话框中选择"Yes, and don't ask again"一类选项时,2.1.89 生成的规则更新写入 localSettings(.claude/settings.local.json,按项目生效、不提交版本库),或仅写入 session;要写进用户级或共享项目级设置,需通过 /permissions 等界面选择目标位置。
规则的同步机制也值得关注:当用户在外部编辑器中修改了配置文件,syncPermissionRulesFromDisk 函数会重新加载磁盘上的规则并替换内存中的对应来源。替换操作按来源分组进行——先清空该来源的所有旧规则,再写入新规则——以确保删除的规则不会因增量合并而残留。
9.4 动态分类器
9.4.1 Bash 分类器
Bash 分类器是专门针对 Shell 命令的语义分析器,定义在 src/utils/permissions/bashClassifier.ts 和 src/tools/BashTool/bashPermissions.ts 中。它的核心任务是判断一条 Bash 命令是否匹配用户定义的语义描述规则。
与静态规则的精确匹配不同,Bash 分类器使用 AI 模型进行语义理解。例如,用户可以定义一条 prompt: 前缀的规则:"prompt: commands that modify git history",分类器会判断 git rebase -i HEAD~3 是否符合这个语义描述。
需要先说明版本口径:这一整套 Bash 分类器受 BASH_CLASSIFIER 开关控制,在公开构建中是关闭的。2.1.89 的 cli.js 里仍保留 prompt: 前缀常量和调用点,但取规则的函数恒返回空数组、分类函数恒返回 {matches: false, confidence: "high", reason: "This feature is disabled"},下文的推测性检查(pendingClassifierCheck、executeAsyncClassifierCheck)在包里也查无此名。因此本节描述的是还原源码中的设计,公开版用户写的 prompt: 规则不会生效。
分类器的输入输出定义如下:
// 文件: src/types/permissions.ts
export type ClassifierResult = {
matches: boolean // 是否匹配描述
matchedDescription?: string // 匹配到的具体描述
confidence: 'high' | 'medium' | 'low' // 置信度评分
reason: string // 判断理由
}
export type ClassifierBehavior = 'deny' | 'ask' | 'allow'
分类器的三级置信度机制是一个重要的安全设计:只有 high 置信度的匹配结果才会被采纳(匹配 deny 描述则拒绝、匹配 ask 描述则要求确认),medium 和 low 置信度的结果被忽略,命令继续走常规的权限检查。置信度的引入使得分类器不是一个简单的二值判断器,而是一个能够表达不确定性的推理系统——当分类器"不太确定"时,它选择将决策权交还给常规判定逻辑,而非冒险做出可能错误的判断。
Bash 分类器还支持三种行为类型(ClassifierBehavior):deny(命令匹配了某个拒绝描述)、ask(命令匹配了某个需确认的描述)、allow(命令匹配了某个允许描述)。这使得同一个分类器基础设施可以同时服务于允许规则和拒绝规则的语义匹配。
在 bashPermissions.ts 中,分类器的调用被集成到 Bash 工具的权限检查流程中。一个关键的性能优化是 推测性分类器检查(speculative classifier check)——在权限对话框弹出的同时,异步启动分类器评估。如果分类器在用户做出选择前返回了批准结果,可以自动跳过对话框:
// 文件: src/hooks/toolPermission/handlers/interactiveHandler.ts(简化)
// 异步执行 bash 分类器检查
if (feature('BASH_CLASSIFIER') && result.pendingClassifierCheck) {
setClassifierChecking(ctx.toolUseID)
void executeAsyncClassifierCheck(
result.pendingClassifierCheck,
signal,
isNonInteractiveSession,
{
shouldContinue: () => !isResolved() && !userInteracted,
onComplete: () => { clearClassifierChecking(ctx.toolUseID) },
onAllow: decisionReason => {
if (!claim()) return // 用户已先行响应
// 显示自动批准的过渡动画
ctx.updateQueueItem({
classifierAutoApproved: true,
classifierMatchedRule: matchedRule,
})
resolveOnce(ctx.buildAllow(ctx.input, { decisionReason }))
},
},
)
}
注意 shouldContinue 回调——一旦用户开始与对话框交互(userInteracted 变为 true),分类器的自动批准就会被取消。这体现了"用户意图优先"的设计原则。
9.4.2 Transcript 分类器(auto mode)
Transcript 分类器是 auto 模式的核心决策引擎,实现在 src/utils/permissions/yoloClassifier.ts 中。与 Bash 分类器针对单条命令不同,它会分析完整的对话上下文来决定一个工具调用是否安全。
分类器的工作流程是一个精心设计的两阶段管线:
阶段一(fast stage): 一次输出很短的模型调用,快速给出判定;判定放行则到此结束
阶段二(thinking stage): 阶段一未放行时,再做一次带推理的模型调用复核
两个阶段都是模型调用(2.1.89 的遥测里分别记为 stage1、stage2 的用量与费用);实现上也可以配置只跑其中一个阶段(fast / thinking / both)。
分类器的输出结构包含丰富的遥测信息:
// 文件: src/types/permissions.ts
export type YoloClassifierResult = {
thinking?: string // AI 的思考过程
shouldBlock: boolean // 是否阻止
reason: string // 阻止/放行的理由
unavailable?: boolean // 分类器是否不可用
transcriptTooLong?: boolean // 上下文是否超出限制
model: string // 使用的模型
usage?: ClassifierUsage // Token 用量
durationMs?: number // 延迟
stage?: 'fast' | 'thinking' // 最终决策的阶段
// ... 更多遥测字段
}
在 hasPermissionsToUseTool 函数中,auto 模式的判定逻辑展现了多层优化策略:
// 文件: src/utils/permissions/permissions.ts(简化)
// 优化一: acceptEdits 快速通道
// 如果 acceptEdits 模式会允许此操作,跳过分类器
if (result.behavior === 'ask' &&
tool.name !== AGENT_TOOL_NAME &&
tool.name !== REPL_TOOL_NAME) {
const acceptEditsResult = await tool.checkPermissions(parsedInput, {
...context,
getAppState: () => ({
...state,
toolPermissionContext: { ...state.toolPermissionContext, mode: 'acceptEdits' },
}),
})
if (acceptEditsResult.behavior === 'allow') {
return { behavior: 'allow', ... } // 跳过分类器
}
}
// 优化二: 安全工具白名单
if (classifierDecisionModule.isAutoModeAllowlistedTool(tool.name)) {
return { behavior: 'allow', ... } // 跳过分类器
}
// 优化三: 实际调用分类器
const action = formatActionForClassifier(tool.name, input)
const classifierResult = await classifyYoloAction(
context.messages, action, tools, appState.toolPermissionContext, signal,
)
这三层优化的设计思路是:尽量用廉价的本地判断避免昂贵的 API 调用。只有当前两层都无法确定时,才会调用真正的 AI 分类器。acceptEdits 快速通道和安全工具白名单覆盖了工作目录内的文件编辑、目录创建、只读工具等日常操作,用意是降低分类器的调用频率和由此产生的 token 开销与延迟。
需要特别注意的是,Agent 工具和 REPL 工具被明确排除在 acceptEdits 快速通道之外。原因是这两类工具的 checkPermissions 在 acceptEdits 模式下会返回 allow,但它们的操作可能包含需要分类器评估的安全风险——Agent 工具可能启动具有任意 prompt 的子 Agent(委托攻击),REPL 工具的 JavaScript 代码可能包含 VM 沙箱逃逸。因此必须让分类器对它们进行完整的安全评估。
分类器的结果还包含丰富的遥测元数据:两阶段各自的 token 用量、延迟、请求 ID、以及 prompt 各部分的字符长度。这些数据使得团队可以精确计算分类器的 overhead 占比——即分类器消耗的 token 相对于主对话总 token 的比例。通过记录 sessionInputTokens 和 sessionOutputTokens 等主会话指标,分析师可以计算出形如"分类器开销占会话总成本的 X%"这样的关键指标。
9.4.3 分类器的安全防护
auto 模式下有多重安全防护机制:
不可绕过的安全检查:即使在 auto 模式下,safetyCheck 类型的决策如果标记为 classifierApprovable: false,仍然需要人工审批:
// 文件: src/utils/permissions/permissions.ts
if (result.decisionReason?.type === 'safetyCheck' &&
!result.decisionReason.classifierApprovable) {
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
return { behavior: 'deny', ... }
}
return result // 回退到人工审批
}
危险权限剥离:进入 auto 模式时,isDangerousBashPermission 函数会识别并剥离可能绕过分类器的规则:
// 文件: src/utils/permissions/permissionSetup.ts
export function isDangerousBashPermission(
toolName: string,
ruleContent: string | undefined,
): boolean {
if (toolName !== BASH_TOOL_NAME) return false
if (ruleContent === undefined || ruleContent === '') return true // Bash(*)
if (content === '*') return true
// 检查危险的解释器前缀
for (const pattern of DANGEROUS_BASH_PATTERNS) {
if (content === pattern) return true // python
if (content === `${pattern}:*`) return true // python:*
if (content === `${pattern}*`) return true // python*
// ...
}
return false
}
DANGEROUS_BASH_PATTERNS 列表涵盖了所有可以执行任意代码的解释器和命令,包括 python、node、ruby、eval、exec、ssh 等。这意味着即使用户之前配置了 Bash(python:*) 的允许规则,切换到 auto 模式时也会被自动移除。
分类器不可用时的降级:当分类器 API 调用失败时,系统根据 tengu_iron_gate_closed feature gate 决定是"fail closed"(拒绝并提示重试)还是"fail open"(回退到人工审批):
if (classifierResult.unavailable) {
if (getFeatureValue_CACHED_WITH_REFRESH('tengu_iron_gate_closed', true, ...)) {
// Fail closed: 拒绝操作
return { behavior: 'deny', message: buildClassifierUnavailableMessage(...) }
}
// Fail open: 回退到正常权限处理
return result
}
9.5 权限决策流程
9.5.1 完整决策流程图
以下是从工具调用到最终决策的完整流程:
工具调用 (tool.execute)
|
v
[useCanUseTool Hook]
|
+-- forceDecision? ----YES----> 直接使用预设决策
|
NO
|
v
[hasPermissionsToUseTool] -- 外层包装
|
v
[hasPermissionsToUseToolInner] -- 核心决策管线
|
|== 阶段一: 规则优先判定 ==
|
+-- 1a. 工具级 deny 规则匹配? ----YES----> DENY
|
+-- 1b. 工具级 ask 规则匹配? -----YES----> ASK (除非沙箱可自动放行)
|
+-- 1c. tool.checkPermissions()
| (命令级规则匹配、路径验证、sed约束等)
|
+-- 1d. 工具实现拒绝? -----------YES----> DENY
|
+-- 1e. 工具要求用户交互? --------YES----> ASK
|
+-- 1f. 内容级 ask 规则? ---------YES----> ASK (bypass不可覆盖)
|
+-- 1g. 安全检查(敏感路径)? ------YES----> ASK (bypass不可覆盖)
|
|== 阶段二: 模式判定 ==
|
+-- 2a. bypassPermissions? ------YES----> ALLOW
|
+-- 2b. 工具级 allow 规则匹配? ---YES----> ALLOW
|
+-- 3. 无匹配 -> passthrough 转 ASK
|
v
[hasPermissionsToUseTool 外层] -- 模式后处理
|
+-- allow 时重置拒绝追踪计数
|
+-- ask + dontAsk 模式? ---------> DENY (转换 ask 为 deny)
|
+-- ask + auto 模式? ------------> 进入分类器流程
| |
| +-- safetyCheck 且不可分类器审批? -> DENY/ASK
| +-- acceptEdits 快速通道? --------> ALLOW
| +-- 安全工具白名单? --------------> ALLOW
| +-- 调用 AI 分类器
| | +-- shouldBlock=true -------> DENY (含拒绝追踪)
| | +-- shouldBlock=false ------> ALLOW
| | +-- unavailable ------------> DENY 或回退
| | +-- transcriptTooLong ------> 回退到人工审批
|
+-- ask + shouldAvoidPermissionPrompts? -> 运行 Hook -> DENY
|
v
[返回到 useCanUseTool]
|
+-- behavior === "allow" -> resolve(allow)
|
+-- behavior === "deny" -> resolve(deny) + 通知(auto模式)
|
+-- behavior === "ask" -> 进入处理器链
|
+-- coordinatorHandler: Hook -> 分类器 -> null
|
+-- swarmWorkerHandler: 分类器 -> 邮箱转发 -> null
|
+-- interactiveHandler: 推送 UI 队列
|
竞争: 用户操作 / Bridge / Channel / Hook / 分类器
|
v
resolve(最先完成的决策)
9.5.2 PermissionContext 的设计
PermissionContext 是权限决策流程的执行上下文,定义在 src/hooks/toolPermission/PermissionContext.ts 中。它封装了所有与单次权限判定相关的状态和操作:
// 文件: src/hooks/toolPermission/PermissionContext.ts(核心接口)
const ctx = {
tool, // 工具实例
input, // 工具输入
toolUseContext, // 全局上下文
toolUseID, // 唯一标识
// 日志与分析
logDecision(args),
logCancelled(),
// 权限持久化
persistPermissions(updates),
// 中止检测
resolveIfAborted(resolve),
cancelAndAbort(feedback?, isAbort?, contentBlocks?),
// 分类器集成
tryClassifier(pendingCheck, updatedInput),
// Hook 执行
runHooks(permissionMode, suggestions, updatedInput),
// 决策构建
buildAllow(updatedInput, opts?),
buildDeny(message, decisionReason),
// 用户交互处理
handleUserAllow(updatedInput, permissionUpdates, feedback, ...),
handleHookAllow(finalInput, permissionUpdates, ...),
// UI 队列操作
pushToQueue(item),
removeFromQueue(),
updateQueueItem(patch),
}
return Object.freeze(ctx) // 不可变
Object.freeze 确保上下文在创建后不会被意外修改。ResolveOnce 辅助类型则保证 Promise 的单次 resolve 语义:
// 文件: src/hooks/toolPermission/PermissionContext.ts
function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
let claimed = false
let delivered = false
return {
resolve(value: T) {
if (delivered) return
delivered = true
claimed = true
resolve(value)
},
isResolved() { return claimed },
claim() {
if (claimed) return false
claimed = true
return true
},
}
}
claim() 和 isResolved() 的分离是为了解决一个微妙的竞态问题:在异步回调中,检查是否已 resolve 和实际进行 resolve 之间可能有其他异步操作插入。claim() 将这两步合并为一个原子操作,消除了竞态窗口。
9.6 拒绝追踪与分析
9.6.1 拒绝追踪机制
在 auto 模式下,AI 分类器可能反复拒绝模型的操作请求,导致模型陷入"尝试-被拒-再尝试"的无效循环。拒绝追踪机制(denial tracking)正是为了解决这个问题而设计的。
核心数据结构定义在 src/utils/permissions/denialTracking.ts 中:
// 文件: src/utils/permissions/denialTracking.ts
export type DenialTrackingState = {
consecutiveDenials: number // 连续拒绝次数
totalDenials: number // 总拒绝次数
}
export const DENIAL_LIMITS = {
maxConsecutive: 3, // 连续拒绝上限
maxTotal: 20, // 总拒绝上限
} as const
两个阈值的设计意图不同:maxConsecutive 捕捉的是"模型陷入循环"的模式——连续 3 次被拒绝意味着模型可能在重复尝试同一类被禁止的操作。maxTotal 捕捉的是"整体风险累积"——一个会话中被拒绝 20 次意味着模型可能在系统性地尝试突破安全边界。
状态转换逻辑非常清晰:
export function recordDenial(state: DenialTrackingState): DenialTrackingState {
return {
consecutiveDenials: state.consecutiveDenials + 1,
totalDenials: state.totalDenials + 1,
}
}
export function recordSuccess(state: DenialTrackingState): DenialTrackingState {
if (state.consecutiveDenials === 0) return state // 无变化则返回相同引用
return { ...state, consecutiveDenials: 0 }
}
export function shouldFallbackToPrompting(state: DenialTrackingState): boolean {
return (
state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
state.totalDenials >= DENIAL_LIMITS.maxTotal
)
}
注意 recordSuccess 中的优化:当 consecutiveDenials 已经是 0 时,直接返回原始引用而不创建新对象。这与 React 的 Object.is 浅比较配合,可以避免不必要的重新渲染。
9.6.2 拒绝超限处理
当拒绝次数超过阈值时,系统执行不同的降级策略:
// 文件: src/utils/permissions/permissions.ts
function handleDenialLimitExceeded(
denialState, appState, classifierReason,
assistantMessage, tool, result, context,
): PermissionDecision | null {
if (!shouldFallbackToPrompting(denialState)) return null
const hitTotalLimit = denialState.totalDenials >= DENIAL_LIMITS.maxTotal
const isHeadless = appState.toolPermissionContext.shouldAvoidPermissionPrompts
// 无头模式下直接中止 agent
if (isHeadless) {
throw new AbortError(
'Agent aborted: too many classifier denials in headless mode'
)
}
// 交互模式下回退到人工审批
// 如果是总拒绝上限,重置计数器
if (hitTotalLimit) {
persistDenialState(context, {
...denialState,
totalDenials: 0,
consecutiveDenials: 0,
})
}
return {
...result,
decisionReason: {
type: 'classifier',
classifier: originalClassifier,
reason: `${warning}\n\nLatest blocked action: ${classifierReason}`,
},
}
}
这里有两个关键的设计决策:
第一,无头模式(headless)下超限直接抛出 AbortError 中止整个 agent。因为无头模式没有用户可以介入审批,继续运行只会浪费 token。(这是 2.1.89 的行为;截至 2026-09-24 的官方文档,-p 运行达到阈值时只是不执行该操作,Claude 继续工作,不再中止运行。)
第二,交互模式下将 deny 降级为 ask——把决策权交还给用户。同时在消息中包含最近被阻止的操作理由,帮助用户做出知情的决策。
9.6.3 拒绝记录与通知
被 auto 模式拒绝的操作会被记录到一个全局列表中,供用户在 /permissions 命令中查阅:
// 文件: src/utils/autoModeDenials.ts
export type AutoModeDenial = {
toolName: string
display: string // 人类可读的描述
reason: string // 拒绝理由
timestamp: number
}
let DENIALS: readonly AutoModeDenial[] = []
const MAX_DENIALS = 20
export function recordAutoModeDenial(denial: AutoModeDenial): void {
if (!feature('TRANSCRIPT_CLASSIFIER')) return
DENIALS = [denial, ...DENIALS.slice(0, MAX_DENIALS - 1)]
}
在 useCanUseTool 中,每次 auto 模式的拒绝都会同时触发即时通知:
// 文件: src/hooks/useCanUseTool.tsx
if (result.decisionReason?.type === "classifier" &&
result.decisionReason.classifier === "auto-mode") {
recordAutoModeDenial({
toolName: tool.name,
display: description,
reason: result.decisionReason.reason ?? "",
timestamp: Date.now(),
})
toolUseContext.addNotification?.({
key: "auto-mode-denied",
priority: "immediate",
jsx: <>
<Text color="error">
{tool.userFacingName(input).toLowerCase()} denied by auto mode
</Text>
<Text dimColor> · /permissions</Text>
</>
})
}
2.1.89 的 CHANGELOG 对应两条:被 auto 模式拒绝的命令会弹出通知,并出现在 /permissions 的 Recent 标签页中,可按 r 重试;同版还新增了 PermissionDenied Hook,它只在 auto 模式分类器拒绝后触发,返回 retry: true 只是告诉模型"可以重试",并不会自动重试(见第 7 章)。
9.6.4 分析遥测
权限决策系统包含全面的分析遥测,集中在 src/hooks/toolPermission/permissionLogging.ts 中。每次权限决策都会触发多路日志:
// 文件: src/hooks/toolPermission/permissionLogging.ts
function logPermissionDecision(
ctx: PermissionLogContext,
args: PermissionDecisionArgs,
permissionPromptStartTimeMs?: number,
): void {
// 1. 分析事件(logEvent 上报 tengu_* 事件)
if (args.decision === 'accept') {
logApprovalEvent(tool, messageId, args.source, waitMs)
} else {
logRejectionEvent(tool, messageId, args.source, waitMs)
}
// 2. 代码编辑工具的 OTel 计数器
if (isCodeEditingTool(tool.name)) {
void buildCodeEditToolAttributes(...).then(
attributes => getCodeEditToolDecisionCounter()?.add(1, attributes)
)
}
// 3. 工具决策上下文存储
toolUseContext.toolDecisions.set(toolUseID, {
source: sourceString,
decision,
timestamp: Date.now(),
})
// 4. OpenTelemetry 事件
void logOTelEvent('tool_decision', { decision, source, tool_name })
}
审批事件根据来源细分为多个事件名,便于漏斗分析:tengu_tool_use_granted_in_config(规则自动批准)、tengu_tool_use_granted_in_prompt_permanent(用户永久批准)、tengu_tool_use_granted_in_prompt_temporary(用户临时批准)、tengu_tool_use_granted_by_classifier(分类器批准)、tengu_tool_use_granted_by_permission_hook(Hook 批准)。
对于 auto 模式的分类器,每次决策还会记录详细的 overhead 遥测:
logEvent('tengu_auto_mode_decision', {
decision,
toolName,
classifierModel,
classifierInputTokens,
classifierOutputTokens,
classifierDurationMs,
classifierCostUSD,
classifierStage: classifierResult.stage,
// 与主会话的对比数据
sessionInputTokens: getTotalInputTokens(),
sessionOutputTokens: getTotalOutputTokens(),
// ...
})
这些遥测数据使团队能够精确衡量分类器的 token 开销占比、延迟影响、以及缓存命中率,从而持续优化 auto 模式的性价比。
9.7 多 Agent 场景下的权限
在多 Agent 架构中,权限请求的路由变得更加复杂。下图展示了不同角色的 Agent 如何处理权限决策:
flowchart TB
subgraph Coordinator["Coordinator 模式"]
C_Tool["工具请求权限"] --> C_Hook["coordinatorHandler"]
C_Hook --> C_Auto["自动化检查"]
C_Auto -->|"通过"| C_Allow["ALLOW"]
C_Auto -->|"未决"| C_UI["显示交互对话框"]
end
subgraph SwarmWorker["Swarm Worker 模式"]
W_Tool["工具请求权限"] --> W_Handler["swarmWorkerHandler"]
W_Handler --> W_Classifier["先运行分类器"]
W_Classifier -->|"通过"| W_Allow["ALLOW"]
W_Classifier -->|"未决"| W_Mailbox["信箱转发"]
W_Mailbox --> W_Leader["Leader 读取信箱"]
W_Leader --> W_Response["权限决策回传"]
end
subgraph Headless["无头 Agent (CI/CD)"]
H_Tool["工具请求权限"] --> H_Rules["仅静态规则"]
H_Rules -->|"allow 匹配"| H_Allow["ALLOW"]
H_Rules -->|"其他"| H_Deny["DENY\n无法弹出 UI"]
end
subgraph BridgeAuth["Bridge 远程审批"]
B_Tool["工具请求权限"] --> B_Forward["经 Remote Control 转发"]
B_Forward --> B_IDE["claude.ai 网页/App 审批"]
B_IDE --> B_Response["回传决策"]
end
9.7.1 Swarm Worker 的权限转发
在多 Agent 协作场景中,swarm worker 运行在后台进程中,没有直接的 UI 来展示权限对话框。swarmWorkerHandler.ts 实现了一套优雅的权限委托机制。
当 swarm worker 需要权限审批时,它首先尝试本地的分类器自动批准(对于 Bash 命令;这一步同样受 BASH_CLASSIFIER 控制,公开构建中直接跳过)。如果分类器无法决定,则将权限请求序列化并通过邮箱(mailbox)发送给 leader agent:
// 文件: src/hooks/toolPermission/handlers/swarmWorkerHandler.ts(简化)
// 创建权限请求
const request = createPermissionRequest({
toolName: ctx.tool.name,
toolUseId: ctx.toolUseID,
input: ctx.input,
description,
permissionSuggestions: suggestions,
})
// 注册回调(在发送前注册,避免竞态)
registerPermissionCallback({
requestId: request.id,
toolUseId: ctx.toolUseID,
async onAllow(allowedInput, permissionUpdates, feedback, contentBlocks) {
if (!claim()) return
clearPendingRequest()
resolveOnce(await ctx.handleUserAllow(finalInput, permissionUpdates, ...))
},
onReject(feedback, contentBlocks) {
if (!claim()) return
clearPendingRequest()
resolveOnce(ctx.cancelAndAbort(feedback, undefined, contentBlocks))
},
})
// 发送请求到 leader
await sendPermissionRequestViaMailbox(request)
// 显示等待指示器
ctx.toolUseContext.setAppState(prev => ({
...prev,
pendingWorkerRequest: {
toolName: ctx.tool.name,
toolUseId: ctx.toolUseID,
description,
},
}))
这里的关键时序是:先注册回调,再发送请求。这消除了 leader 在 worker 注册回调之前就已经响应的竞态条件。同时,claim() 机制确保即使存在多个响应通道(分类器和 leader 响应同时到达),也只有一个能成功。
9.7.2 Coordinator 模式的权限处理
coordinatorHandler 用于带 awaitAutomatedChecksBeforeDialog 标志的 Agent(见 9.2.3),它可以在显示交互对话框之前先运行自动化检查。coordinatorHandler.ts 的逻辑是严格顺序化的:
coordinatorHandler 流程:
|
+-- 1. 运行 PermissionRequest Hook(快速、本地)
| +-- Hook 返回 allow/deny? -> 直接使用
|
+-- 2. 运行 Bash 分类器(较慢、需推理;公开构建中为空操作)
| +-- 分类器返回批准? -> 直接使用
|
+-- 3. 都未决定 -> 返回 null
| -> 调用方继续到 interactiveHandler
与 interactiveHandler 中的并行竞争不同,coordinator 是串行执行的。这是因为 coordinator 希望在显示 UI 之前就尽量解决权限问题——如果 Hook 或分类器能决定,用户完全不需要看到对话框。
9.7.3 无头 Agent 的权限处理
对于完全无头的后台 Agent(如 SDK 启动的 agent),既没有 UI 也没有 leader 可以转发。这种情况下的处理策略定义在 hasPermissionsToUseTool 的末尾:
// 文件: src/utils/permissions/permissions.ts
// 当权限提示不可用时(无头/后台 agent)
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
// 先给 Hook 一次机会
const hookDecision = await runPermissionRequestHooksForHeadlessAgent(
tool, input, toolUseID, context,
appState.toolPermissionContext.mode,
result.suggestions,
)
if (hookDecision) return hookDecision
// Hook 也无法决定,则自动拒绝
return {
behavior: 'deny',
decisionReason: {
type: 'asyncAgent',
reason: 'Permission prompts are not available in this context',
},
message: AUTO_REJECT_MESSAGE(tool.name),
}
}
这里的 runPermissionRequestHooksForHeadlessAgent 是最后的安全阀——它允许用户通过配置 PermissionRequest Hook 来为无头场景定制自动化的审批逻辑。如果没有配置任何 Hook,或者 Hook 没有返回决策,则执行安全的"默认拒绝"策略。
9.7.4 Bridge 与 Channel 的远程审批
在交互模式下,权限审批不仅限于本地终端。interactiveHandler.ts 同时启动多个远程审批通道:
Bridge 通道即 Remote Control(claude remote-control / /remote-control)会话,连接到 claude.ai 网页端和移动 App,允许用户在浏览器或手机上审批本地终端的权限请求(它不是 IDE 集成)。请求和响应经这条远程会话连接以 control_request / control_response 消息传输,支持 updatedInput(修改后的输入)和 updatedPermissions(权限规则更新)的双向传递。
Channel 通道连接到 Telegram、iMessage 等即时通讯平台,通过 MCP 协议发送权限请求通知。用户可以在手机上用简短的"yes/no"回复来审批权限。Channel 的通知使用结构化格式,由各平台的 MCP 服务器负责渲染为平台原生的消息样式。
所有远程通道与本地 UI 之间通过 claim() 机制实现原子性竞争——第一个响应胜出,后续响应被静默忽略。
设计决策
为什么采用多阶段流水线而非单一决策函数?
权限判定看似是一个简单的布尔问题,但实际上涉及多个维度的考量:规则优先级、模式特权、安全约束、AI 推理、用户意图。将这些维度拆分为独立的阶段,使得每个阶段的职责清晰,便于单独测试和演进。例如,添加新的安全检查只需在阶段一插入一个新步骤,而不会影响分类器或用户交互逻辑。
为什么 deny 规则不可被任何模式覆盖?
这是一个安全设计的根本原则。如果 bypassPermissions 模式可以覆盖 deny 规则,那么一个被明确禁止的危险操作就可能被意外执行。deny 规则代表的是用户或管理员的"绝对禁止"意图,它应该是整个权限系统中唯一不可妥协的硬约束。
为什么 auto 模式需要剥离危险权限?
Bash(python:*) 这样的规则在 default 模式下是安全的——用户已经通过手动审批表达了信任。但在 auto 模式下,这条规则会在 AI 分类器评估之前就自动放行,等于完全绕过了安全分类。剥离机制确保了 auto 模式的安全评估覆盖所有可能执行任意代码的路径。
为什么使用 ResolveOnce 而非标准的 Promise 竞争?
Promise.race 只是选择最先完成的 Promise,但不会取消其他 Promise 的副作用(如 UI 更新、远程请求)。ResolveOnce 配合 claim() 不仅保证单次 resolve,还通过其返回值告诉失败的竞争者"你输了",使其可以执行适当的清理操作(如取消 Bridge 请求、移除 Channel 订阅)。
为什么拒绝追踪区分"连续"和"总数"两个维度?
连续拒绝反映的是"当前行为模式"——模型可能在重复尝试同一类操作,陷入了无效循环。3 次连续拒绝就足以触发干预,打断这个循环。总拒绝反映的是"累积风险"——即使拒绝不是连续的(中间穿插了成功的操作),20 次拒绝也意味着会话中存在显著的安全张力,模型可能在系统性地试探安全边界,需要人工审视。这两个维度共同构成了对 auto 模式行为的完整监控,分别捕捉"局部循环"和"全局趋势"两种异常模式。
为什么权限规则需要如此多的来源层次?
八种来源对应的是不同的作用范围与生命周期,它们的规则是叠加而非互相覆盖(判定时汇总后按 deny → ask → allow 处理,见 9.3.4)。全局配置(userSettings)为所有项目提供共同的规则;项目配置(projectSettings)可以根据项目特性追加规则(例如,一个 DevOps 项目可能允许 docker 命令,而一个前端项目不需要);本地配置(localSettings)允许开发者的个人偏好不影响团队共享的项目配置;命令行参数(cliArg)提供了一次性的临时规则;会话级规则(session)则是最短暂的——用户在对话框中点击"本次允许"的结果。这种层次化设计使得权限配置既可以在组织层面统一管理,也可以在个人层面灵活定制,同时保持清晰的溯源链路。
为什么交互对话框要同时启动多个审批通道?
在实际使用场景中,用户可能在多种界面之间切换——有时看着终端,有时在浏览器中工作,有时甚至不在电脑旁但手机在手。多通道设计确保权限审批不会成为瓶颈:用户可以从最方便的设备上响应。Bridge 通道让 claude.ai 网页端成为终端的远程控制器;Channel 通道让即时通讯成为移动端的审批入口。按还原源码的设计,这些通道还与本地 Bash 分类器并行竞争——如果 AI 能自信地做出判断,用户甚至不需要在任何设备上操作(公开构建中该分类器已剥离,参与竞争的是 PermissionRequest Hook)。
小结
Claude Code 的权限模型是一个精密的多层决策系统。从本章的分析中,我们可以提炼出几个核心设计理念:
安全分层:deny 规则是硬约束,不可被任何模式覆盖;安全检查(敏感路径保护)是准硬约束,只有标记为 classifierApprovable 的才允许 AI 分类器评估;其余操作的约束强度随模式不同而变化。
渐进式信任:从 plan(只读)到 default(标准)到 acceptEdits(宽松)到 auto(AI 自主)到 bypassPermissions(几乎无限制),每个模式代表一个更高的信任级别。用户可以根据当前任务的风险程度动态调整。
优雅降级:auto 模式下,分类器不可用时按 tengu_iron_gate_closed 开关拒绝(fail closed)或降级为人工审批(fail open);拒绝次数超限时降级为人工审批(无头模式下则中止);无头模式下需要确认的操作降级为 Hook 处理或默认拒绝。每一层降级都有明确的语义和用户反馈。
多通道竞争:权限审批不是单一阻塞操作,而是本地 UI、远程 Bridge、Channel 通知、Hook 自动化、分类器评估多路并行竞争。这确保了权限审批的响应速度取决于最快的通道,而不是最慢的。
可观测性:每次权限决策都通过多路遥测(tengu_* 分析事件、OTel 计数器、上下文存储)进行记录,使得权限系统的行为完全透明、可分析、可优化。
这套权限模型的设计哲学可以用一句话概括:在 AI 自主执行和人类安全监督之间找到动态平衡点,并通过多层防御确保即使某一层失效,整体安全性仍然得到保障。
下一章将深入沙箱机制——权限模型的物理隔离层。如果说权限模型是"软件层面"的安全屏障,那么沙箱就是"操作系统层面"的安全兜底,二者共同构成了 Claude Code 纵深防御体系的完整图景。