Claude Code 源码深度讲解 · 必修篇 3/5

Permission 决策系统

Permission 决定 agent 能否进企业。Claude Code 用6 种模式 × 7 层规则 × 多分类器融合构造了一套精细的决策机制, 同时把"安全不变量"(如"子 agent 只能提升限制")直接编码进了代码。

01引言:安全是企业可用性的门槛

agent 能改文件、跑命令、调网络。如果没有权限系统,一个误用就能擦库、删分支、泄漏凭证。

1.1 Permission 要回答的 4 个问题

问题Claude Code 机制
这个操作现在能不能做?canUseTool → PermissionResult
是谁做的决策?rule 的 source 字段(7 层之一)
用户能否改变决策?PermissionMode 提供快速调整
能不能事前预审?Plan Mode + 分类器

1.2 两个核心设计哲学

Fail-closed(默认拒绝)
没有明确规则匹配时,默认进人工对话框,而不是默认放行。这是把"出了事谁背锅"的风险从用户转到设计层。
Layered by policy authority(按权威分层)
企业策略(managed/policy)权威最高,用户配置其次,session 临时决策最低。权威层可以强制覆盖,用户无法越过。

026 种 PermissionMode

PermissionMode 是整个决策的"上下文开关" — 同样一个 Edit 操作,在不同 mode 下结果完全不同。

2.1 完整 6 种模式(PermissionMode.ts:44)

Mode符号语义谁设置
default(无)标准:按规则 → 分类器 → 对话框启动默认
plan禁止所有写操作,只能 Read / Grep / Glob/plan 或 EnterPlanMode
acceptEdits⏵⏵Edit/Write/NotebookEdit 自动通过,其他仍需问/accept-edits 或 Shift+Tab
bypassPermissions⏵⏵一切工具自动通过(危险)--dangerously-skip-permissions CLI
dontAsk⏵⏵类似 bypass,但仅主线程,子 agent 仍问特殊场景
auto(ant-only)⏵⏵自动模式:TRANSCRIPT_CLASSIFIER 做决策/auto
bubble(内部)子 agent 的提示"冒泡"到父终端处理子 agent 自动

2.2 模式转换状态机

default plan只读探索 acceptEdits自动编辑 bypassPermissions一切自动 auto分类器托管 /plan ExitPlanMode Shift+Tab --dangerously-skip-permissions /auto
PermissionMode 状态机,default 是中枢,其他模式都通过 default 跳转

2.3 Plan Mode 的特殊规则

// Plan mode 下,filterToolsForAgent 会特殊放行 ExitPlanMode
if (toolMatchesName(tool, EXIT_PLAN_MODE_V2_TOOL_NAME) && permissionMode === 'plan') {
  return true   // 即使不在允许工具里也放行
}
// 否则 Plan mode 下 Edit/Write/Bash(writing 命令)都被拒

03规则语法三级

权限规则用同一种字符串语法表达,从粗粒度到细粒度三级。

3.1 三种形式

// 1. 工具级 — 最粗
"Read"            // 允许所有 Read 调用
"Bash"            // 允许所有 Bash 调用

// 2. 工具 + pattern — 中粒度
"Bash(npm:*)"     // 允许 npm 开头的命令
"Bash(git status)"  // 精确命令
"Read(/src/**)"   // 只允许读 src 目录

// 3. 工具 + 规则内容 — 最细
"Agent(researcher,reviewer)"   // 只能派这两种 agent
"Edit(*.md)"                    // 只能编辑 markdown

3.2 permissionRuleValueFromString 解析

// permissionRuleParser.ts
permissionRuleValueFromString("Bash(npm:*)")
// → { toolName: "Bash", ruleContent: "npm:*" }

permissionRuleValueFromString("Read")
// → { toolName: "Read", ruleContent: undefined }

3.3 allow vs deny vs ask

类别作用优先级
allow绕过对话框直接放行中(被 deny 覆盖)
deny直接拒绝,不显示对话框高(覆盖 allow)
ask强制显示对话框(即使有 allow)最高

3.4 pattern 匹配语法

// Bash pattern — 基于 shell-quote 解析
"npm:*"            // npm 开头,含子命令
"git commit"       // 精确匹配
"*.test.ts"        // glob

// Path pattern — 基于 picomatch
"/src/**/*.ts"     // 递归 glob
"!/node_modules/**"   // 排除

047 层规则来源

每条规则都有来源层,决定它的权威性。冲突时按权威合并。

4.1 7 层优先级(由低到高)

7. policy(企业级) 不可被用户覆盖 6. managed(managed-settings.json) MDM 推送 5. flag(--allowedTools CLI) 本次启动的临时授权 4. project(.claude/settings.json) 团队共享,git 管理 3. user(~/.claude/settings.json) 用户全局 2. session(/allow 临时命令) 对话中加的临时规则 1. cliArg(SDK --allowedTools) SDK 消费者传入
7 层规则来源按权威从下往上递增,合并时高层覆盖低层

4.2 ToolPermissionContext 结构

type ToolPermissionContext = {
  mode: PermissionMode
  alwaysAllowRules: {
    cliArg: string[],
    session: string[],
    localSettings: string[],
    projectSettings: string[],
    userSettings: string[],
    flagSettings: string[],
    managedSettings: string[],
    policySettings: string[],
  },
  alwaysDenyRules: { ...same 7 layers }
  additionalWorkingDirectories: Map<string, WorkingDir>
  shouldAvoidPermissionPrompts?: boolean
  awaitAutomatedChecksBeforeDialog?: boolean
}

4.3 来源追溯的实际用处

// 当 deny 发生时,能告诉用户"是哪一层禁止的"
const denyRule = getDenyRuleForAgent(context, AGENT_TOOL_NAME, agentType)
throw new Error(
  `Agent type '${agentType}' has been denied by permission rule '${denyRule.value}'
   from ${denyRule.source}.`
)
// 显示:from managedSettings → 用户知道要联系 IT
// 显示:from projectSettings → 用户知道是项目规则

05核心决策流程 5 步

canUseTool 判断一个工具调用能否进行时,按这 5 步走,任一步命中即短路。

canUseTool 调用 ① 匹配 deny 规则? 任意 7 层 deny ② mode 短路? bypass/acceptEdits/plan allow / deny ③ 匹配 allow 规则? 任意 7 层 allow ④ 分类器判定 yoloClassifier / bash classifier ⑤ 用户对话框
5 步决策:deny → mode → allow → 分类器 → 对话框。任一步短路即完成

5.1 为什么 deny 在 allow 之前

安全第一原则
即使用户在 session 里手滑加了 allow: "Bash",managed 层的 deny: "Bash(rm:*)" 仍然会拦住。 deny 永远不会被 allow 覆盖

5.2 mode 短路的特殊路径

switch (mode) {
  case 'bypassPermissions':
    return { behavior: 'allow', updatedInput: input }
  case 'plan':
    // Plan 模式下所有写工具 hard deny,除了 ExitPlanMode
    if (isWriteTool(tool)) return { behavior: 'deny', message: 'Plan mode is read-only' }
  case 'acceptEdits':
    // Edit/Write/NotebookEdit 直接通过,其他继续走流程
    if (isEditTool(tool)) return { behavior: 'allow', updatedInput: input }
  // default / dontAsk 不短路,继续 step 3
}

06canUseTool 调用链

src/hooks/useCanUseTool.ts 定义的 CanUseToolFn 是整个决策入口。

6.1 签名

type CanUseToolFn = (
  tool: Tool,
  input: unknown,
  toolUseContext: ToolUseContext,
  assistantMessage: AssistantMessage,
  requestPrompt?: (source, summary?) => PromptResponse,
) => Promise<PermissionResult>

6.2 调用来源

  • checkPermissionsAndCallTool 在工具执行前
  • streamedCheckPermissionsAndCallTool 在流式执行器里
  • AgentTool 派子 agent 前检查 Agent 权限

6.3 通用流程

async function canUseTool(tool, input, context, assistantMessage): Promise<PermissionResult> {
  // 1. tool 内置 checkPermissions(每个工具可自定义)
  const toolDecision = await tool.checkPermissions?.(input, context)
  if (toolDecision) return toolDecision

  // 2. Filesystem 特殊规则(路径 + 黑白名单)
  if (isFilesystemTool(tool)) {
    return checkFilesystemPermission(tool, input, context)
  }

  // 3. 常规规则匹配
  const ruleResult = checkPermissionRules(tool, input, context)
  if (ruleResult) return ruleResult

  // 4. 分类器
  const classifierResult = await runClassifiers(tool, input, context)
  if (classifierResult.decision !== 'prompt') return classifierResult

  // 5. 用户对话框
  return await showPermissionDialog(tool, input, context)
}

07Filesystem 特殊决策树

filesystem.ts 单文件 62KB,处理读/写/执行三个维度的路径决策。

7.1 决策树

  1. 路径合法性

    必须是绝对路径;没有 null byte / shell 元字符的 escape 攻击

  2. dangerous path 黑名单

    ~/.ssh、~/.aws、/etc/passwd、/etc/shadow 等硬编码

  3. additionalWorkingDirectories 白名单

    用户显式授权的额外目录(/add-dir 命令)

  4. cwd + worktree 自动允许

    当前工作目录总是被允许

  5. 用户规则匹配

    Read("/src/**") 之类的自定义规则

7.2 例子

// cwd = /Users/me/project
Read('/Users/me/project/src/foo.ts')    → allow (cwd 内)
Read('/Users/me/.ssh/id_rsa')              → deny (dangerous 黑名单)
Read('/tmp/output.txt')                   → prompt (cwd 外,无规则)
Read('../other-project/config')           → deny (非绝对)
Edit('/Users/me/project/src/foo.ts')    → prompt (default mode 下)
Edit('/Users/me/project/src/foo.ts')    → allow (acceptEdits mode 下)

08pathValidation

pathValidation.ts 专门处理路径安全校验,防止各种路径穿越攻击。

8.1 检查项

检查攻击向量
必须绝对路径相对路径可绕过白名单
解析 .. 规范化/foo/../../etc/passwd
解析 symlink 后检查/tmp/link → /etc/shadow
null byte 检查/foo\0.txt 截断
Windows UNC / drive letter跨磁盘访问

8.2 additionalWorkingDirectories 白名单

// 用户 /add-dir /path/to/workspace
// 把该目录加入 toolPermissionContext.additionalWorkingDirectories

// 之后该目录下的 Read/Edit 都 allow,跟 cwd 同级
additionalWorkingDirectories.set('/path/to/workspace', {
  mode: 'full',    // or 'readonly'
  addedAt: Date.now(),
  source: 'session',
})

09规则冲突检测

shadowedRuleDetection.ts 在规则加载时警告"这条规则永远不会生效"。

9.1 冲突类型

// 冲突 1:同层 deny 覆盖 allow
allow: ["Bash(npm:*)"]
deny:  ["Bash"]                  // ← allow 永远不生效

// 冲突 2:高层 deny 覆盖低层 allow
[projectSettings] allow: ["Bash(rm:*)"]
[managedSettings] deny:  ["Bash(rm:*)"]    // ← project 的 allow 无效

// 冲突 3:重复规则
[projectSettings] allow: ["Read"]
[userSettings]    allow: ["Read"]         // ← 冗余,不是错但警告

9.2 加载时的 warning

// Claude Code 启动时输出
Warning Rule 'Bash(npm:*)' in projectSettings is shadowed by
  'Bash' in same layer (deny). It will never apply.

10YOLO 分类器

bash 命令千变万化,写规则列完所有安全命令不现实。YOLO classifier 用 LLM 自动判断命令是否危险。

10.1 classifier 流程

  1. 构建 transcript:buildTranscriptForClassifier 把命令 + 上下文组装成 prompt
  2. 调用 Haiku:用小模型分类(成本 < 1/100 主模型)
  3. 解析结果:输出 YOLO / ASK / BLOCK
  4. 缓存决策:同一命令不重复分类

10.2 分类 prompt(简化)

// yolo-classifier-prompts/*.md
You are a security classifier. Decide if this command is safe:

Command: {{command}}
Working dir: {{cwd}}
Recent context: {{recent_turns}}

Reply with one of:
- YOLO: clearly safe (ls, cat, git status, npm test, ...)
- ASK: requires human judgment (rm, curl, git push, ...)
- BLOCK: known dangerous (rm -rf /, dd, mkfs, ...)

10.3 bash classifier 与 YOLO 不同

bashClassifier.ts 是纯规则引擎,用 shell-quote 解析命令,然后按黑白名单匹配。 YOLO 是 LLM-based,用于 bashClassifier 返回 ASK 时的二次判定。

11分类器融合

classifierDecision.ts 把多个分类器的结果按权重融合。

11.1 融合逻辑

// 多分类器同时跑:
const bashRule = bashClassifier(command)          // rule-based
const yolo = await yoloClassifier(command, ctx)    // LLM-based
const dangerousPatterns = matchDangerousPatterns(command)  // regex

// 融合规则:最严格者胜
if (dangerousPatterns || bashRule === 'BLOCK' || yolo === 'BLOCK') return 'deny'
if (bashRule === 'ASK' || yolo === 'ASK') return 'prompt'
if (bashRule === 'YOLO' && yolo === 'YOLO') return 'allow'
return 'prompt'   // 不确定就问

11.2 dangerous patterns 硬编码

// dangerousPatterns.ts — 无论如何都不允许的命令
const DANGEROUS_PATTERNS = [
  /rm\s+-rf\s+\//,            // rm -rf /
  /:(){ :|:& };:/,           // fork bomb
  /dd\s+if=.*of=\/dev\/(sd|nvme|hd)/,   // dd 写盘
  /mkfs\./,                  // 格式化
  /curl.*\|\s*(sh|bash)/,    // curl pipe sh
  ...
]

12特殊 flags 语义

ToolPermissionContext 里几个 boolean flag 的细微语义。

12.1 shouldAvoidPermissionPrompts

// 默认值 = !isInteractive
// 含义:不能显示用户对话框 → "prompt" 决策直接变 "deny"
if (ctx.shouldAvoidPermissionPrompts && result.behavior === 'prompt') {
  return { behavior: 'deny', message: 'Cannot prompt in non-interactive mode' }
}

场景:SDK、异步 agent、后台任务。

12.2 awaitAutomatedChecksBeforeDialog

// 异步 agent 的特殊模式(bubble):
// 1. 先跑所有自动检查(分类器、hook)
// 2. 只有所有自动检查都返回 prompt 时才显示对话框
// 3. 保证用户不被"本来能自动决定的"频繁打扰

12.3 bypassPermissionsKillswitch

// bypassPermissionsKillswitch.ts
// 企业可以硬关闭 bypassPermissions 模式
function isBypassModeAvailable() {
  if (process.getuid?.() === 0) return false      // root 禁用
  if (isInSandbox()) return false
  if (isKilledByManagedSettings()) return false
  return true
}

13子 agent 权限继承

安全不变量:子 agent 只能提升限制,不能降低限制。

13.1 模式继承规则(runAgent.ts:421)

if (
  agentPermissionMode &&                              // agent 自己定义了 mode
  state.toolPermissionContext.mode !== 'bypassPermissions' &&
  state.toolPermissionContext.mode !== 'acceptEdits' &&
  !(feature('TRANSCRIPT_CLASSIFIER') && state.toolPermissionContext.mode === 'auto')
) {
  // 允许覆盖 — 比如 agent 想进 plan 模式
  toolPermissionContext = { ...toolPermissionContext, mode: agentPermissionMode }
}
// 如果父是 bypass/acceptEdits/auto,子 agent 不能把它改严 — 保持父的"放行"属性

13.2 为什么这样设计

用户意图至上
用户开了 bypass 意味着"我相信这个 session 全部操作"。如果子 agent 能"偷偷"把自己降到 plan, 会让用户期望的"全自动"工作流被打断。信任一旦给出,就不被子系统撤销

13.3 allowedTools 独立授权

// runAgent.ts:475 — agent 的工具可以单独授权
if (allowedTools !== undefined) {
  toolPermissionContext = {
    ...toolPermissionContext,
    alwaysAllowRules: {
      cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg,   // 保留
      session: [...allowedTools],    // session 层换成 agent 专属
    },
  }
}

目的:让 agent 只看到"SDK 给的 + agent 自己申请的"权限,父的 session 临时授权不会泄漏给它。

14PermissionResult 数据流

type PermissionResult =
  | { behavior: 'allow', updatedInput: unknown, decisionReason?: string }
  | { behavior: 'deny', message: string, interrupt?: boolean }
  | { behavior: 'ask', message: string }   // 强制 prompt,即使有 allow

14.1 updatedInput 的关键语义

allow 结果里的 updatedInput 可以改变工具输入。典型用途:

  • PreToolUse hook 返回 updatedInput: { path: "/safe/path" } 修正参数
  • 路径规范化:相对路径 → 绝对路径
  • 敏感参数脱敏:API key 替换为 env 引用

14.2 interrupt flag

// deny + interrupt: true → 不只拒绝这次调用,还要停掉整轮
{ behavior: 'deny', message: 'Security violation', interrupt: true }
// 触发 abortController.abort(),整个 agent 回合终止

15Plan Mode 耦合

Plan Mode 跟 permission 系统深度耦合 —— 进入 plan 模式本身就是权限决策的一部分。

15.1 生命周期

  1. 进入

    用户 Shift+Tab 或 EnterPlanMode tool → mode = 'plan'

  2. Plan 期

    模型只能 Read / Grep / Glob / WebFetch / Agent(仅 Explore/Plan 类只读 agent)

  3. 产出计划

    ExitPlanModeV2Tool 生成计划文档 → 让用户 approve

  4. 批准

    用户 approve → mode 回到 default/acceptEdits,计划附件作为后续 prompt 的一部分

15.2 allowedPrompts 预审

// ExitPlanModeV2 可声明计划需要的权限
ExitPlanMode({
  allowedPrompts: [
    { tool: 'Bash', prompt: 'run tests' },
    { tool: 'Bash', prompt: 'install dependencies' },
  ]
})
// 用户 approve 时相当于一次性授权这些用途
// 后续 Bash 调用自动匹配,无需再 prompt

16rm -rf 在 5 种模式下的命运

用同一个操作串起整个系统,看各模式下的不同处理。

16.1 Bash("rm -rf /tmp/foo")

模式结果原因
defaultprompt无 allow 规则 → classifier → ASK → 对话框
plandenyplan 模式禁写
acceptEditspromptacceptEdits 只对 Edit/Write 工具短路,Bash 不在其内
bypassPermissionsallowbypass 全放行(killswitch 未触发时)
auto取决于 classifiertranscript classifier 判断,可能 allow 可能 prompt

16.2 Bash("rm -rf /")

模式结果原因
所有deny匹配 DANGEROUS_PATTERNS 硬编码,即使 bypass 也拒
硬编码 deny 不可被任何模式覆盖
这是整个系统唯一的"全局绝对 deny"层,在所有 mode / rule 之上。 dangerousPatterns.ts 就是这个系统的"安全网"。

17可迁移设计原则

  1. Fail-closed by default — 不明确就拒绝,比不明确就放行安全得多
  2. Layered authority — 企业 > 用户 > 会话,低层无法覆盖高层的 deny
  3. deny always beats allow — 即使同层,deny 永远胜
  4. 硬编码危险模式 — 某些命令应该无论规则怎么配都禁,作为最后安全网
  5. Mode 是快捷方式 — plan/bypass 等 mode 是"快速调整一组规则"的语法糖
  6. 分类器 + 规则融合 — 规则精确但不全,分类器覆盖长尾;融合时取最严
  7. 子 agent 只能提升限制 — 用户的信任决策不被子系统撤销
  8. 来源追溯 — deny 时告诉用户"是哪一层禁的",便于沟通和修复
最大的启示
permission 不是事后加的安全模块,而是跟 tool 系统共生的核心机制。 在 agent 系统设计之初就要把它纳入架构,否则"安全"永远是 bolt-on,永远有绕过路径。