Claude Code设计与实现-第9章 多模式权限模型

《Claude Code 设计与实现》完整目录

第9章 多模式权限模型

开篇引言

在一个能够读写文件、执行 Shell 命令、启动子进程的 AI 编程助手中,权限控制是安全的最后一道防线。Claude Code 面临的核心挑战是:如何在保障安全的前提下,尽可能减少用户的审批疲劳?

传统的权限模型往往走向两个极端:要么对每一步操作都弹出确认对话框,导致用户体验极度割裂;要么一旦授权就不再过问,留下巨大的安全隐患。Claude Code 选择了第三条路径------一个多层次、多维度的权限决策框架。它融合了静态规则匹配、动态 AI 分类器、多种权限模式、以及对拒绝行为的智能追踪,构建出一套既灵活又安全的权限治理体系。

本章将从源码层面深入剖析这套权限模型的完整设计。我们将看到,一个看似简单的"是否允许执行"的判断,背后是一条精心编排的多阶段决策流水线。从静态规则的解析匹配,到动态分类器的语义推理,再到多通道竞争的用户交互,每一个环节都经过了深思熟虑的工程设计。

理解这套权限模型,不仅有助于我们更好地使用 Claude Code,也为我们在其他 AI 系统中设计类似的安全治理机制提供了一个极具参考价值的工程范本。

本章要点

  • Claude Code 定义了七种权限模式(default、acceptEdits、plan、bypassPermissions、dontAsk、auto、bubble),每种模式对应不同的安全与效率平衡点
  • useCanUseTool Hook 是权限系统的中枢调度器,将静态规则判定、动态分类器评估、用户交互三个阶段串联为一条完整的决策流水线
  • 静态规则系统采用三类规则(allowRules、denyRules、alwaysAskRules)与多层来源优先级机制,实现细粒度的权限声明
  • Bash 分类器对命令进行语义级安全分析,Transcript 分类器(auto mode)基于完整对话上下文进行 AI 推理决策
  • 拒绝追踪机制通过连续拒绝计数和总拒绝计数,在自动模式下实现"安全降级"------超过阈值后自动回退到人工审批
  • 多 Agent 场景下,swarm worker 通过邮箱机制将权限请求转发给 leader,coordinator 则在显示交互对话框前先行运行自动化检查

9.1 权限模式总览

Claude Code 的七种权限模式构成了一个从最严格到最宽松的安全频谱,下图展示了各模式之间的信任层级关系:

flowchart LR subgraph External["外部模式 (用户可配置)"] plan["plan\n只读/规划"] default["default\n逐一确认"] acceptEdits["acceptEdits\n自动批准编辑"] dontAsk["dontAsk\n静默拒绝"] bypass["bypassPermissions\n绕过权限"] end subgraph Internal["内部模式"] auto["auto\nAI 分类器决策"] 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 中,分为外部模式和内部模式两个层次:

typescript 复制代码
// 文件: 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

这里有一个精妙的分层设计:外部模式(ExternalPermissionMode)是面向用户的、可以在设置文件和 CLI 参数中指定的模式;内部模式(InternalPermissionMode)则额外包含了 autobubble 两个仅在特定构建条件下存在的模式。auto 模式依赖 TRANSCRIPT_CLASSIFIER 功能开关,仅在 Anthropic 内部版本中可用。

为什么要区分内部模式和外部模式?核心原因是功能门控(feature gating)和构建时消除(dead code elimination)。Bun 的打包器会在构建时评估 feature() 调用,对于外部发布版本,TRANSCRIPT_CLASSIFIER 返回 false,因此 auto 模式相关的代码在编译阶段就被完全移除,不会增加外部版本的包体积。PERMISSION_MODES 常量使用 satisfies 类型约束确保运行时数组与编译时类型保持一致,这是 TypeScript 高级类型编程的一个典型应用。

9.1.2 各模式的行为语义

每种权限模式在 src/utils/permissions/PermissionMode.ts 中配置了对应的显示信息:

typescript 复制代码
// 文件: 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',
  },
  // ...
}

以下是各模式的核心行为差异:

default 模式 是最标准的工作模式,也是大多数用户日常使用的模式。每次工具调用都会经过完整的权限检查流程:先匹配静态规则(deny/ask/allow),然后根据工具自身的 checkPermissions 方法判断,最后对未匹配的操作弹出用户确认对话框。在这个模式下,用户对每一个未被规则覆盖的操作都拥有完整的审批权。系统会展示操作详情、提供"始终允许"选项(生成持久化的 allow 规则),以及"拒绝并提供反馈"选项。这是安全性与可用性的基础平衡点。

acceptEdits 模式 是 default 的宽松变体,专为代码编写密集型任务设计。它在 default 的基础上,自动放行文件编辑类操作(Edit、Write、NotebookEdit 工具在工作目录内的修改)以及特定的文件系统 Bash 命令。这大幅减少了日常编码场景中的权限弹窗数量,同时仍然对网络请求、进程管理等高风险操作保持审批要求。在 src/tools/BashTool/modeValidation.ts 中,可以看到被自动允许的命令列表:

typescript 复制代码
// 文件: src/tools/BashTool/modeValidation.ts

const ACCEPT_EDITS_ALLOWED_COMMANDS = [
  'mkdir', 'touch', 'rm', 'rmdir', 'mv', 'cp', 'sed',
] as const

plan 模式 是一种"只读规划"模式。在此模式下,Claude 只进行思考和规划,不实际执行任何修改操作。这对于复杂任务的前期分析阶段非常有用------用户可以先让 Claude 输出一个详细的执行计划,确认方案合理后再切换到执行模式。plan 模式还有一个特殊行为:如果用户进入 plan 模式之前是 bypassPermissions 模式,那么退出 plan 模式时会恢复到 bypassPermissions 模式而非 default 模式,这通过 prePlanMode 字段来记录。

bypassPermissions 模式 跳过绝大多数权限检查,使 Claude 能够不受中断地连续执行操作。然而,"绕过"并非"无视"------它仍然尊重三类不可绕过的安全约束:deny 规则(步骤 1a 的明确拒绝)、content-specific ask 规则(步骤 1f,如 Bash(npm publish:*) 这类用户刻意设置的审批点)、以及安全检查(步骤 1g,对 .git/.claude/.vscode/、shell 配置文件等敏感路径的保护)。这一设计确保了即使在最宽松的模式下,核心安全底线仍然不可突破。此模式需要用户通过 --dangerously-skip-permissions 命令行参数显式启用,名称中的"dangerously"前缀本身就是一种风险提示。

dontAsk 模式 将所有本应弹出用户确认的 ask 决策自动转换为 deny。这意味着 Claude 不会中断用户的工作流,但也不会执行任何未被规则明确允许的操作。这个模式非常适合 CI/CD 等自动化管道场景------在这些场景中没有人可以回答权限弹窗,与其让进程挂起等待,不如安全地拒绝并让 Claude 尝试其他方案。

auto 模式 是权限系统中最复杂也最具创新性的模式,它用 AI 分类器完全替代人工审批。当权限检查的初步结果为 ask 时,auto 模式不会弹出对话框,而是调用 Transcript 分类器对当前操作在完整对话上下文中进行安全评估。分类器批准则自动执行,拒绝则自动阻止。这种设计的核心理念是:AI 可以理解操作的意图和上下文,比简单的规则匹配做出更精准的安全判断。不过,auto 模式也配备了多层安全网,包括拒绝追踪、危险权限剥离、以及不可分类器审批的安全检查,这些将在后续章节详述。

bubble 模式 是内部使用的特殊模式,用于多 Agent 架构中权限请求的向上传递场景。当子 Agent 遇到需要权限审批的操作时,可以将请求"冒泡"到父 Agent 或最终的用户界面。

9.1.3 模式切换机制

用户可以通过 Shift+Tab 快捷键循环切换权限模式。切换逻辑定义在 src/utils/permissions/getNextPermissionMode.ts 中:

typescript 复制代码
// 文件: 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'
    default:
      return 'default'
  }
}

注意两个关键的条件守卫:isBypassPermissionsModeAvailable 确保 bypassPermissions 只在用户主动启用时可用,防止普通用户意外进入高风险模式;canCycleToAuto 同时检查 auto 模式的 feature gate 状态和运行时可用性标志,确保只有在分类器服务正常可用时才允许切换。模式切换不是简单的循环列表,而是一个基于当前上下文动态计算的有向图------每个节点的出边取决于当前的运行环境配置。

Anthropic 内部用户(USER_TYPE === 'ant')的切换路径与外部用户不同:内部用户直接从 default 跳到 bypassPermissions 或 auto,跳过 acceptEdits 和 plan,因为 auto 模式已经提供了更智能的自动化审批。

切换到 auto 模式时,还需要执行一个关键的安全操作------剥离危险权限规则:

typescript 复制代码
// 文件: 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: IDE 远程审批 and Classifier-->>Hook: 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 组件树中使用的回调函数。

typescript 复制代码
// 文件: 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 内部的实现遵循一条清晰的三阶段管线:

typescript 复制代码
// 文件: 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 Handlersrc/hooks/toolPermission/handlers/coordinatorHandler.ts):在协调器模式下,先序列化地运行自动化检查(Hook 和分类器),再回退到交互对话框。这确保了自动化通道优先于人工审批:

typescript 复制代码
// 文件: 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 Handlersrc/hooks/toolPermission/handlers/swarmWorkerHandler.ts):当运行在 swarm worker 模式时,先尝试分类器自动批准,否则将权限请求通过邮箱转发给 leader agent:

typescript 复制代码
// 文件: 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 Handlersrc/hooks/toolPermission/handlers/interactiveHandler.ts):最终的兜底处理器,负责向用户展示权限确认对话框,并发起多路竞争:

typescript 复制代码
// 文件: 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 分类器异步检查
}

这里的设计精华在于 createResolveOnce 机制------它确保无论哪个竞争通道先到达,Promise 只会被 resolve 一次。claim() 方法提供原子性的"声明-检查"操作,避免了异步回调之间的竞态条件。


9.3 静态规则匹配

9.3.1 三类规则体系

Claude Code 的静态权限规则分为三类,定义在 ToolPermissionContext 类型中:

typescript 复制代码
// 文件: 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:通过 feature flag 动态下发的规则,由 Anthropic 控制
  • policySettings:企业管理策略配置的规则,不可被用户修改或删除
  • cliArg :通过 --allowed-tools 等命令行参数传入的规则,仅当次会话有效
  • command :由斜杠命令(如 /permissions)在运行时动态设置的规则
  • session:会话级别的临时规则,用户在权限对话框中选择"本次会话允许"时创建

这种多来源架构的一个重要特性是策略不可篡改性policySettings 来源的规则代表组织级的安全策略,无法被用户通过任何途径覆盖或删除。这为企业部署提供了必要的合规保障。

9.3.2 规则的定义格式

每条规则由工具名和可选的内容限定组成,解析逻辑在 src/utils/permissions/permissionRuleParser.ts 中:

typescript 复制代码
// 文件: 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 开头的命令(使用 :* 语法)
  • 通配符匹配Bash(npm run *) 匹配符合通配符模式的命令(使用 * 语法)
typescript 复制代码
// 文件: src/utils/permissions/shellRuleMatching.ts

export type ShellPermissionRule =
  | { type: 'exact'; command: string }
  | { type: 'prefix'; prefix: string }
  | { type: 'wildcard'; pattern: string }

9.3.3 匹配算法与优先级

规则的匹配遵循严格的优先级顺序,体现在 hasPermissionsToUseToolInner 函数的步骤编排中:

rust 复制代码
优先级(从高到低):
  1a. deny 规则(工具级别)   -> 立即拒绝
  1b. ask 规则(工具级别)    -> 要求确认
  1c. 工具自身的 checkPermissions(命令级别规则匹配)
  1d. 工具实现拒绝            -> 立即拒绝
  1e. 工具要求用户交互        -> 要求确认
  1f. 内容级 ask 规则         -> 要求确认(不可被 bypass 覆盖)
  1g. 安全检查                -> 要求确认(不可被 bypass 覆盖)
  2a. 模式判定(bypassPermissions) -> 自动放行
  2b. allow 规则(工具级别)  -> 自动放行
  3.  passthrough 转 ask      -> 要求确认

这个优先级设计的关键原则是:deny 永远优先于 allow,安全检查不可绕过。即使在 bypassPermissions 模式下(步骤 2a),步骤 1d、1f、1g 中的拒绝和安全检查仍然生效。

工具级别的规则匹配逻辑如下:

typescript 复制代码
// 文件: 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 中。getAllowRulesgetDenyRulesgetAskRules 三个函数遍历所有来源,将字符串形式的规则解析为结构化的 PermissionRule 对象:

typescript 复制代码
// 文件: 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),
    })),
  )
}

来源的优先级体现在规则的覆盖关系上。policySettings 来源的规则(企业策略)不可被用户删除或覆盖,而 session 级别的规则只在当前会话中有效。在规则的持久化写入方面,只有 userSettingsprojectSettingslocalSettings 三种来源支持磁盘持久化;cliArgsession 是纯内存规则,policySettingsflagSettings 是外部控制的只读规则。当用户在权限对话框中选择"始终允许此工具"时,系统会根据上下文决定将规则写入 userSettings(全局有效)还是 projectSettings(项目有效),并在后续会话中自动加载。

规则的同步机制也值得关注:当用户在外部编辑器中修改了配置文件,syncPermissionRulesFromDisk 函数会重新加载磁盘上的规则并替换内存中的对应来源。替换操作按来源分组进行------先清空该来源的所有旧规则,再写入新规则------以确保删除的规则不会因增量合并而残留。


9.4 动态分类器

9.4.1 Bash 分类器

Bash 分类器是专门针对 Shell 命令的语义分析器,定义在 src/utils/permissions/bashClassifier.tssrc/tools/BashTool/bashPermissions.ts 中。它的核心任务是判断一条 Bash 命令是否匹配用户定义的语义描述规则。

与静态规则的精确匹配不同,Bash 分类器使用 AI 模型进行语义理解。例如,用户可以定义一条 prompt: 前缀的规则:"prompt: commands that modify git history",分类器会判断 git rebase -i HEAD~3 是否符合这个语义描述。

分类器的输入输出定义如下:

typescript 复制代码
// 文件: src/types/permissions.ts

export type ClassifierResult = {
  matches: boolean              // 是否匹配描述
  matchedDescription?: string   // 匹配到的具体描述
  confidence: 'high' | 'medium' | 'low'  // 置信度评分
  reason: string                // 判断理由
}

export type ClassifierBehavior = 'deny' | 'ask' | 'allow'

分类器的三级置信度机制是一个重要的安全设计:只有 high 置信度的匹配结果才会被直接采纳,mediumlow 置信度的结果可能触发额外的确认逻辑。置信度的引入使得分类器不是一个简单的二值判断器,而是一个能够表达不确定性的推理系统------当分类器"不太确定"时,它选择将决策权交还给更高层的判定逻辑,而非冒险做出可能错误的判断。

Bash 分类器还支持三种行为类型(ClassifierBehavior):deny(分类器认为命令匹配了某个拒绝描述)、ask(分类器认为命令需要用户确认)、allow(分类器认为命令匹配了某个允许描述)。这使得同一个分类器基础设施可以同时服务于允许规则和拒绝规则的语义匹配。

bashPermissions.ts 中,分类器的调用被集成到 Bash 工具的权限检查流程中。一个关键的性能优化是 推测性分类器检查(speculative classifier check)------在权限对话框弹出的同时,异步启动分类器评估。如果分类器在用户做出选择前返回了批准结果,可以自动跳过对话框:

typescript 复制代码
// 文件: 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): 使用 AI 模型进行深度推理

分类器的输出结构包含丰富的遥测信息:

typescript 复制代码
// 文件: 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 模式的判定逻辑展现了多层优化策略:

typescript 复制代码
// 文件: 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 的比例。通过记录 sessionInputTokenssessionOutputTokens 等主会话指标,分析师可以计算出形如"分类器开销占会话总成本的 X%"这样的关键指标。

9.4.3 分类器的安全防护

auto 模式下有多重安全防护机制:

不可绕过的安全检查 :即使在 auto 模式下,safetyCheck 类型的决策如果标记为 classifierApprovable: false,仍然需要人工审批:

typescript 复制代码
// 文件: src/utils/permissions/permissions.ts

if (result.decisionReason?.type === 'safetyCheck' &&
    !result.decisionReason.classifierApprovable) {
  if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
    return { behavior: 'deny', ... }
  }
  return result  // 回退到人工审批
}

危险权限剥离 :进入 auto 模式时,isDangerousBashPermission 函数会识别并剥离可能绕过分类器的规则:

typescript 复制代码
// 文件: 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 列表涵盖了所有可以执行任意代码的解释器和命令,包括 pythonnoderubyevalexecssh 等。这意味着即使用户之前配置了 Bash(python:*) 的允许规则,切换到 auto 模式时也会被自动移除。

分类器不可用时的降级 :当分类器 API 调用失败时,系统根据 tengu_iron_gate_closed feature gate 决定是"fail closed"(拒绝并提示重试)还是"fail open"(回退到人工审批):

typescript 复制代码
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 完整决策流程图

以下是从工具调用到最终决策的完整流程:

lua 复制代码
工具调用 (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 中。它封装了所有与单次权限判定相关的状态和操作:

typescript 复制代码
// 文件: 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 语义:

typescript 复制代码
// 文件: 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 中:

typescript 复制代码
// 文件: 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 次意味着模型可能在系统性地尝试突破安全边界。

状态转换逻辑非常清晰:

typescript 复制代码
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 拒绝超限处理

当拒绝次数超过阈值时,系统执行不同的降级策略:

typescript 复制代码
// 文件: 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。

第二,交互模式下将 deny 降级为 ask------把决策权交还给用户。同时在消息中包含最近被阻止的操作理由,帮助用户做出知情的决策。

9.6.3 拒绝记录与通知

被 auto 模式拒绝的操作会被记录到一个全局列表中,供用户在 /permissions 命令中查阅:

typescript 复制代码
// 文件: 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 模式的拒绝都会同时触发即时通知:

typescript 复制代码
// 文件: 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>
    </>
  })
}

9.6.4 分析遥测

权限决策系统包含全面的分析遥测,集中在 src/hooks/toolPermission/permissionLogging.ts 中。每次权限决策都会触发多路日志:

typescript 复制代码
// 文件: src/hooks/toolPermission/permissionLogging.ts

function logPermissionDecision(
  ctx: PermissionLogContext,
  args: PermissionDecisionArgs,
  permissionPromptStartTimeMs?: number,
): void {
  // 1. Statsig 分析事件
  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 遥测:

typescript 复制代码
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["转发到 IDE"] B_Forward --> B_IDE["IDE 审批对话框"] B_IDE --> B_Response["回传决策"] end

9.7.1 Swarm Worker 的权限转发

在多 Agent 协作场景中,swarm worker 运行在后台进程中,没有直接的 UI 来展示权限对话框。swarmWorkerHandler.ts 实现了一套优雅的权限委托机制。

当 swarm worker 需要权限审批时,它首先尝试本地的分类器自动批准(对于 Bash 命令)。如果分类器无法决定,则将权限请求序列化并通过邮箱(mailbox)发送给 leader agent:

typescript 复制代码
// 文件: 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 模式的权限处理

Coordinator 是一种特殊的 worker 模式,它可以在显示交互对话框之前先运行自动化检查。coordinatorHandler.ts 的逻辑是严格顺序化的:

lua 复制代码
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 的末尾:

typescript 复制代码
// 文件: 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 通道 连接到 claude.ai 网页端,允许用户通过浏览器审批本地终端的权限请求。请求和响应通过 WebSocket bridge 传输,支持 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 模式行为的完整监控,分别捕捉"局部循环"和"全局趋势"两种异常模式。

为什么权限规则需要如此多的来源层次?

八种来源层次对应的是软件配置管理中经典的"就近覆盖"原则。全局配置(userSettings)为所有项目提供合理的默认值;项目配置(projectSettings)可以根据项目特性覆盖全局默认(例如,一个 DevOps 项目可能允许 docker 命令,而一个前端项目不需要);本地配置(localSettings)允许开发者的个人偏好不影响团队共享的项目配置;命令行参数(cliArg)提供了一次性的临时覆盖能力;会话级规则(session)则是最短暂的------用户在对话框中点击"本次允许"的结果。这种层次化设计使得权限配置既可以在组织层面统一管理,也可以在个人层面灵活定制,同时保持清晰的溯源链路。

为什么交互对话框要同时启动多个审批通道?

在实际使用场景中,用户可能在多种界面之间切换------有时看着终端,有时在浏览器中工作,有时甚至不在电脑旁但手机在手。多通道设计确保权限审批不会成为瓶颈:用户可以从最方便的设备上响应。Bridge 通道让 claude.ai 网页端成为终端的远程控制器;Channel 通道让即时通讯成为移动端的审批入口。最关键的是,所有通道都与本地分类器并行竞争------如果 AI 能自信地做出判断,用户甚至不需要在任何设备上操作。


小结

Claude Code 的权限模型是一个精密的多层决策系统。从本章的分析中,我们可以提炼出几个核心设计理念:

安全分层 :deny 规则是硬约束,不可被任何模式覆盖;安全检查(敏感路径保护)是准硬约束,只有标记为 classifierApprovable 的才允许 AI 分类器评估;其余操作的约束强度随模式不同而变化。

渐进式信任:从 plan(只读)到 default(标准)到 acceptEdits(宽松)到 auto(AI 自主)到 bypassPermissions(几乎无限制),每个模式代表一个更高的信任级别。用户可以根据当前任务的风险程度动态调整。

优雅降级:auto 模式下,分类器不可用时降级为人工审批;拒绝次数超限时降级为人工审批;无头模式下降级为 Hook 处理或默认拒绝。每一层降级都有明确的语义和用户反馈。

多通道竞争:权限审批不是单一阻塞操作,而是本地 UI、远程 Bridge、Channel 通知、Hook 自动化、分类器评估多路并行竞争。这确保了权限审批的响应速度取决于最快的通道,而不是最慢的。

可观测性:每次权限决策都通过多路遥测(Statsig 分析、OTel 计数器、上下文存储)进行记录,使得权限系统的行为完全透明、可分析、可优化。

这套权限模型的设计哲学可以用一句话概括:在 AI 自主执行和人类安全监督之间找到动态平衡点,并通过多层防御确保即使某一层失效,整体安全性仍然得到保障。

从更宏观的视角来看,Claude Code 的权限模型为整个 AI 工具生态提供了一个重要的工程参考。随着 AI Agent 能力的持续增强,权限治理将成为越来越核心的基础设施。Claude Code 展示了如何将传统的访问控制理论(基于规则的 RBAC/ABAC)与 AI 原生的安全机制(基于上下文的分类器、语义级命令分析)有机融合,构建出一个既能适应快速迭代的产品需求、又能维持严格安全底线的权限治理框架。

在下一章中,我们将深入沙箱机制------权限模型的物理隔离层。如果说权限模型是"软件层面"的安全屏障,那么沙箱就是"操作系统层面"的安全兜底,二者共同构成了 Claude Code 纵深防御体系的完整图景。

相关推荐
杨艺韬9 小时前
Claude Code设计与实现-第14章 多 Agent 协调与 Swarm
agent
杨艺韬9 小时前
Claude Code设计与实现-第11章 MCP 协议集成
agent
杨艺韬9 小时前
Claude Code设计与实现-第6章 工具类型系统设计
agent
杨艺韬9 小时前
Claude Code设计与实现-第1章 为什么需要理解 Claude Code
agent
杨艺韬9 小时前
Claude Code设计与实现-前言
agent
杨艺韬9 小时前
Claude Code设计与实现-第2章 架构总览
agent
杨艺韬9 小时前
Claude Code设计与实现-第4章 Query 引擎:Agent 的心脏
agent
杨艺韬9 小时前
Claude Code设计与实现-第3章 CLI 启动与性能优化
agent
杨艺韬9 小时前
OpenClaw设计与实现-第12章 定时任务与自动化
agent