Claude Code 权限系统拆解:一个工具调用要过几道关卡才能执行?

5 阅读16分钟

Claude Code 的权限系统厉害的地方,不是“会不会弹窗”,而是它把规则、工具自检、分类器、Hooks、多 Agent 转发全都串成了一条可组合的决策流水线。

一、先讲结论

如果只看表面,你会以为 Claude Code 的权限系统无非是几种模式切换:默认模式弹窗、危险模式放行、自动模式少打扰。

但源码真正做的事情复杂得多。

一次工具调用从发起到真正执行,至少会经过:

  1. 规则引擎
  2. 工具自己的 checkPermissions()
  3. 模式分支
  4. 安全检查
  5. AI 分类器
  6. Hooks / UI / 多 Agent 转发

更关键的是,它不是一套“单点 if-else”,而是内层 hasPermissionsToUseToolInner() 加外层 hasPermissionsToUseTool() 两层配合。前者负责主干裁决,后者负责 dontAskauto、headless fallback 这些模式化后处理。

还有一个很容易写错的点要先说清楚:

  • TypeScript 类型联合 InternalPermissionMode 包含 7 个值:defaultacceptEditsbypassPermissionsplandontAskautobubble
  • 但不要把它们都理解成"公开可选模式"
  • SDK schema 公开暴露的只有 5 个(EXTERNAL_PERMISSION_MODES):defaultacceptEditsbypassPermissionsplandontAsk
  • bubble 只存在于类型定义中,不在 PERMISSION_MODES 运行时验证集里,也没有对应的 PERMISSION_MODE_CONFIG 配置——它是子 Agent 的纯内部占位
  • autoTRANSCRIPT_CLASSIFIER feature gate 约束,开启后才会加入 PERMISSION_MODES,不是所有构建都对外开放

这点如果不先讲清楚,后面整篇文章都会把“源码内部状态”和“产品公开能力”混在一起。

二、完整执行路径:6 阶段决策流水线

一个工具调用的主干权限判定发生在 hasPermissionsToUseToolInner() 里。我把它整理成下面这张图:

flowchart TD
    Start["工具调用发起"] --> S1a

    subgraph Stage1["阶段1:规则检查 + 工具自检"]
        S1a["1a. Deny Rule 命中?"] -->|是| DENY1["❌ 直接拒绝"]
        S1a -->|否| S1b
        S1b["1b. Ask Rule 命中?"] -->|是| ASK1["⚠️ 需要确认"]
        S1b -->|否| S1c
        S1c["1c. tool.checkPermissions()"] -->|allow| ALLOW0["✅ 工具直接放行"]
        S1c -->|deny| DENY2["❌ 工具拒绝"]
        S1c -->|ask| S1e
        S1c -->|passthrough| Stage2
        S1e["1e. requiresUserInteraction?"] -->|是| ASK2["⚠️ 必须人工交互"]
        S1e -->|否| S1f
        S1f["1f. 内容级 Ask Rule?"] -->|是| ASK3["⚠️ 内容级确认"]
        S1f -->|否| S1g
        S1g["1g. Safety Check?"] -->|是| ASK4["⚠️ 安全检查"]
        S1g -->|否| Stage2
    end

    subgraph Stage2["阶段2:模式 / Always Allow"]
        S2a["2a. bypassPermissions 或特殊 plan?"] -->|是| ALLOW1["✅ 放行"]
        S2a -->|否| S2b
        S2b["2b. Always Allow 命中?"] -->|是| ALLOW2["✅ 放行"]
        S2b -->|否| Stage3
    end

    subgraph Stage3["阶段3:标准化结果"]
        S3["passthrough -> ask"] --> Stage4
    end

    subgraph Stage4["阶段4:外层后处理"]
        S4a["dontAsk 模式?"] -->|是| DENY3["❌ 自动拒绝"]
        S4a -->|否| S4b
        S4b["auto / plan+auto 语义?"] -->|是| Classifier
        S4b -->|否| S4c
        S4c["shouldAvoidPermissionPrompts?"] -->|是| HookOrDeny["Hooks -> 否则拒绝"]
        S4c -->|否| UserConfirm["展示确认对话框"]
    end

    subgraph Classifier["Auto 模式分类器"]
        C1["不可分类的 safetyCheck?"] -->|是| DENY4["❌ 拒绝或保留人工确认"]
        C1 -->|否| C2
        C2["acceptEdits 快速路径?"] -->|通过| ALLOW3["✅ 放行"]
        C2 -->|否| C3
        C3["安全工具 allowlist?"] -->|是| ALLOW4["✅ 放行"]
        C3 -->|否| C4
        C4["调用 YOLO Classifier"] --> C5
        C5["分类器判定"] -->|允许| ALLOW5["✅ 放行"]
        C5 -->|阻止| C6
        C6["Denial 限制检查"] -->|超限| UserConfirm2["回退到人工确认"]
        C6 -->|未超限| DENY5["❌ 分类器拒绝"]
    end

严格来说,源码还单独抽了一个 checkRuleBasedPermissions(),把 1a-1g 这一段“纯规则逻辑”复用了一遍;但真正决定最终结果的,仍然是上面这条主干。

阶段 1:规则检查不是一层,是一组有优先级的闸门

1a. Deny Rule

这一层最硬,命中就直接拒绝。

更值得注意的是,Deny Rule 不只是“运行时拒绝”,还会在工具池构建阶段把工具直接过滤掉:

export function filterToolsByDenyRules(tools, permissionContext) {
  return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}

这意味着模型看到的不是“有个工具但你不能用”,而是“这个工具根本不存在”。

这个设计很强,因为它直接减少了模型绕过限制的动机。

Deny / Ask / Allow 规则的来源也不止配置文件那几层。源码里权限规则来源一共 8 种:

  • userSettings
  • projectSettings
  • localSettings
  • flagSettings
  • policySettings
  • cliArg
  • command
  • session

注意不要漏掉 command 这一层——它来自内置命令(如 /init/config),和 cliArg(CLI 启动参数)是不同的来源。

1b. Ask Rule

如果工具整体命中 Ask Rule,会先得到一个 ask

但 Bash 有一个重要例外——沙箱自动放行:

  • SandboxManager.isSandboxingEnabled() 为真
  • autoAllowBashIfSandboxed 开启
  • 且当前命令确实会进入沙箱执行(shouldUseSandbox(input) 返回真)
  • 也就是没有 excludedCommand / dangerouslyDisableSandbox 这类绕开沙箱的情况

满足这些条件时,Ask Rule 会被跳过,继续交给 Bash 自己的 checkPermissions() 细查。沙箱提供了额外的安全层,因此不需要再弹确认框。

1c. tool.checkPermissions()

这是 Claude Code 权限系统最重要的扩展点之一。

默认实现不是 passthrough,而是直接返回 allow

checkPermissions: (input) =>
  Promise.resolve({ behavior: 'allow', updatedInput: input })

也就是说,默认语义是“没有额外限制”。真正复杂的工具,比如 Bash、PowerShell、文件读写、Notebook 编辑,都会重写这一层。

这里还有一个很漂亮的类型设计:PermissionResultPermissionDecision 多了一个 passthrough

type PermissionResult =
  | PermissionDecision
  | { behavior: 'passthrough', ... }

passthrough 的意思不是“允许”,而是“我不做决定,交回通用流程”。

这让工具作者不用硬选 allow / deny / ask,只在自己真正懂的那部分做判断。

1e-1g. ask 结果的再细分

工具一旦返回 ask,还要继续分三种:

  • requiresUserInteraction():有些工具即使在 bypass 下也必须人工确认
  • 内容级 Ask Rule:比如 Bash(npm publish:*)
  • safetyCheck:例如 .git/.claude/.vscode/、shell 配置文件、跨机器桥接消息等

这里最关键的规则是:

  • 内容级 Ask Rule 对 bypassPermissions 免疫
  • safetyCheckbypassPermissions 也免疫

也就是说,危险模式不是“所有检查都跳过”,只是跳过了后半段通用确认逻辑,前面的硬闸门仍然有效。

阶段 2:模式和 Always Allow

通过阶段 1 后,再看两件事。

2a. bypassPermissions

这里不仅包含直接的 bypassPermissions,还包含一种容易忽略的特殊情况:

  • 当前模式是 plan
  • 但上下文里 isBypassPermissionsModeAvailable 仍然为真

源码会把它视为“权限可旁路”的场景。

所以不要把 plan 理解成权限系统里一个简单的“只读 if 分支”。plan 更像一个流程模式,实际行为还和工具池、ExitPlanMode、auto 语义复用等机制缠在一起。

2b. Always Allow

这一层才是传统意义上的白名单。

但也别把它理解成“用户点一次 always allow,就永远新增一条 allow 规则”。Claude Code 的 permission prompt 返回的是结构化 suggestions,有些“永远允许”会被翻译成更高层的权限更新,而不是一条裸 allow rule。

阶段 3:把 passthrough 标准化成 ask

走到这里,如果工具还没有给出明确裁决,passthrough 会被统一转成 ask

这一步很重要,因为后面的 UI、Hooks、classifier 逻辑只处理三态:

  • allow
  • deny
  • ask

阶段 4:真正复杂的是外层后处理

外层 hasPermissionsToUseTool() 拿到内层结果后,才会按模式分流:

模式行为
default进入交互式确认
acceptEdits文件系统编辑类操作更容易被自动放行,其他工具仍可能询问
bypassPermissions跳过通用确认,但前面那几层硬检查依然生效
plan更像“规划流程模式”,不是单靠权限函数把所有写操作一刀切掉
dontAskask 直接转成 deny
auto尝试用 transcript classifier 自动判断
bubble子 Agent 的权限请求冒泡给父 Agent 终端处理

注:auto 不是所有构建都可见,bubble 更是内部模式。把这 7 个值直接当成“用户可选项”会夸大产品表面能力。

这里还有第二个容易混淆的点:

  • dontAsk 是 mode
  • shouldAvoidPermissionPrompts 是上下文字段

它们不是一回事。

很多异步子 Agent、后台 Agent 没法弹 UI,并不是因为它们处在 dontAsk,而是因为上下文被打上了 shouldAvoidPermissionPrompts: true。这时系统会先跑 PermissionRequest Hooks,Hooks 也没能决定,才会自动拒绝。

三、Auto 模式:不是所有 ask 都交给分类器

auto 是 Claude Code 最有意思的权限创新之一:不是直接弹窗,而是把“要不要拦”交给另一个模型来判断。

但它不是“所有 ask 都无脑调分类器”。源码做了很多短路和特殊分支。

快速路径 1:模拟 acceptEdits

如果某个操作在 acceptEdits 下本来就能放行,那 auto 没必要再花一次 classifier 调用成本。

const acceptEditsResult = await tool.checkPermissions(parsedInput, {
  ...context,
  getAppState: () => ({
    ...state,
    toolPermissionContext: {
      ...state.toolPermissionContext,
      mode: 'acceptEdits',
    },
  }),
})

这条快速路径很实用,尤其针对工作目录内的编辑。

但 Claude Code 也刻意把 AgentREPL 排除掉了,因为它们在 acceptEdits 下太容易“看起来安全、实际上绕过 classifier”。

快速路径 2:安全工具 allowlist

源码里有一组 SAFE_YOLO_ALLOWLISTED_TOOLS,覆盖了所有只读、元数据、协调类工具:

  • 文件只读:FileRead
  • 搜索:GrepGlobLSPToolSearch
  • MCP 资源:ListMcpResourcesReadMcpResource
  • 任务管理:TodoWriteTaskCreateTaskGetTaskUpdateTaskListTaskStopTaskOutput
  • 交互 / 流程:AskUserQuestionEnterPlanModeExitPlanMode
  • Swarm 协调:TeamCreateTeamDeleteSendMessage
  • 其他安全工具:SleepWorkflow(条件编译)、以及若干 ant-only 内部工具

这些工具不需要 transcript classifier 介入,直接放行。

快速路径 3:真正调用 classifyYoloAction()

前两层都不命中,才会进入 classifier。

这时 Claude Code 会把:

  1. 当前动作格式化成 action
  2. 对话历史压成 classifier transcript
  3. 通过独立 side query 调模型
  4. 得到 shouldBlock 和原因 值得注意的是,从 telemetry 代码看,classifier 实际上是两阶段(stage1 + stage2)的,有独立的 token 用量和延迟追踪。也就是说它不是简单的"一次 API 调用",内部有更复杂的判定链路。 这部分设计得很工程化,因为它不只是“调用个模型”,还考虑了很多失败分支:
  • transcript 太长
  • API unavailable
  • fail-open / fail-closed
  • denial tracking

两个容易忽视的边界

边界 1:不是所有 safetyCheck 都一样

safetyCheck 里有一个字段很关键:

classifierApprovable: boolean

这意味着:

  • 有些 safety check 可以交给 classifier 看上下文后决定
  • 有些则完全不能,必须人工确认或直接拒绝

例如:

  • .git/.claude/、shell 配置文件这类敏感文件路径,通常是 classifierApprovable: true
  • 可疑 Windows 路径模式、跨机器桥接消息这类更硬的场景,会是 false

换句话说,不是"auto 模式会吃掉 safety check",而是"auto 只接手其中可分类的那一部分"。

边界 2:PowerShell 默认根本不进 auto classifier

源码里还有一个特殊分支:PowerShell 默认要求显式人工确认,除非 ant-only 的 POWERSHELL_AUTO_MODE feature 打开。

这说明 Claude Code 的权限系统虽然有统一主干,但并不是“所有工具一视同仁”。工具特性仍然能把某些动作拉回更保守的路径。

容错机制

这部分有两个关键细节:

  • transcript 过长时,交互式场景会回退到人工确认;headless 场景直接 throw AbortError(永久条件,transcript 只增不减,重试没意义)
  • classifier 连续拒绝 3 次或累计拒绝 20 次时,会降级到人工确认;如果是 headless,同样直接 abort
export const DENIAL_LIMITS = {
  maxConsecutive: 3,
  maxTotal: 20,
}

这套 denial tracking 很有价值,因为它承认了一件事:分类器会误判,所以系统必须有退路。

四、Bash:真正和确认对话框竞速的是专用 allow classifier

关于"Bash 命令的双重分类器竞速",需要讲准确一点。

更准确的说法是:

  • Claude Code 里确实有两套 classifier 家族
  • 一套是 auto 模式下的 transcript classifier
  • 另一套是 Bash 专用的 allow classifier
  • 但它们不会在同一次 Bash 权限判定里同时上场

因为 Bash 的推测性 allow classifier 有明确 guard:

  • auto 模式下不跑
  • bypassPermissions 下不跑
  • 没有 prompt allow descriptions 时也不跑

真正会和用户确认对话框竞速的,是这个 Bash 专用 allow classifier。

sequenceDiagram
    participant TE as toolExecution
    participant B as BashTool.checkPermissions
    participant SC as 推测性 Bash Allow Classifier
    participant IH as interactiveHandler
    participant D as 确认对话框
    participant U as 用户

    TE->>SC: startSpeculativeClassifierCheck(更早阶段预启动)
    TE->>B: 执行权限检查
    B-->>TE: 返回 { ask, pendingClassifierCheck }
    TE->>IH: 进入交互式权限处理
    IH->>D: 展示确认对话框
    par 异步竞速
        SC->>IH: executeAsyncClassifierCheck 消费结果
        alt 高置信度允许 & 用户尚未交互
            IH-->>D: ✅ 自动放行,展示 ✓ 后移除对话框
        else 不匹配 / 低置信度
            IH-->>D: 清除 classifier 指示器
        end
    and 用户操作
        U->>D: 手动批准或拒绝(200ms 宽限期后生效)
    end

这块实现有三个很漂亮的细节:

  1. 推测性预执行startSpeculativeClassifierCheck()toolExecution.ts 的更早阶段就启动了(输入解析后、权限检查前),结果缓存进 Map<string, Promise>,后续由 consumeSpeculativeClassifierCheck() 消费
  2. 纯异步竞速:没有固定超时——classifier 和用户操作/Hooks/Bridge 完全并行竞争,谁先完成谁赢,通过 createResolveOnce()claim() 做原子抢占
  3. 200ms 交互宽限期:用户碰到对话框后,自动批准逻辑就失效(userInteracted = true);但前 200ms 的误触不会取消 classifier,防止意外按键打断自动流程

也就是说,Claude Code 不是简单地"弹个窗,顺便后台跑个异步任务",而是很认真地减少弹窗频率,同时避免 UI 抢用户控制权。

五、Hooks 和 Permission Suggestions:它不是 yes/no,而是可编排权限代理

Hooks 和权限系统的关系,值得讲准一点。

更准确地说:

  • PreToolUse / PostToolUse / PostToolUseFailure 属于更广义的工具生命周期
  • 真正直接插进权限决策里的,是 PermissionRequest
Hook 事件触发时机能做什么
PreToolUse工具执行前审计、改输入、阻止执行
PostToolUse工具执行后记录结果、追加自动化处理
PermissionRequest结果为 ask自动 allow/deny、改输入、更新权限

PermissionRequest 不只服务 headless

容易把它理解成"headless agent 的兜底",但实际上它在两种场景都会跑:

  1. 交互式场景 对话框已经显示出来,但 PermissionRequest Hook 会在后台异步执行。如果 Hook 比用户点按钮更快,就直接接管这次权限决策。

  2. headless / async 场景 没法展示 UI,于是先跑 Hook;Hook 不决定,才自动拒绝。

对应源码大意如下:

if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
  const hookDecision = await runPermissionRequestHooksForHeadlessAgent(...)
  if (hookDecision) return hookDecision
  return { behavior: 'deny', message: AUTO_REJECT_MESSAGE(tool.name) }
}

所以更准确的总结不是“dontAsk 模式下先给 Hook 一次机会”,而是:

  • dontAsk 会把 ask 转成 deny
  • shouldAvoidPermissionPrompts 会触发 headless fallback
  • 在这条 fallback 上,系统仍然优先给 PermissionRequest Hook 一次接管机会

Permission prompt 返回的不是布尔值,而是结构化更新

Claude Code 的 permission prompt 不只是“允许 / 拒绝”,还可能附带结构化 suggestions。文件权限场景尤其明显:

const updates = shouldSuggestAcceptEdits
  ? [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }]
  : []

if (isOutsideWorkingDir) {
  updates.push({
    type: 'addDirectories',
    directories: dirsToAdd,
    destination: 'session',
  })
}

这意味着用户点下“Always allow”时,系统不一定在做“加一条 allow 规则”这么粗糙的事。

它可能做的是:

  • 把当前 session 切到 acceptEdits
  • 把目标目录加入 working directories
  • 或者持久化一组 updatedPermissions

这是非常重要的工程细节。因为它说明 Claude Code 不是在堆规则,而是在维护一份结构化权限上下文

六、多 Agent:同一套权限系统,被接成了三种分流模型

Claude Code 里多 Agent 不是权限系统之外的附属功能,它直接改变权限请求的去向。

1. Fork Subagent:bubble

Fork 子 Agent 会继承父 Agent 的完整上下文,但把权限模式设成 bubble

export const FORK_AGENT = {
  permissionMode: 'bubble',
  tools: ['*'],
  model: 'inherit',
}

含义很直接:

  • 子 Agent 可以继续工作
  • 但它没有独立权限 UI
  • 所有需要确认的请求都“冒泡”回父终端

2. Coordinator Worker:自动检查优先,弹窗靠后

协调者模式下,worker 会先顺序执行:

  1. PermissionRequest Hooks
  2. Bash classifier
  3. 都没决定再回退到交互式对话框

这对应 awaitAutomatedChecksBeforeDialog: true 的语义。

为什么这里是顺序 await,而不是像主线程那样竞速?

因为 worker 自己没有独立终端,先把自动化分支跑完,能减少对主 Agent 的打扰。

3. Swarm Worker:先 classifier,再 mailbox 转发给 leader

Swarm worker 的路径也不一样:

  1. 先尝试 classifier 自动放行
  2. 不行就把 permission request 通过 mailbox 发给 leader
  3. leader 侧弹出和本地一模一样的确认 UI
  4. 用户决策后再回传给 worker

这一层设计说明 Claude Code 把“谁来确认”也抽象成了权限系统的一部分。

4. 反递归保护

多 Agent 最怕的不是一次危险操作,而是无限套娃。

Claude Code 也注意到了这点:

  • 外部构建默认限制 Agent 工具递归嵌套
  • fork child 会通过历史里的 fork 标记检测递归

也就是说,多 Agent 权限设计不只是“把确认框转发一下”,还得顺手把递归爆炸面堵上。

七、和 VS Code Agent / Copilot 的差别在哪

这个对比只基于 2026 年 3 月能看到的官方公开文档,不猜测未公开实现:

维度Claude CodeVS Code / Copilot 公开模型
权限表达方式运行时多阶段决策链产品级模式 + 设置项
模式表达源码内部 7 种,公开能力少于 7 种公开文档强调 Edit automatically / Request approval / Plan,另有 bypass 类设置
自动化决策acceptEdits 快速路径 + 安全工具 allowlist + transcript classifier + Bash allow classifier + Hooks默认工作区编辑可自动批准,其他操作按模式和设置确认;公开文档未强调类似的 runtime classifier 链路
粒度工具级 + 内容级规则 + 路径安全检查更偏操作类别和产品级交互设置
多 Agent 权限分流bubble / coordinator / swarm 三种公开文档更强调统一 session 体验,未看到同级别的权限冒泡 / mailbox 转发设计

如果只看产品界面,你会觉得两者都在做“权限模式”。

但往源码里走,差别会很明显:

  • VS Code 这类产品更像是在做“用户可理解的权限档位”
  • Claude Code 更像是在做“给长时间自主运行的 Agent 用的 runtime permission engine”

这也是为什么 Claude Code 会有那么多你在 UI 上根本看不到的内部状态,比如 passthroughbubbleshouldAvoidPermissionPromptsawaitAutomatedChecksBeforeDialog

八、给 Agent 开发者的几个设计启示

从源码里看,Claude Code 权限系统最值得借鉴的不是某一条规则,而是几条工程原则。

1. Deny 最好直接隐藏工具,而不是靠报错教育模型

只要模型知道一个工具存在,它就会尝试继续靠近那条能力边界。直接从工具池里删掉,比“拒绝一次”更有效。

2. passthrough 很有用

权限系统里很多复杂度,来自工具被迫过早做决定。给工具一个“我没意见”的中间态,主干架构会干净很多。

3. safetyCheck 不该只有一个布尔值

Claude Code 用 classifierApprovable 把 safety check 又分成两层:

  • 可以交给 classifier 看上下文
  • 不能交,必须人工处理

这比“所有敏感路径一律弹窗”更细,也比“一律交给模型判断”稳。

4. “Always allow” 不一定等于“加一条 allow rule”

很多系统的权限实现停留在布尔开关上,但 Claude Code 会把批准动作翻译成:

  • 切模式
  • 加目录
  • 持久化规则

这才像是在维护长期会话的权限状态,而不是在堆例外。

5. headless 需要单独建模

dontAsk 不够。你还需要一个类似 shouldAvoidPermissionPrompts 的上下文字段,明确告诉系统:这个 Agent 有没有能力展示 UI。

6. classifier 一定要有退路

Claude Code 很清醒地承认:AI classifier 会误判、会超时、会超上下文、会不可用。

所以它准备了:

  • 快速路径,减少调用次数
  • fail-open / fail-closed
  • denial limit
  • headless abort

这比“调一个模型试试看”成熟得多。

九、最后总结

如果让我用一句话概括 Claude Code 的权限系统,我会说:

它不是一个“权限弹窗模块”,而是一套把规则引擎、工具局部知识、模型分类器、Hooks、UI 和多 Agent 编排接到一起的运行时决策系统。

这套设计最厉害的地方,也不是某个 classifier 或某条规则本身,而是层与层之间分工很清楚:

  • 哪些判断必须前置
  • 哪些判断可以下放给工具
  • 哪些危险不能被 bypass
  • 哪些场景该交给模型
  • 模型错了以后怎么退回人类

从工程成熟度看,这比“多加几条白名单规则”高了不止一个层级。

如果你在做 Agent 安全架构,这篇源码最值得抄的,不是具体实现,而是这套分层思路。