问题引入
你愿意让 AI 不经询问就执行 rm -rf / 吗?大概没人愿意。那 git push 呢?这个答案就没那么统一了——有人觉得推送到自己的开发分支完全没问题,有人觉得推送到 main 绝对需要确认。那 cat package.json 呢?如果每次读文件都要点"允许",用户体验会让人抓狂。
每个操作的风险不同,权限边界应该画在哪里?
这不是一个新问题。Unix 的 rwx 权限模型、Android 的运行时权限、浏览器的同源策略——每个平台都在"能力"和"安全"之间找平衡。但 AI 编码工具面临的挑战更复杂:
- 操作空间巨大:不只是读写文件,还有执行命令、网络请求、调用外部服务
- 风险评估需要语义理解:
rm -rf node_modules 和 rm -rf / 在语法上很像,但风险天壤之别
- 用户期望矛盾:既要"自动化"又要"安全",既要"快"又要"问我"
- 多角色协作:主 agent、coordinator worker、swarm worker 的权限需求完全不同
Claude Code 的权限系统用了一个精巧的多层评估流水线来解决这个问题。这篇文章将从源码层面完整剖析它的设计。
权限模式全景
在进入评估流水线之前,我们先看看 Claude Code 定义了哪些权限模式。这些模式决定了系统的"默认姿态"。
模式定义
权限模式在 src/types/permissions.ts 中定义:
1// src/types/permissions.ts, lines 16-38
2export const EXTERNAL_PERMISSION_MODES = [
3 'acceptEdits',
4 'bypassPermissions',
5 'default',
6 'dontAsk',
7 'plan',
8] as const
9
10export type ExternalPermissionMode = (typeof EXTERNAL_PERMISSION_MODES)[number]
11
12export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
13export type PermissionMode = InternalPermissionMode
14
15export const INTERNAL_PERMISSION_MODES = [
16 ...EXTERNAL_PERMISSION_MODES,
17 ...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const)),
18] as const satisfies readonly PermissionMode[]
注意 auto 模式被 feature('TRANSCRIPT_CLASSIFIER') 守卫——这是一个仅限内部的特性门控。bubble 模式则是完全内部的,永远不会出现在用户可配置的选项中。
模式行为矩阵
每个模式的具体行为在 src/utils/permissions/PermissionMode.ts 中配置:
1// src/utils/permissions/PermissionMode.ts, lines 42-91
2const PERMISSION_MODE_CONFIG: Partial<
3 Record<PermissionMode, PermissionModeConfig>
4> = {
5 default: {
6 title: 'Default',
7 shortTitle: 'Default',
8 symbol: '',
9 color: 'text',
10 external: 'default',
11 },
12 plan: {
13 title: 'Plan Mode',
14 shortTitle: 'Plan',
15 symbol: PAUSE_ICON,
16 color: 'planMode',
17 external: 'plan',
18 },
19 acceptEdits: {
20 title: 'Accept edits',
21 shortTitle: 'Accept',
22 symbol: '⏵⏵',
23 color: 'autoAccept',
24 external: 'acceptEdits',
25 },
26 bypassPermissions: {
27 title: 'Bypass Permissions',
28 shortTitle: 'Bypass',
29 symbol: '⏵⏵',
30 color: 'error',
31 external: 'bypassPermissions',
32 },
33 // ...
34}
各模式的语义总结如下:
| 模式 | 语义 | 典型场景 |
|---|
default | 所有非只读操作都需要用户确认 | 日常交互使用 |
plan | 只生成计划,不执行修改操作 | 代码审查、架构讨论 |
acceptEdits | 自动批准文件编辑,但 Bash 命令仍需确认 | 信任模型的编辑能力 |
bypassPermissions | 跳过几乎所有权限检查 | CI/CD 环境、完全信任场景 |
dontAsk | 将所有 ask 转换为 deny | 非交互式环境 |
auto | 使用 AI 分类器自动判断风险 | 内部高级用户 |
这个频谱设计很优雅:从"完全信任"到"完全不信任",用户可以根据场景选择合适的位置。但模式只是第一层——它决定了"默认姿态",具体的权限判断还需要经过多层评估。
多层评估流水线
权限评估的核心入口是 hasPermissionsToUseTool 函数,定义在 src/utils/permissions/permissions.ts。整个流水线可以分为两个大的阶段:静态规则评估(同步、快速)和 动态交互评估(异步、可能涉及用户交互)。
第一阶段:静态规则评估(hasPermissionsToUseToolInner)
1// src/utils/permissions/permissions.ts, lines 1158-1319
2async function hasPermissionsToUseToolInner(
3 tool: Tool,
4 input: { [key: string]: unknown },
5 context: ToolUseContext,
6): Promise<PermissionDecision> {
7 if (context.abortController.signal.aborted) {
8 throw new AbortError()
9 }
10
11 let appState = context.getAppState()
12
13 // 1. Check if the tool is denied
14 // 1a. Entire tool is denied
15 const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
16 if (denyRule) {
17 return {
18 behavior: 'deny',
19 decisionReason: { type: 'rule', rule: denyRule },
20 message: `Permission to use ${tool.name} has been denied.`,
21 }
22 }
23 // ...
24}
完整的静态评估流程按优先级排列如下:
这个顺序的设计有深意:
Deny 优先:无论当前是什么模式,deny 规则总是最先检查。这保证了安全底线——即使你在 bypassPermissions 模式下,显式的 deny 规则依然有效。
安全检查绕过豁免:步骤 1f 和 1g 确保了某些安全检查即使在 bypassPermissions 模式下也无法绕过。对 .git/、.claude/、shell 配置文件的修改始终需要确认。这是"trust but verify"思想的体现——你信任 AI 的能力,但某些操作的后果太严重了。
模式检查在中间:bypassPermissions 模式的检查放在 deny 规则和安全检查之后、allow 规则之前。这意味着 bypass 模式是"跳过正常权限",而不是"跳过一切"。
Passthrough 兜底:如果工具自身的 checkPermissions 返回 passthrough(表示"我不关心这个权限判断"),系统将其转换为 ask,确保没有操作在静默中被允许。
第二阶段:动态交互评估(useCanUseTool 及后续)
当第一阶段返回 ask 时,控制流进入 useCanUseTool 钩子。这里是真正的复杂性所在:
1// src/hooks/useCanUseTool.tsx, lines 28-33
2function useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext) {
3 // ...
4 return async (tool, input, toolUseContext, assistantMessage, toolUseID) =>
5 new Promise(resolve => {
6 const ctx = createPermissionContext(/* ... */)
7 // ...
8 const result = await hasPermissionsToUseTool(tool, input, toolUseContext, ...)
9 // 根据 result.behavior 分流处理
10 })
11}
当 result.behavior === 'ask' 时,系统进入多竞争者模式——Hook、分类器、用户、Bridge(claude.ai 远程)、Channel(Telegram 等渠道)五个来源同时竞争"谁先做出决策"。这部分我们在后续章节详细展开。
规则系统深度解析
三类规则
权限规则分为三类,每类都有独立的来源追踪:
1// src/Tool.ts, lines 123-148
2export type ToolPermissionContext = DeepImmutable<{
3 mode: PermissionMode
4 additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
5 alwaysAllowRules: ToolPermissionRulesBySource
6 alwaysDenyRules: ToolPermissionRulesBySource
7 alwaysAskRules: ToolPermissionRulesBySource
8 isBypassPermissionsModeAvailable: boolean
9 isAutoModeAvailable?: boolean
10 strippedDangerousRules?: ToolPermissionRulesBySource
11 shouldAvoidPermissionPrompts?: boolean
12 awaitAutomatedChecksBeforeDialog?: boolean
13 prePlanMode?: PermissionMode
14}>
ToolPermissionRulesBySource 的类型本质上是 Record<PermissionRuleSource, string[]>。每个来源对应一组规则字符串。规则的来源在 src/utils/permissions/permissions.ts 中定义:
1// src/utils/permissions/permissions.ts, lines 109-114
2const PERMISSION_RULE_SOURCES = [
3 ...SETTING_SOURCES, // localSettings, userSettings, projectSettings, policySettings, flagSettings
4 'cliArg', // 命令行参数
5 'command', // 命令级别
6 'session', // 会话级别
7] as const satisfies readonly PermissionRuleSource[]
七种来源按优先级从高到低:
- policySettings:企业策略(管理员配置,不可覆盖)
- flagSettings:特性标志
- projectSettings:项目级配置(
.claude/settings.json)
- localSettings:本地配置(
.claude/settings.local.json)
- userSettings:用户全局配置(
~/.claude/settings.json)
- cliArg:命令行参数传入
- session:当前会话中用户的临时选择
规则匹配机制
规则的匹配逻辑支持两种粒度:
1// src/utils/permissions/permissions.ts, lines 238-269
2function toolMatchesRule(
3 tool: Pick<Tool, 'name' | 'mcpInfo'>,
4 rule: PermissionRule,
5): boolean {
6 // Rule must not have content to match the entire tool
7 if (rule.ruleValue.ruleContent !== undefined) {
8 return false
9 }
10
11 const nameForRuleMatch = getToolNameForPermissionCheck(tool)
12
13 // Direct tool name match
14 if (rule.ruleValue.toolName === nameForRuleMatch) {
15 return true
16 }
17
18 // MCP server-level permission: rule "mcp__server1" matches tool "mcp__server1__tool1"
19 const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName)
20 const toolInfo = mcpInfoFromString(nameForRuleMatch)
21
22 return (
23 ruleInfo !== null &&
24 toolInfo !== null &&
25 (ruleInfo.toolName === undefined || ruleInfo.toolName === '*') &&
26 ruleInfo.serverName === toolInfo.serverName
27 )
28}
工具级匹配:规则 "Bash" 匹配所有 Bash 工具调用。
内容级匹配:规则 "Bash(prefix:npm install)" 只匹配以 npm install 开头的 Bash 命令。
MCP 服务器级匹配:规则 "mcp__server1" 匹配该服务器下的所有工具。
内容级匹配通过 getRuleByContentsForTool 实现:
1// src/utils/permissions/permissions.ts, lines 362-389
2export function getRuleByContentsForToolName(
3 context: ToolPermissionContext,
4 toolName: string,
5 behavior: PermissionBehavior,
6): Map<string, PermissionRule> {
7 const ruleByContents = new Map<string, PermissionRule>()
8 let rules: PermissionRule[] = []
9 switch (behavior) {
10 case 'allow': rules = getAllowRules(context); break
11 case 'deny': rules = getDenyRules(context); break
12 case 'ask': rules = getAskRules(context); break
13 }
14 for (const rule of rules) {
15 if (
16 rule.ruleValue.toolName === toolName &&
17 rule.ruleValue.ruleContent !== undefined &&
18 rule.ruleBehavior === behavior
19 ) {
20 ruleByContents.set(rule.ruleValue.ruleContent, rule)
21 }
22 }
23 return ruleByContents
24}
这个设计的精妙之处在于:它不是简单的"全允许/全拒绝",而是允许用户对同一个工具的不同操作设置不同的权限级别。你可以允许 Bash(prefix:npm test) 但拒绝 Bash(prefix:npm publish),允许 Bash(prefix:git status) 但要求确认 Bash(prefix:git push)。
规则来源追踪示例
实际运行时,一个规则的生命周期可能是这样的:
流程
用户在交互对话中选择 "Always allow for this project"
→ 生成 PermissionUpdate: { type: 'addRules', destination: 'projectSettings', ... }
→ persistPermissionUpdates 写入 .claude/settings.json
→ applyPermissionUpdates 更新内存中的 ToolPermissionContext
→ 下次匹配时,该规则通过 projectSettings 来源被查找到
ToolPermissionContext 的 DeepImmutable 设计
仔细看 ToolPermissionContext 的类型定义:
1// src/Tool.ts, line 123
2export type ToolPermissionContext = DeepImmutable<{
3 mode: PermissionMode
4 additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
5 alwaysAllowRules: ToolPermissionRulesBySource
6 alwaysDenyRules: ToolPermissionRulesBySource
7 alwaysAskRules: ToolPermissionRulesBySource
8 // ...
9}>
DeepImmutable 是一个递归类型工具,它将对象的所有层级都标记为 readonly。这不是偶然的设计选择——它解决了权限系统中最危险的一类 bug:运行时权限被意外修改。
想象一个场景:
1// 危险的可变设计(Claude Code 不使用这种方式)
2const context = getToolPermissionContext()
3context.mode = 'bypassPermissions' // 直接修改了全局权限模式!
通过 DeepImmutable,任何尝试修改权限上下文的代码都会在编译期报错。要修改权限状态,必须通过 setToolPermissionContext 创建新对象——这保证了权限状态的变更是可追踪的、原子的。
同时,初始化时也有明确的空状态工厂函数:
1// src/Tool.ts, lines 140-148
2export const getEmptyToolPermissionContext: () => ToolPermissionContext =
3 () => ({
4 mode: 'default',
5 additionalWorkingDirectories: new Map(),
6 alwaysAllowRules: {},
7 alwaysDenyRules: {},
8 alwaysAskRules: {},
9 isBypassPermissionsModeAvailable: false,
10 })
默认模式是 default,所有规则为空,bypass 不可用。这是一个"安全默认值"设计——系统启动时处于最严格的状态,需要显式放宽。
文件系统作用域
权限系统不仅检查"你能用什么工具",还检查"你能操作哪些文件"。additionalWorkingDirectories 是这个机制的关键组件。
工作目录边界
默认情况下,Claude Code 的文件操作被限制在当前工作目录(cwd)内。但实际开发中,项目可能跨越多个目录——monorepo 中的多个子项目、共享库目录等。additionalWorkingDirectories 允许用户扩展这个边界。
危险文件和目录保护
即使在工作目录内,某些文件也受到额外保护:
1// src/utils/permissions/filesystem.ts, lines 57-79
2export const DANGEROUS_FILES = [
3 '.gitconfig',
4 '.gitmodules',
5 '.bashrc',
6 '.bash_profile',
7 '.zshrc',
8 '.zprofile',
9 '.profile',
10 '.ripgreprc',
11 '.mcp.json',
12 '.claude.json',
13] as const
14
15export const DANGEROUS_DIRECTORIES = [
16 '.git',
17 '.vscode',
18 '.idea',
19 '.claude',
20] as const
这些文件的共同特点是:修改它们可能导致代码执行或数据泄露。.bashrc 被修改意味着下次打开终端时会执行恶意代码;.gitconfig 被修改可能导致凭证泄露;.mcp.json 被修改可能引入恶意的 MCP 服务器。
即使在 bypassPermissions 或 auto 模式下,对这些路径的修改也必须经过用户确认(步骤 1g 的安全检查)。这是整个权限系统中唯一不可配置的硬约束。
PermissionContext 与 ResolveOnce 原子性模式
当权限评估进入交互阶段,系统面临一个经典的并发问题:多个异步来源可能同时做出权限决策。PermissionContext 和 ResolveOnce 模式是解决这个问题的核心机制。
createPermissionContext
createPermissionContext 在 src/hooks/toolPermission/PermissionContext.ts 中定义,它创建一个封装了所有权限操作的上下文对象:
1// src/hooks/toolPermission/PermissionContext.ts, lines 96-347
2function createPermissionContext(
3 tool: ToolType,
4 input: Record<string, unknown>,
5 toolUseContext: ToolUseContext,
6 assistantMessage: AssistantMessage,
7 toolUseID: string,
8 setToolPermissionContext: (context: ToolPermissionContext) => void,
9 queueOps?: PermissionQueueOps,
10) {
11 const ctx = {
12 tool,
13 input,
14 toolUseContext,
15 assistantMessage,
16 messageId: assistantMessage.message.id,
17 toolUseID,
18 logDecision(args, opts?) { /* ... */ },
19 logCancelled() { /* ... */ },
20 async persistPermissions(updates) { /* ... */ },
21 resolveIfAborted(resolve) { /* ... */ },
22 cancelAndAbort(feedback?, isAbort?, contentBlocks?) { /* ... */ },
23 async tryClassifier(pendingClassifierCheck, updatedInput) { /* ... */ },
24 async runHooks(permissionMode, suggestions, updatedInput?, startMs?) { /* ... */ },
25 buildAllow(updatedInput, opts?) { /* ... */ },
26 buildDeny(message, decisionReason) { /* ... */ },
27 async handleUserAllow(updatedInput, permissionUpdates, ...) { /* ... */ },
28 async handleHookAllow(finalInput, permissionUpdates, ...) { /* ... */ },
29 pushToQueue(item) { queueOps?.push(item) },
30 removeFromQueue() { queueOps?.remove(toolUseID) },
31 updateQueueItem(patch) { queueOps?.update(toolUseID, patch) },
32 }
33 return Object.freeze(ctx)
34}
注意最后一行 Object.freeze(ctx)——上下文对象被冻结,不可修改。这与 DeepImmutable 的设计哲学一脉相承:权限相关的对象应该是不可变的。
ResolveOnce:原子性决策保证
多个来源竞争做出权限决策时,最危险的是"双重决策"——Hook 批准了操作,与此同时用户也点了"拒绝"。如果两个决策都被执行,系统状态会混乱。
ResolveOnce 通过一个简洁的原子性模式解决了这个问题:
1// src/hooks/toolPermission/PermissionContext.ts, lines 63-94
2type ResolveOnce<T> = {
3 resolve(value: T): void
4 isResolved(): boolean
5 claim(): boolean
6}
7
8function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
9 let claimed = false
10 let delivered = false
11 return {
12 resolve(value: T) {
13 if (delivered) return
14 delivered = true
15 claimed = true
16 resolve(value)
17 },
18 isResolved() {
19 return claimed
20 },
21 claim() {
22 if (claimed) return false
23 claimed = true
24 return true
25 },
26 }
27}
这里有两个标志位 claimed 和 delivered,它们的区别很重要:
claimed:表示"有人声称了决策权"。一旦被设置,其他竞争者的 claim() 调用会返回 false。
delivered:表示"Promise 已经被 resolve"。防止 resolve 被多次调用。
为什么需要两个标志而不是一个?因为在异步回调中,claim() 和 resolve() 之间可能有 await:
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 159-161
2async onAllow(updatedInput, permissionUpdates, feedback?, contentBlocks?) {
3 if (!claim()) return // 原子检查:如果其他来源已决策,立即退出
4 // ↑ 从这里到下面的 resolveOnce 之间有 await
5 resolveOnce(
6 await ctx.handleUserAllow(updatedInput, permissionUpdates, ...)
7 )
8}
如果只用 delivered 一个标志,两个回调可能都通过 !delivered 检查,然后都执行 await ctx.handleUserAllow,导致双重处理。claim() 的原子性检查关闭了这个窗口。
三种权限处理器
权限系统的交互阶段由三个专门的处理器负责,分别对应三种不同的运行场景。
interactiveHandler:主 agent 的交互式处理
这是最复杂的处理器,因为它需要协调最多的竞争来源。定义在 src/hooks/toolPermission/handlers/interactiveHandler.ts。
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 57-60
2function handleInteractivePermission(
3 params: InteractivePermissionParams,
4 resolve: (decision: PermissionDecision) => void,
5): void {
注意返回类型是 void,不是 Promise。这个函数不等待决策完成——它设置好所有回调后立即返回。决策通过回调异步发生。
竞争来源包括:
- 用户交互(onAllow / onReject / onAbort)
- Hook 异步执行(runHooks)
- Bash 分类器(executeAsyncClassifierCheck)
- Bridge 远程响应(来自 claude.ai 的批准/拒绝)
- Channel 响应(来自 Telegram/iMessage 等渠道的批准/拒绝)
一个值得注意的细节是分类器的用户交互保护机制:
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 108-122
2onUserInteraction() {
3 // Grace period: ignore interactions in the first 200ms to prevent
4 // accidental keypresses from canceling the classifier prematurely
5 const GRACE_PERIOD_MS = 200
6 if (Date.now() - permissionPromptStartTimeMs < GRACE_PERIOD_MS) {
7 return
8 }
9 userInteracted = true
10 clearClassifierChecking(ctx.toolUseID)
11 clearClassifierIndicator()
12},
当用户开始与权限对话框交互(按方向键、Tab 键、打字)时,分类器的自动批准会被取消。但有一个 200ms 的宽限期——防止用户在对话框刚弹出时的无意按键取消了分类器。这种对用户体验的精细打磨体现了工程团队的经验。
coordinatorHandler:coordinator worker 的串行预检
Coordinator worker(协调器子 agent)的处理逻辑更简单。由于它运行在主 agent 的上下文中但不能直接显示 UI,它先串行执行自动化检查,只有都不能决策时才回退到交互式对话:
1// src/hooks/toolPermission/handlers/coordinatorHandler.ts, lines 26-62
2async function handleCoordinatorPermission(
3 params: CoordinatorPermissionParams,
4): Promise<PermissionDecision | null> {
5 const { ctx, updatedInput, suggestions, permissionMode } = params
6
7 try {
8 // 1. Try permission hooks first (fast, local)
9 const hookResult = await ctx.runHooks(
10 permissionMode, suggestions, updatedInput,
11 )
12 if (hookResult) return hookResult
13
14 // 2. Try classifier (slow, inference -- bash only)
15 const classifierResult = feature('BASH_CLASSIFIER')
16 ? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
17 : null
18 if (classifierResult) return classifierResult
19 } catch (error) {
20 if (error instanceof Error) {
21 logError(error)
22 } else {
23 logError(new Error(`Automated permission check failed: ${String(error)}`))
24 }
25 }
26
27 // 3. Neither resolved -- fall through to dialog.
28 return null
29}
关键区别:interactive handler 让 Hook 和分类器与用户并行竞争;coordinator handler 让它们串行执行,在展示对话框之前完成。这是因为 coordinator worker 的 awaitAutomatedChecksBeforeDialog 标志为 true——它的设计哲学是"先让自动化系统尝试解决,解决不了再打扰用户"。
swarmWorkerHandler:swarm worker 的邮箱转发
Swarm worker(集群工作节点)是最特殊的角色——它们不能直接与用户交互,也不能直接显示权限对话框。它们的策略是:先尝试分类器自动批准,不行就把权限请求转发给 leader:
1// src/hooks/toolPermission/handlers/swarmWorkerHandler.ts, lines 40-156
2async function handleSwarmWorkerPermission(
3 params: SwarmWorkerPermissionParams,
4): Promise<PermissionDecision | null> {
5 if (!isAgentSwarmsEnabled() || !isSwarmWorker()) {
6 return null // 不是 swarm 环境,返回 null 让调用者回退到交互式处理
7 }
8
9 // 先尝试分类器自动批准
10 const classifierResult = feature('BASH_CLASSIFIER')
11 ? await ctx.tryClassifier?.(params.pendingClassifierCheck, updatedInput)
12 : null
13 if (classifierResult) return classifierResult
14
15 // 转发权限请求到 leader
16 const decision = await new Promise<PermissionDecision>(resolve => {
17 const { resolve: resolveOnce, claim } = createResolveOnce(resolve)
18
19 const request = createPermissionRequest({
20 toolName: ctx.tool.name,
21 toolUseId: ctx.toolUseID,
22 input: ctx.input,
23 description,
24 permissionSuggestions: suggestions,
25 })
26
27 // 先注册回调,再发送请求——避免竞态条件
28 registerPermissionCallback({
29 requestId: request.id,
30 toolUseId: ctx.toolUseID,
31 async onAllow(allowedInput, permissionUpdates, feedback?, contentBlocks?) {
32 if (!claim()) return
33 // ...
34 },
35 onReject(feedback?, contentBlocks?) {
36 if (!claim()) return
37 // ...
38 },
39 })
40
41 // 发送请求
42 void sendPermissionRequestViaMailbox(request)
43
44 // 显示等待指示器
45 ctx.toolUseContext.setAppState(prev => ({
46 ...prev,
47 pendingWorkerRequest: { toolName: ctx.tool.name, toolUseId: ctx.toolUseID, description },
48 }))
49
50 // abort 信号处理
51 ctx.toolUseContext.abortController.signal.addEventListener('abort', () => {
52 if (!claim()) return
53 resolveOnce(ctx.cancelAndAbort(undefined, true))
54 }, { once: true })
55 })
56
57 return decision
58}
这里有一个很优雅的竞态条件防护:先注册回调,再发送请求。如果顺序反过来,可能出现这样的场景:
- Worker 发送权限请求到 leader
- Leader 瞬间响应
- 响应到达时回调还没注册
- 响应被丢弃
通过先注册后发送,即使 leader 的响应在 sendPermissionRequestViaMailbox 返回之前就到达,回调也已经就位,可以处理响应。
三种处理器的调度逻辑
在 useCanUseTool.tsx 中,三种处理器按如下顺序被调度:
1// src/hooks/useCanUseTool.tsx, lines 94-168
2case "ask": {
3 // 1. Coordinator 预检(如果 awaitAutomatedChecksBeforeDialog)
4 if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) {
5 const coordinatorDecision = await handleCoordinatorPermission({...})
6 if (coordinatorDecision) {
7 resolve(coordinatorDecision)
8 return
9 }
10 }
11
12 // 2. Swarm worker 处理(如果是 swarm 环境)
13 const swarmDecision = await handleSwarmWorkerPermission({...})
14 if (swarmDecision) {
15 resolve(swarmDecision)
16 return
17 }
18
19 // 3. 交互式处理(主 agent 的兜底)
20 handleInteractivePermission({
21 ctx, description, result,
22 awaitAutomatedChecksBeforeDialog: ...,
23 bridgeCallbacks: ...,
24 channelCallbacks: ...,
25 }, resolve)
26 return
27}
这是一个经典的责任链模式:每个处理器要么返回一个决策(非 null),要么返回 null 让下一个处理器接手。最后的 handleInteractivePermission 是兜底——它总是能处理请求(通过显示 UI)。
Auto 模式与 AI 分类器
auto 模式是权限系统中最前沿的部分。它不是简单地允许或拒绝所有操作,而是使用 AI 分类器来评估每个操作的风险。
分类器评估流程
当模式为 auto 时,hasPermissionsToUseTool 在返回 ask 之前会经过一系列快速路径检查:
1// src/utils/permissions/permissions.ts, lines 519-648(简化)
2if (appState.toolPermissionContext.mode === 'auto') {
3 // 快速路径 1: 安全检查不可被分类器绕过
4 if (result.decisionReason?.type === 'safetyCheck' && !result.decisionReason.classifierApprovable) {
5 return result // 保持 ask
6 }
7
8 // 快速路径 2: 需要用户交互的工具
9 if (tool.requiresUserInteraction?.()) {
10 return result
11 }
12
13 // 快速路径 3: acceptEdits 模式下会允许的操作
14 const acceptEditsResult = await tool.checkPermissions(parsedInput, {
15 ...context,
16 getAppState: () => ({
17 ...state,
18 toolPermissionContext: { ...state.toolPermissionContext, mode: 'acceptEdits' },
19 }),
20 })
21 if (acceptEditsResult.behavior === 'allow') {
22 return { behavior: 'allow', ... } // 直接允许,无需分类器
23 }
24
25 // 快速路径 4: 安全工具白名单
26 if (classifierDecisionModule.isAutoModeAllowlistedTool(tool.name)) {
27 return { behavior: 'allow', ... }
28 }
29
30 // 最后: 调用分类器 API
31 // ...
32}
这个设计体现了"渐进式信任"的理念:
- 硬安全检查永远不可绕过
- 如果
acceptEdits 模式认为安全,那 auto 模式也应该认为安全——避免了不必要的分类器 API 调用
- 白名单工具(如只读工具)直接放行
- 只有真正需要判断的操作才发送给分类器
连续拒绝追踪
auto 模式还有一个连续拒绝追踪机制(denialTracking),当分类器连续拒绝多个操作时,系统会回退到交互式提示。这防止了分类器过于保守导致工作流完全卡住的情况。
钩子(Hook)预审机制
权限钩子是 Claude Code 可扩展性的关键。用户可以配置自定义的 PermissionRequest 钩子,在标准权限检查之外添加额外的逻辑。
钩子在流水线中的位置
钩子的执行时机取决于运行模式:
- 交互式模式:钩子与用户对话框并行竞争(fire-and-forget 异步)
- Coordinator 模式:钩子在对话框显示之前串行执行
- Swarm 模式:钩子不直接参与(由 leader 侧执行)
1// src/hooks/toolPermission/PermissionContext.ts, lines 216-263
2async runHooks(
3 permissionMode, suggestions, updatedInput?, permissionPromptStartTimeMs?,
4): Promise<PermissionDecision | null> {
5 for await (const hookResult of executePermissionRequestHooks(
6 tool.name, toolUseID, input, toolUseContext,
7 permissionMode, suggestions, toolUseContext.abortController.signal,
8 )) {
9 if (hookResult.permissionRequestResult) {
10 const decision = hookResult.permissionRequestResult
11 if (decision.behavior === 'allow') {
12 return await this.handleHookAllow(finalInput, decision.updatedPermissions ?? [], ...)
13 } else if (decision.behavior === 'deny') {
14 // Hook 还可以选择 interrupt: true 来中止整个会话
15 if (decision.interrupt) {
16 toolUseContext.abortController.abort()
17 }
18 return this.buildDeny(decision.message || 'Permission denied by hook', ...)
19 }
20 }
21 }
22 return null // 没有 hook 做出决策
23}
钩子可以做三件事:
- 允许(
behavior: 'allow'):跳过用户确认,直接执行。可以附带 updatedPermissions 持久化新的规则。
- 拒绝(
behavior: 'deny'):阻止执行。可以设置 interrupt: true 来中止整个会话。
- 不做决策(不返回或跳过):让其他机制继续处理。
无头 agent 的钩子处理
对于 shouldAvoidPermissionPrompts 为 true 的无头 agent(后台运行、没有 UI 的 agent),钩子是唯一的自动化批准途径。如果钩子不做决策,操作会被自动拒绝:
1// src/utils/permissions/permissions.ts, lines 400-470
2async function runPermissionRequestHooksForHeadlessAgent(
3 tool, input, toolUseID, context, permissionMode, suggestions,
4): Promise<PermissionDecision | null> {
5 try {
6 for await (const hookResult of executePermissionRequestHooks(
7 tool.name, toolUseID, input, context,
8 permissionMode, suggestions, context.abortController.signal,
9 )) {
10 if (!hookResult.permissionRequestResult) continue
11 const decision = hookResult.permissionRequestResult
12 if (decision.behavior === 'allow') {
13 // 持久化更新、返回 allow
14 return { behavior: 'allow', updatedInput: finalInput, decisionReason: { type: 'hook', ... } }
15 }
16 if (decision.behavior === 'deny') {
17 return { behavior: 'deny', message: ..., decisionReason: { type: 'hook', ... } }
18 }
19 }
20 } catch (error) {
21 logError(new Error('PermissionRequest hook failed for headless agent', { cause: toError(error) }))
22 }
23 return null // 调用者会执行 auto-deny
24}
权限队列与 React 状态桥接
权限对话框不是简单的 window.confirm——它是一个完整的 React 组件,支持编辑输入、选择持久化选项、提供反馈等。权限系统通过 PermissionQueueOps 接口与 React 状态对接:
1// src/hooks/toolPermission/PermissionContext.ts, lines 357-379
2function createPermissionQueueOps(
3 setToolUseConfirmQueue: React.Dispatch<React.SetStateAction<ToolUseConfirm[]>>,
4): PermissionQueueOps {
5 return {
6 push(item: ToolUseConfirm) {
7 setToolUseConfirmQueue(queue => [...queue, item])
8 },
9 remove(toolUseID: string) {
10 setToolUseConfirmQueue(queue =>
11 queue.filter(item => item.toolUseID !== toolUseID),
12 )
13 },
14 update(toolUseID: string, patch: Partial<ToolUseConfirm>) {
15 setToolUseConfirmQueue(queue =>
16 queue.map(item =>
17 item.toolUseID === toolUseID ? { ...item, ...patch } : item,
18 ),
19 )
20 },
21 }
22}
这是一个优雅的"桥接"设计——权限逻辑完全不依赖 React。PermissionQueueOps 是一个泛型接口,任何能提供 push/remove/update 操作的系统都可以替换 React 实现。这也意味着权限系统可以被移植到其他 UI 框架或完全无 UI 的环境中。
recheckPermission:权限热更新
权限对话框还有一个特殊的回调 recheckPermission,允许在对话框显示期间重新评估权限:
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 204-231
2async recheckPermission() {
3 if (isResolved()) return
4 const freshResult = await hasPermissionsToUseTool(
5 ctx.tool, ctx.input, ctx.toolUseContext, ctx.assistantMessage, ctx.toolUseID,
6 )
7 if (freshResult.behavior === 'allow') {
8 if (!claim()) return
9 if (bridgeCallbacks && bridgeRequestId) {
10 bridgeCallbacks.cancelRequest(bridgeRequestId)
11 }
12 channelUnsubscribe?.()
13 ctx.removeFromQueue()
14 ctx.logDecision({ decision: 'accept', source: 'config' })
15 resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
16 }
17},
这解决了一个实际场景:用户在 claude.ai(Bridge)上切换了权限模式(比如从 default 切到 bypassPermissions),CLI 端正在显示的权限对话框应该立即消失,操作自动继续。recheckPermission 在 mode switch 事件触发时被调用,重新评估权限,如果新模式允许该操作,就自动批准并关闭对话框。
分类器自动批准的视觉反馈
当分类器在用户做出决策之前自动批准了操作,交互式处理器会显示一个短暂的对号标记:
1// src/hooks/toolPermission/handlers/interactiveHandler.ts, lines 469-521
2onAllow: decisionReason => {
3 if (!claim()) return
4 // ...
5
6 // 显示自动批准的过渡动画
7 if (feature('TRANSCRIPT_CLASSIFIER')) {
8 ctx.updateQueueItem({
9 classifierCheckInProgress: false,
10 classifierAutoApproved: true,
11 classifierMatchedRule: matchedRule,
12 })
13 }
14
15 // 保持对号可见一段时间,然后移除对话框
16 // 终端聚焦时 3 秒,不聚焦时 1 秒
17 // 用户可以按 Esc 提前关闭(通过 onDismissCheckmark)
18 const checkmarkMs = getTerminalFocused() ? 3000 : 1000
19 checkmarkTransitionTimer = setTimeout(() => {
20 ctx.removeFromQueue()
21 }, checkmarkMs)
22},
这个设计考虑了用户感知:
- 终端聚焦时:用户可能在看屏幕,给 3 秒让他们注意到操作被自动批准了
- 终端不聚焦时:用户不在看,1 秒就够了
- 可手动关闭:按 Esc 立即关闭,不阻塞工作流
- abort 安全:如果在对号显示期间发生了 sibling abort(比如另一个工具失败),对号对话框也会被正确清理
可迁移模式:为 AI 应用构建分层权限系统
Claude Code 的权限系统设计可以被提炼为一套通用的 AI 应用权限架构模式。以下是关键的设计原则及其对应的实现策略。
原则 1:分层评估,Deny 优先
流程
Deny 规则 → 安全检查 → 模式检查 → Allow 规则 → 工具自身判断 → 默认 Ask
每一层只做一件事,评估顺序固定。deny 规则在最前面确保安全底线不可被绕过。这个模式可以直接应用到任何需要权限控制的 AI 应用中。
原则 2:不可变状态 + 原子性决策
权限状态用 DeepImmutable 保护,决策过程用 ResolveOnce 保证原子性。当你的系统有多个异步来源可能同时做出决策时(用户、自动化系统、远程审批),claim() 模式是一个轻量级但可靠的解决方案。
原则 3:安全默认值 + 显式放宽
1// 默认状态:最严格
2const empty = {
3 mode: 'default',
4 alwaysAllowRules: {},
5 alwaysDenyRules: {},
6 alwaysAskRules: {},
7 isBypassPermissionsModeAvailable: false,
8}
系统启动时处于"最安全"状态。每一次放宽都需要显式操作——用户点击"Always allow"、管理员配置策略、命令行参数传入。这确保了安全性不会因为配置遗漏而被意外降低。
原则 4:规则来源追踪
每条规则都携带来源信息(哪个配置文件、哪个层级)。这不仅用于优先级排序,更用于审计——当出现权限问题时,你可以准确知道"这条规则是从哪里来的"。
1type PermissionRule = {
2 source: PermissionRuleSource // 'projectSettings' | 'userSettings' | ...
3 ruleBehavior: 'allow' | 'deny' | 'ask'
4 ruleValue: PermissionRuleValue // { toolName: string, ruleContent?: string }
5}
原则 5:处理器分离
不同的运行环境有不同的权限需求。Claude Code 的三种处理器模式——交互式(竞争式)、coordinator(串行预检)、swarm(邮箱转发)——展示了如何为同一套规则系统适配不同的执行环境。
核心抽象是 PermissionContext:它封装了所有权限操作(日志、持久化、队列管理),让处理器只关注流程控制。新增一种运行环境时,只需要实现一个新的处理器函数,而不需要修改规则评估逻辑。
原则 6:渐进式信任
auto 模式的分类器不是直接对所有操作调用 AI 评估——它先用快速路径过滤掉明显安全和明显危险的操作:
流程
安全检查(不可绕过)→ acceptEdits 快速路径 → 白名单快速路径 → 分类器 API
每增加一层快速路径,就减少一批不必要的 API 调用。这个模式对任何使用 AI 做运行时决策的系统都适用。
实际架构建议
如果你在构建一个 AI 应用的权限系统,可以从以下最小架构开始:
1// 最小权限系统骨架
2type PermissionDecision = { behavior: 'allow' | 'deny' | 'ask' }
3
4type PermissionRule = {
5 source: string
6 behavior: 'allow' | 'deny' | 'ask'
7 pattern: string // 匹配工具名或操作内容
8}
9
10// 1. 静态评估
11function evaluateStaticRules(
12 action: string,
13 rules: PermissionRule[],
14): PermissionDecision | null {
15 // Deny 优先
16 const denyMatch = rules.find(r => r.behavior === 'deny' && matches(action, r.pattern))
17 if (denyMatch) return { behavior: 'deny' }
18
19 // Allow 匹配
20 const allowMatch = rules.find(r => r.behavior === 'allow' && matches(action, r.pattern))
21 if (allowMatch) return { behavior: 'allow' }
22
23 return null // 没有规则匹配,交给动态评估
24}
25
26// 2. 动态评估(可扩展)
27async function evaluateDynamic(
28 action: string,
29 handlers: PermissionHandler[],
30): Promise<PermissionDecision> {
31 for (const handler of handlers) {
32 const decision = await handler.evaluate(action)
33 if (decision) return decision
34 }
35 return { behavior: 'ask' } // 兜底:询问用户
36}
然后根据需求逐步添加:
- 不可变状态保护:防止运行时修改
- 原子性竞争:当有多个异步决策源时
- 来源追踪:当需要审计或调试时
- 分类器集成:当操作空间太大无法用规则覆盖时
- 处理器分离:当有多种运行环境时
总结
Claude Code 的权限系统是一个多层防御体系,它的核心洞察是:权限不是一个二元选择(允许/拒绝),而是一个在多个维度上的连续频谱。
- 模式维度:从
bypassPermissions 到 dontAsk,用户选择自己的风险偏好
- 规则维度:从整体工具级到内容级,支持精细的权限控制
- 来源维度:从策略到会话,多层配置互相叠加
- 角色维度:主 agent、coordinator、swarm worker 各有不同的处理流程
- 时间维度:分类器、Hook、用户交互在时间轴上竞争,第一个做出决策的获胜
这个系统的工程实现中有很多值得学习的模式:DeepImmutable 保护状态安全、ResolveOnce 保证原子性、处理器分离保证可扩展性、渐进式快速路径减少不必要的计算。这些模式并不局限于权限系统——任何涉及多来源异步决策的系统都可以借鉴。
最后,回到开头的问题:权限边界应该画在哪里?Claude Code 的答案是——不画一条固定的线,而是提供一套工具让每个用户画自己的线。这也许是目前 AI 工具安全领域最务实的解法。