Claude Code 源码解剖

Claude Code 的权限系统

从 PermissionMode、PermissionRule、Bash 规则匹配、hasPermissionsToUseTool 三阶段管线、路径验证和危险规则剥离,拆解 Claude Code 如何把工具自动化放进可解释的安全边界。

第 12 章 17 分钟

Claude Code 的权限系统

这一章要解决的问题是:模型已经决定要调用工具了,Claude Code 凭什么判断这次工具调用能不能真的执行?

源码里的权限系统不是一个确认弹窗,也不是某个总开关。它是一条前置在副作用之前的判定管线:

  • 权限模式决定默认姿态。
  • 权限规则表达用户、项目、企业和会话的安全意图。
  • 工具自己的 checkPermissions() 补充语义检查。
  • 路径和命令规则拦住文本绕过和 TOCTOU。
  • dontAskautobypassPermissions 在最后改变 ask 的处理方式,但不是所有安全检查都能绕过。

核心直觉

Claude Code 的权限系统不是“模型请求后问不问用户”,而是“工具副作用发生前,系统如何把 allow / deny / ask 这个三态决策算出来”。

先看源码入口

源码定位关键符号负责什么
types/permissions.tsExternalPermissionModeInternalPermissionModePermissionMode定义外部模式和内部模式
PermissionMode.ts模式配置对象模式标题、符号、颜色和显示信息
getNextPermissionMode.tsgetNextPermissionMode()canCycleToAuto()Shift+Tab 模式切换顺序和 auto 可用性
permissionSetup.tstransitionPermissionMode()模式切换副作用,比如 plan 准备、auto 危险规则剥离
types/permissions.tsPermissionRulePermissionRuleValuePermissionUpdate权限规则和持久化更新的数据结构
permissionRuleParser.tspermissionRuleValueFromString()解析 ToolName(content) 规则字符串
shellRuleMatching.tsparsePermissionRule()matchWildcardPattern()Bash 规则的 exact / prefix / wildcard 匹配
permissions.tshasPermissionsToUseTool()hasPermissionsToUseToolInner()权限管线核心入口
pathValidation.tsvalidatePath()isPathAllowed()文件路径验证、工作区判断、安全路径保护
permissionSetup.tsisDangerousBashPermission()stripDangerousPermissionsForAutoMode()auto 模式进入时剥离危险 allow 规则
shadowedRuleDetection.tsUnreachableRule检测被 deny / ask 遮蔽的无效 allow 规则

这张表里最重要的是 hasPermissionsToUseTool()。模式、规则、路径验证、分类器最后都要汇入它。

总流程图

flowchart TD A["模型提出工具调用"] --> B["hasPermissionsToUseTool()"] B --> C["阶段一:规则验证和工具自检"] C --> D{"工具级 deny 规则?"} D -->|是| X["deny"] D -->|否| E{"工具级 ask 规则?"} E -->|是| Y["ask"] E -->|否| F["tool.checkPermissions()"] F -->|deny| X F -->|ask| Y F -->|allow 或通过| G{"内容级 ask / 安全检查?"} G -->|命中| Y G -->|未命中| H["阶段二:模式裁决"] H --> I{"bypassPermissions?"} I -->|是| Z["allow"] I -->|否| J{"allow 规则或工具自身 allow?"} J -->|是| Z J -->|否| Y Y --> K["阶段三:模式后处理"] K --> L{"permissionMode"} L -->|dontAsk| X L -->|auto| M["YOLO 分类器"] L -->|default / acceptEdits / plan| N["用户确认对话框"] M -->|安全| Z M -->|阻止或不确定| N

这张图有一个关键顺序:deny 和安全检查在前,模式裁决在后。bypassPermissions 并不是把整条管线从头跳过,它在阶段二才起作用。

权限模式:粗粒度姿态,不是最终裁决

外部可见的权限模式定义在 types/permissions.ts

export const EXTERNAL_PERMISSION_MODES = [
  'acceptEdits',
  'bypassPermissions',
  'default',
  'dontAsk',
  'plan',
] as const

内部还有两个模式。

export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
export type PermissionMode = InternalPermissionMode

这段代码证明:公开 API 和内部运行时不是完全同一组模式。autobubble 是内部扩展,auto 还受 feature('TRANSCRIPT_CLASSIFIER') 等门控影响。

模式运行时含义关键边界
default保守默认,大量操作进入确认初始安全姿态
acceptEdits工作区内编辑更容易自动通过Shell 命令仍需规则和确认
plan偏只读和规划不应执行写操作
bypassPermissions阶段二直接放行阶段一的 deny / ask / 安全检查仍可先拦
dontAsk不弹确认,把 ask 变成 deny适合非交互运行
auto用分类器处理 ask 灰区规则和硬安全检查仍在前面
bubble内部模式作为内部控制态使用

模式切换有副作用

模式切换不是改枚举值。transitionPermissionMode() 会处理进入或离开某些模式时的状态迁移。

切换触发动作为什么
进入 planprepareContextForPlanMode(),保存 prePlanMode计划模式需要记住之前从哪里来
进入 autostripDangerousPermissionsForAutoMode()防止危险 allow 规则让分类器失去意义
离开 autorestoreDangerousPermissions()恢复被临时剥离的规则
离开 plan标记 hasExitedPlanMode让后续流程知道已经脱离计划态

这说明权限模式本身也带状态管理,不是 UI 档位。

权限规则:三字段表达安全意图

权限规则的数据结构很小,但它承载了用户、项目、企业和会话级策略。

export type PermissionRule = {
  source: PermissionRuleSource
  ruleBehavior: PermissionBehavior
  ruleValue: PermissionRuleValue
}

规则目标由工具名和可选内容组成。

export type PermissionRuleValue = {
  toolName: string
  ruleContent?: string
}

这段代码证明:权限规则不是只看工具名。Bash 可以是工具级规则,也可以是 Bash(git status)Bash(npm:*) 这类内容级规则。

规则来源有优先级

原文列出八种规则来源:

来源典型位置影响范围
policySettings企业托管策略组织统一下发
projectSettings.claude/settings.json项目共享,可提交 git
localSettings.claude/settings.local.json本地私有,通常 gitignore
userSettings~/.claude/settings.json用户全局
flagSettings--settings运行时指定
cliArg--allowed-tools命令行临时规则
command命令上下文当前命令范围
session会话内临时规则当前会话

这说明权限不是只有一份配置文件。企业策略、项目约定、个人偏好和会话临时授权会合并进同一套裁决管线。

规则字符串解析

规则在配置中通常写成字符串:

Bash
Bash(git status)
Bash(npm:*)
Edit(/path/to/file)

permissionRuleValueFromString() 负责解析 ToolName(content),并处理内容里的括号转义。原文还指出 Bash()Bash(*) 都会被视为工具级规则,等价于 Bash

这个设计很实用,但也带来一个后果:Bash(*) 不是“只允许一个星号参数”,而是允许 Bash 工具本身。读规则时不能把括号里的 * 当成普通 shell glob。

命令规则匹配:exactprefixwildcard 三种语义

Shell 权限规则不能只靠字符串包含。shellRuleMatching.ts 把规则解析成判别联合。

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

这段代码证明 Bash 规则有三类匹配语义:

类型规则示例匹配不匹配
exactgit statusgit statusgit status --short
prefixnpm:*npm installnpm testnpx npm
wildcarddocker build -t *docker build -t appdocker run app

前缀规则来自 legacy :* 语法:

const match = rule.match(/^(.+):\*$/)

通配符匹配要处理 \* 字面星号。源码用 null-byte 哨兵把转义星号临时替换掉,避免正则转换时把字面 * 当 wildcard。

const ESCAPED_STAR_PLACEHOLDER = '\x00ESCAPED_STAR\x00'
const ESCAPED_BACKSLASH_PLACEHOLDER = '\x00ESCAPED_BACKSLASH\x00'

这段代码证明,规则匹配不是简单 includes('*')。它要区分“通配符星号”和“用户想匹配字面星号”,否则权限规则本身会出现绕过或误拦。

权限管线:hasPermissionsToUseTool() 的三阶段

真正执行工具前,权限系统会走 hasPermissionsToUseTool()。原文把它拆成三阶段:规则验证、模式裁决、模式后处理。

阶段一:deny、ask 和工具自检先跑

阶段一是最防御性的部分。它检查工具级 deny、工具级 ask、工具自身 checkPermissions(),然后再检查内容级 ask 和安全检查。

// 简化后的行为结构
if (matchesToolLevelDenyRule(tool)) {
  return deny
}

if (matchesToolLevelAskRule(tool)) {
  return ask
}

const toolDecision = tool.checkPermissions(input, context)
if (toolDecision.behavior === 'deny') {
  return deny
}
if (toolDecision.behavior === 'ask') {
  return ask
}

if (matchesContentLevelAskRule(call)) {
  return ask
}

if (matchesSafetyCheck(call)) {
  return ask
}

这段伪源码证明:工具自己有第二道语义闸门。比如 Bash 要解析命令,FileEdit 要看路径和写入行为,PowerShell 要检查 PowerShell 特有风险。系统不会只用全局规则替代工具语义。

阶段一里有两个关键点:

  • 内容级 ask 规则在 bypassPermissions 下也必须提示。
  • .git/.claude/.vscode/、shell 配置文件等安全检查也具有 bypass 免疫性。

这说明 bypassPermissions 不覆盖用户显式表达的 ask 安全意图,也不覆盖系统认为必须确认的危险路径。

阶段二:模式和 allow 规则

阶段二才处理 bypassPermissions、allow 规则和工具自身 allow。

if (permissionMode === 'bypassPermissions') {
  return allow
}

if (matchesAllowRule(call)) {
  return allow
}

if (toolDecision.behavior === 'allow') {
  return allow
}

return ask

这段伪源码证明:bypassPermissions 不是最前面的万能通行证。它只在阶段一没有返回 deny 或强制 ask 之后才直接放行。

阶段三:ask 的后处理

阶段三处理的是“已经变成 ask 的请求怎么办”。

模式ask 的处理
dontAsk转成 deny,不弹确认
auto调用分类器裁决,安全则 allow,不安全或不确定则回到确认
其他模式弹出权限确认对话框

这个设计很重要:auto 分类器不是替代阶段一和阶段二,它只接管 ask 灰区。硬 deny 和 bypass 免疫的安全检查仍然在前面。

自动模式:先剥离危险 allow 规则

当进入 auto 模式,系统会临时剥离危险 Bash allow 规则。核心判断是 isDangerousBashPermission()

export function isDangerousBashPermission(
  toolName: string,
  ruleContent: string | undefined,
): boolean {
  if (toolName !== BASH_TOOL_NAME) {
    return false
  }
  if (ruleContent === undefined || ruleContent === '') {
    return true
  }
  const content = ruleContent.trim().toLowerCase()
  if (content === '*') {
    return true
  }
  // ...检查 DANGEROUS_BASH_PATTERNS
}

这段代码证明:Bash 工具级 allow、空内容、独立 * 都会被认为危险。进入 auto 模式时,这些规则会被临时剥离,离开 auto 再恢复。

危险模式还包括:

规则形态风险
Bash允许所有 shell 命令
Bash(*)等价于工具级 allow
Bash(python:*)允许任意 Python 代码
Bash(node *)允许任意 Node 命令
Bash(python -*)可能允许 python -c 这类任意代码执行

DANGEROUS_BASH_PATTERNS 包含多类解释器、shell、远程执行和包运行器,例如 pythonnoderubyperlnpxbunxnpm runbashshssh 等。内部构建还会额外检测 ghcurlwgetgitkubectlaws 等。

容易误解

auto 模式不是“有 allow 规则就照单全收”。进入 auto 前先剥离危险 allow,是为了避免分类器被一个过宽的历史授权绕过。

路径验证:拒绝语义不确定的路径

文件工具的风险往往不在“读写”两个字,而在路径。validatePath() 是路径权限验证入口。

flowchart TD A["输入路径"] --> B["清理引号,展开 ~"] B --> C{"Windows UNC 路径?"} C -->|是| X["拒绝"] C -->|否| D{"危险 tilde 变体?<br/>~root / ~+ / ~-"} D -->|是| X D -->|否| E{"包含 shell 展开?<br/>$VAR / %VAR% / =cmd"} E -->|是| X E -->|否| F{"glob 模式?"} F -->|写操作| X F -->|读操作| G["验证基目录"] F -->|否| H["解析绝对路径和符号链接"] G --> H H --> I["isPathAllowed()"]

共享路径防护:防止 UNC 泄漏凭据

Windows UNC 路径如 \\server\share 会触发系统自动发送 NTLM 凭据。攻击者可以诱导模型读取恶意 UNC 路径,从而泄漏认证哈希。

源码检测三类变体:

const backslashUncPattern = /\\\\[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i
const forwardSlashUncPattern = /(?<!:)\/\/[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i

这段代码证明:正斜杠 UNC 要用 (?&lt;!:) 排除 URL 的 https://。主机名使用排除集而不是白名单,是为了捕获 Unicode 同形异义字这类欺骗。

时序一致性:TOCTOU 防护

路径验证最核心的判断是:验证时和执行时的语义必须一致。

输入形态风险
~root/...验证时可能像相对路径,shell 执行时展开到 root
$HOME/.ssh/id_rsa验证时是字面字符串,执行时展开为真实敏感路径
%USERPROFILE%Windows 环境变量展开
=rgZsh equals 展开为可执行文件路径
写操作中的 glob验证的是模式,执行时可能匹配多个目标

源码的策略不是尝试完整模拟每种 shell 展开,而是直接拒绝这类语义不确定输入。这是 fail-closed:不确定就不要把它当安全路径。

isPathAllowed() 的顺序

路径规范化后,isPathAllowed() 做最终裁决。

顺序检查结果
1deny 规则立即拒绝
2内部可编辑路径plan、scratchpad、agent memory 等自动允许
3危险目录和文件.git/.claude/、shell rc 文件等需要确认
4工作目录read 自动通过,write 依赖 acceptEdits 等模式
5sandbox 写白名单sandbox 允许写的目录放行
6allow 规则匹配后放行

这说明路径规则也是多层的:不是“在 cwd 内就安全”,也不是“allow 规则永远最后说了算”。危险路径的 safety check 可以先把请求拉回确认。

危险文件和目录:bypass 也绕不过

源码把高风险对象列成常量。

export const DANGEROUS_FILES = [
  '.gitconfig', '.gitmodules',
  '.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile',
  '.ripgreprc', '.mcp.json', '.claude.json',
] as const

export const DANGEROUS_DIRECTORIES = [
  '.git', '.vscode', '.idea', '.claude',
] as const

这段代码证明:危险不是只看“是否在项目内”。.gitconfig 能配置 core.sshCommand,shell rc 文件会在 shell 启动时执行,.vscode/settings.json 可以影响任务和终端行为,.mcp.json 会影响外部工具连接。

这些写操作会被 checkPathSafetyForAutoEdit() 标记为 safetyCheck,具有 bypass 免疫性。部分安全检查在 auto 模式下可能标记 classifierApprovable: true,但不是所有跨边界风险都能交给分类器。

危险删除路径也有专门检测。isDangerousRemovalPath() 防止删除根目录、主目录、Windows 驱动器根目录及其直接子目录,比如 /usr/tmpC:\Windows

被遮蔽规则:配置了也可能永远不会生效

权限规则存在来源和优先级,就会出现遮蔽。比如高优先级规则 deny 了 Bash,低优先级规则 allow 了 Bash(git:*),后者永远到不了。

shadowedRuleDetection.tsUnreachableRule 表达这种情况。

export type UnreachableRule = {
  rule: PermissionRule
  reason: string
  shadowedBy: PermissionRule
  shadowType: ShadowType
  fix: string
}

这段代码证明:Claude Code 不只是执行规则,还会诊断规则配置是否自相矛盾。权限系统需要可解释,否则用户会以为“我已经 allow 了,为什么还是不行”。

权限更新:确认对话会写回规则

权限对话框里的“始终允许”不是只改内存状态。源码用 PermissionUpdate 描述可持久化的权限修改。

操作含义
addRules增加 allow / deny / ask 规则
replaceRules替换某组规则
removeRules删除规则
setMode修改权限模式
addDirectories增加允许目录
removeDirectories移除允许目录

每个更新还会指定目标存储,比如 localSettings。这就是为什么一次确认可以变成后续会话的本地规则,而不是只能影响当前工具调用。

状态和数据结构

结构关键字段影响
PermissionModedefaultacceptEditsplanbypassPermissionsdontAskautobubble决定默认姿态和 ask 后处理
PermissionRulesourceruleBehaviorruleValue表达不同来源的 allow / deny / ask 规则
PermissionRuleValuetoolNameruleContent区分工具级和内容级规则
ShellPermissionRuleexactprefixwildcardBash 命令规则匹配语义
PermissionUpdateaddRules 等六类操作把确认结果或配置变更持久化
UnreachableRuleruleshadowedByshadowTypefix诊断被遮蔽的无效规则
strippedDangerousRules危险 allow 规则列表auto 模式临时剥离,退出后恢复

设计取舍

取舍源码证据工程判断
三态而不是二态PermissionBehavior: allow / deny / ask不确定时交给用户,不冒充安全
deny 和安全检查前置hasPermissionsToUseToolInner() 阶段一高风险在模式裁决前先拦
bypassPermissions 不绕过所有检查内容级 ask 和 safety check bypass 免疫批量效率不能覆盖用户显式安全意图
工具有自己的 checkPermissions()阶段一调用工具自检Bash、Edit、PowerShell 的风险语义不同
Bash 规则分 exact / prefix / wildcardShellPermissionRule 联合类型权限规则要表达命令级粒度
auto 模式剥离危险 allowisDangerousBashPermission()防止过宽历史授权绕过分类器
路径验证拒绝 shell 展开validatePath()$%= 等保守拒绝避免验证时和执行时语义不一致
危险路径单独列常量DANGEROUS_FILESDANGEROUS_DIRECTORIES工作区内也可能有代码执行入口
遮蔽规则可诊断UnreachableRule权限系统要能解释配置为何不生效

读源码抓手

  1. 先读 types/permissions.ts,把 PermissionModePermissionRulePermissionUpdate 三个类型弄清楚。
  2. 再看 getNextPermissionMode()transitionPermissionMode(),理解模式切换不是纯 UI。
  3. 接着读 permissionRuleValueFromString(),确认配置里的 ToolName(content) 怎么进结构化规则。
  4. 然后读 shellRuleMatching.ts,重点看 :**\* 的不同语义。
  5. 核心部分读 hasPermissionsToUseTool(),按阶段一、阶段二、阶段三画出分支。
  6. 之后看 pathValidation.ts,尤其是 UNC、tilde、shell 展开、glob 写操作这些 fail-closed 分支。
  7. 最后读 isDangerousBashPermission()stripDangerousPermissionsForAutoMode(),理解 auto 模式为什么要先清理过宽 allow。

小结

小结

Claude Code 的权限系统是一条副作用前置的安全管线。

  • 权限模式只决定默认姿态,真正裁决来自规则、工具自检、路径验证和模式后处理的组合。
  • allow / deny / ask 三态让系统可以表达“可执行”“不可执行”和“不确定,需要人确认”。
  • Bash 权限规则支持 exact、prefix、wildcard 三种匹配,避免只按工具名粗暴放行。
  • bypassPermissions 在阶段二才放行,无法覆盖前置 deny、内容级 ask 和危险路径安全检查。
  • 路径验证优先拒绝语义不确定的输入,防止 shell 展开、UNC、glob 写操作造成验证和执行不一致。
  • auto 模式不是全自动裸奔,它进入前会剥离危险 allow,把灰区交给分类器而不是让历史授权绕过安全边界。