7 · 权限系统

utils/permissions/ 目录下有 21 个文件,核心的 permissions.ts 有 51 KB。这一章讲清楚「凭什么让这个工具调用跑起来」这个问题的完整答案。

7.1 六种权限模式

const PERMISSION_MODE_CONFIG: Partial<Record<PermissionMode, PermissionModeConfig>> = {
  default:           { title: 'Default',             symbol: '',    color: 'text' },
  plan:              { title: 'Plan Mode',           symbol: '⏸',   color: 'planMode' },
  acceptEdits:       { title: 'Accept edits',        symbol: '⏵⏵',  color: 'autoAccept' },
  bypassPermissions: { title: 'Bypass Permissions',  symbol: '⏵⏵',  color: 'error' },
  dontAsk:           { title: "Don't Ask",           symbol: '⏵⏵',  color: 'error' },
  ...(feature('TRANSCRIPT_CLASSIFIER') ? {
  auto:              { title: 'Auto mode',           symbol: '⏵⏵',  color: 'warning' },
  } : {}),
}

claude-code/src/utils/permissions/PermissionMode.ts

模式行为
default默认。危险操作弹确认框问用户
plan
计划模式
只允许只读操作。模型先做调研、出方案,用户批准后才切回执行模式。用来防止「模型理解错了就直接动手」
acceptEdits
接受编辑
文件编辑类操作自动放行,其他仍然要问。比默认宽松,比 bypass 严格
bypassPermissions
跳过权限
对应 --dangerously-skip-permissions但仍有一层绕不过,见 7.2
dontAsk
不要问我
把所有「需要询问」直接转成「拒绝」。和 bypass 相反 —— bypass 是「都放行」,这个是「都拒绝」。适合完全不想被打扰又不想冒险的场景
auto
自动模式
内部版特性。用模型分类器代替人来做安全判断,见 7.3

还有一个类型上的区分很讲究:

export function isExternalPermissionMode(mode: PermissionMode): mode is ExternalPermissionMode {
  if (process.env.USER_TYPE !== 'ant') return true      // 外部用户没有 auto,所以永远为真
  return mode !== 'auto' && mode !== 'bubble'
}

export function toExternalPermissionMode(mode: PermissionMode): ExternalPermissionMode {
  return getModeConfig(mode).external      // auto 对外映射成 default
}

内部模式对外要有一个映射。auto 模式对外部接口报告成 default —— 这样开发工具包的使用者不会看到一个他们理解不了、也无法设置的模式值。

7.2 十步决策级联

权限决策级联
权限决策级联 — 玫红色虚线框内的 1d 到 1g 排在 2a(bypass 模式)之前。这个顺序就是整个设计的关键点击放大

核心函数 hasPermissionsToUseToolInner() 是一条严格有序的判定链,从上到下逐条检查,第一个命中的直接决定结果:

步骤检查什么结果
0中止信号已拉拒绝
1a整个工具被拒绝规则命中DENY
1b整个工具被询问规则命中ASK
例外:如果这条 Bash 命令能在沙箱里安全执行,跳过继续往下
1c调用工具自己的 checkPermissions()拿到工具自己的判断,不直接出结果
↓ ↓ ↓ 以下四步是 bypass 免疫层 ↓ ↓ ↓
1d工具自己明确说了「拒绝」DENY
1e工具声明「必须有人在场」ASK
1f用户显式配了内容级询问规则ASK
1g安全检查:碰到敏感路径ASK
↑ ↑ ↑ 以上四步是 bypass 免疫层 ↑ ↑ ↑
2abypassPermissions 模式ALLOW
2b整个工具被允许规则命中ALLOW
3都没命中ASK(默认落到人工确认)

bypass 免疫层的四条源码注释

// 1d. Tool implementation denied (catches bash subcommand denies wrapped ...)
// 1e. Tool requires user interaction even in bypass mode
// 1f. Content-specific ask rules from tool.checkPermissions take precedence
//     over bypassPermissions mode. When a user explicitly configures a
//     content-specific ask rule (e.g. Bash(npm publish:*)), the tool's
//     checkPermissions returns {behavior:'ask', ...}. This must be respected
//     even in bypass mode, just as deny rules are respected at step 1d.
// 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
//     bypass-immune — they must prompt even in bypassPermissions mode.
这四条各自守什么
步骤守的是什么
1d工具作者的判断。工具最了解自己的操作有多危险,它的拒绝不能被外部覆盖
1e物理必要性。「向用户提问」这个工具,没人在场就无法完成,放行也没意义
1f用户更具体的意图。用户开 bypass 是想说「别拿常规操作烦我」,但他专门配了 Bash(npm publish:*) 要问,说明这一条是他特意留的闸门。更具体的配置优先于更笼统的配置
1g不可挽回的破坏。删掉 .git/ 等于丢掉整个版本历史;改掉 .claude/ 等于智能体自己改自己的权限配置;改 shell 启动脚本等于留后门

「绕过权限」不等于「绕过一切」。这是一个成熟的产品判断:给用户「关掉烦人确认」的自由,但不给「一键自毁」的自由。如果不留这个底座,第一个不小心让智能体删掉自己 .git 的用户,会永久失去对这个产品的信任。

7.3 自动模式:模型分类器 + 三级快速通道

当判定落到 ASK 且当前是自动模式时,Claude Code 不弹窗,而是再调一次模型来判断这个动作安不安全。这个专用调用叫「分类器」。

但分类器不便宜 —— 每个工具调用一次额外的接口请求。所以前面挡了几层:

if (feature('TRANSCRIPT_CLASSIFIER') &&
    (appState.toolPermissionContext.mode === 'auto' ||
     (appState.toolPermissionContext.mode === 'plan' && isAutoModeActive()))) {

  // 拦截 1:安全检查命中、且这类检查"分类器无权批准"
  if (result.decisionReason?.type === 'safetyCheck' &&
      !result.decisionReason.classifierApprovable) {
    if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
      return { behavior:'deny', message: result.message,
               decisionReason: { type:'asyncAgent',
                 reason:'Safety check requires interactive approval and permission '
                      + 'prompts are not available in this context' } }
    }
    return result       // 保持 ASK
  }

  // 拦截 2:工具声明"必须有人在场"
  if (tool.requiresUserInteraction?.() && result.behavior === 'ask') return result

  // 拦截 3:PowerShell(除非编译期开了特殊开关)
  if (tool.name === POWERSHELL_TOOL_NAME && !feature('POWERSHELL_AUTO_MODE')) { ... }

  // 快速通道 ①:伪装成 acceptEdits 模式再问一次工具
  if (result.behavior === 'ask' &&
      tool.name !== AGENT_TOOL_NAME && tool.name !== REPL_TOOL_NAME) {
    const acceptEditsResult = await tool.checkPermissions(parsedInput, {
      ...context,
      getAppState: () => ({ ...context.getAppState(),
        toolPermissionContext: { ...state.toolPermissionContext,
                                 mode: 'acceptEdits' as const } }),
    })
    if (acceptEditsResult.behavior === 'allow') {
      logEvent('tengu_auto_mode_decision', { decision:'allowed', fastPath:'acceptEdits', ... })
      return { behavior:'allow', updatedInput: acceptEditsResult.updatedInput ?? input,
               decisionReason: { type:'mode', mode:'auto' } }
    }
  }

  // 快速通道 ②:安全工具白名单
  if (classifierDecisionModule!.isAutoModeAllowlistedTool(tool.name)) {
    logEvent('tengu_auto_mode_decision', { decision:'allowed', fastPath:'allowlist', ... })
    return { behavior:'allow', updatedInput: input, decisionReason: { type:'mode', mode:'auto' } }
  }

  // 都没命中 → 真的调分类器
  const action = formatActionForClassifier(tool.name, input)
  setClassifierChecking(toolUseID)
  let classifierResult
  try {
    classifierResult = await classifyYoloAction(
      context.messages,                    // ★ 完整对话记录
      action,
      context.options.tools,
      appState.toolPermissionContext,
      context.abortController.signal)
  } finally {
    clearClassifierChecking(toolUseID)
  }
  ...
}

claude-code/src/utils/permissions/permissions.ts

快速通道 ① 的巧妙之处

把权限模式临时伪装成 acceptEdits,再问工具一次。逻辑是:如果连宽松模式都会放行这个操作,说明它确实无害,没必要付费问分类器。

但两个工具被明确排除:

「Skip for Agent and REPL — their checkPermissions returns 'allow' for acceptEdits mode, which would silently bypass the classifier. REPL code can contain VM escapes between inner tool calls; the classifier must see the glue JavaScript, not just the inner tool calls.」

译:跳过 Agent 和 REPL 工具 —— 它们的权限检查在 acceptEdits 模式下返回「允许」,会静默绕过分类器。REPL 工具的代码可能在内部工具调用之间夹带虚拟机逃逸;分类器必须看到那些胶水 JavaScript 代码,而不只是内部的工具调用。

这是「组合安全 ≠ 单元安全」的绝佳例子。A 安全、B 安全,不代表「先 A 再 B,中间夹一段自定义逻辑」也安全。

分类器看的是完整对话记录

注意 classifyYoloAction(context.messages, ...) 的第一个参数:整个对话记录,不只是这一条命令。

这很关键。一条 rm -rf build/ 命令,在「用户要求清理构建产物」的语境下是安全的,脱离语境就无法判断。但这也带来一个问题:完整对话记录可能有几万 token,塞进分类器会爆。

所以工具接口里有一个专门为此服务的方法:

/**
 * Returns a compact representation of this tool use for the auto-mode
 * security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content`
 * for Edit. Return '' to skip this tool in the classifier transcript
 * (e.g. tools with no security relevance). May return an object to avoid
 * double-encoding when the caller JSON-wraps the value.
 */
toAutoClassifierInput(input: z.infer<Input>): unknown

每个工具自己提供只保留安全语义的压缩表示:Bash 给命令行文本,Edit 给「路径 + 新内容」,没有安全含义的工具返回空串直接不进视野。

连续拒绝追踪:打破僵局

// 任何一次成功放行都重置连续拒绝计数
if (result.behavior === 'allow') {
  const currentDenialState = context.localDenialTracking ?? appState.denialTracking
  if (appState.toolPermissionContext.mode === 'auto' &&
      currentDenialState && currentDenialState.consecutiveDenials > 0) {
    const newDenialState = recordSuccess(currentDenialState)
    persistDenialState(context, newDenialState)
  }
  return result
}

连续被拒达到阈值时,系统不再信任分类器,回退到人工确认。防的是这种僵局:分类器因某种误判一直拒绝,模型不明白为什么,就一直换写法重试 —— 双方都在烧钱但永远推进不了。

注意 context.localDenialTracking ?? appState.denialTracking 这个回退:

「Local denial tracking state for async subagents whose setAppState is a no-op. Without this, the denial counter never accumulates and the fallback-to-prompting threshold is never reached.」

译:为异步子智能体准备的本地拒绝追踪状态 —— 它们的全局状态写入函数是空操作。没有这个,拒绝计数永远不会累加,「回退到人工确认」的阈值也就永远达不到。

这是一个典型的「架构隔离带来的副作用」:为了让子智能体不污染主线程状态,它的 setAppState 被设成了空操作。但这也意味着任何依赖状态累加的机制在子智能体里都失效了。所以要给它一个本地的副本。

7.4 权限规则的语法

用户可以在配置文件里写权限规则。语法有两个层次:

写法含义
Bash整工具级。匹配所有 Bash 调用
Bash(git:*)内容级。只匹配 git 开头的命令
Bash(npm publish:*)只匹配 npm publish 开头的命令
Edit(src/**)只匹配 src 目录下的文件编辑
mcp__serverMCP 服务级前缀。匹配该服务下的所有工具

三类规则:alwaysAllowRules(总是允许)、alwaysDenyRules(总是拒绝)、alwaysAskRules(总是询问)。

整工具级的拒绝规则会在「模型看到工具之前」就生效

/**
 * Filters out tools that are blanket-denied by the permission context.
 * A tool is filtered out if there's a deny rule matching its name with no
 * ruleContent (i.e., a blanket deny for that tool).
 *
 * Uses the same matcher as the runtime permission check (step 1a), so MCP
 * server-prefix rules like `mcp__server` strip all tools from that server
 * before the model sees them — not just at call time.
 */
export function filterToolsByDenyRules<T>(tools, permissionContext): T[] {
  return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}

这是一个重要的区分:「整工具级拒绝」不是在调用时拦截,而是让这个工具根本不出现在模型的工具清单里

两者的差别很大:
· 调用时拦截 → 模型会尝试调用、被拒、然后困惑地换个方式再试,浪费好几轮
· 不出现在清单里 → 模型压根不知道有这个能力,直接走别的路

而内容级规则(Bash(git:*))无法在清单层面过滤 —— 因为 Bash 工具本身要保留,只是某些参数要拦。所以它只能在调用时判定。

影子规则检测

utils/permissions/ 里有一个文件叫 shadowedRuleDetection.ts(影子规则检测)。它解决的问题是:

用户配了两条规则: 1. 允许 Bash(git:*) —— 允许所有 git 命令 2. 询问 Bash(git push:*) —— 但 git push 要问我一次 如果匹配顺序不对,第 2 条就永远不会生效 —— 因为第 1 条更笼统,会先命中并放行。 这条规则就被"遮蔽"(shadowed)了。 影子规则检测负责发现这种配置错误并提醒用户。

7.5 沙箱与只读命令判定

只读命令自动放行

utils/shell/readOnlyCommandValidation.ts,66.7 KB。它的职责是判断「这条 shell 命令是不是只读的」。如果是,就可以自动放行,不打扰用户。

这件事比看起来难得多,因为要处理:

所以 utils/bash/ 目录下有一个完整的 shell 语法解析器:bashParser.ts(128 KB)+ ast.ts(109 KB)。它把 shell 命令解析成抽象语法树,然后在树上做分析,而不是用正则匹配字符串。

而且还有一个实验性的替代实现:编译期开关里能看到 TREE_SITTER_BASHTREE_SITTER_BASH_SHADOW —— 后者的命名(shadow,影子)说明他们在用影子模式验证新解析器:两个解析器同时跑,结果不一致时记录下来,但仍然用旧的那个的结果。这样可以在零风险的前提下收集新实现的准确率数据。

操作系统级沙箱

在 macOS 上,Claude Code 使用系统自带的 sandbox-exec 机制(也叫 seatbelt)。它可以在进程启动时施加一份策略文件,限制这个进程能访问哪些路径、能不能联网。

权限判定链的第 1b 步有一个特殊分支就和沙箱有关:

// 1b. Check if the entire tool should always ask for permission
const askRule = getAskRuleForTool(...)
if (askRule) {
  // 当"沙箱内自动放行"开启时,能被沙箱化的命令跳过询问规则,
  // 通过 Bash 的 checkPermissions 自动放行。
  // 那些不会被沙箱化的命令(排除列表里的、显式禁用沙箱的)仍然遵守询问规则。
  if (!canSandboxAutoAllow) {
    return { behavior:'ask', ... }
  }
  // 否则继续往下,让 Bash 的 checkPermissions 处理具体命令的规则
}

逻辑是:如果这条命令会在沙箱里跑,那么即使它「看起来危险」也没关系 —— 沙箱会兜住。所以可以跳过询问。这是「用更强的隔离手段换取更少的打扰」。

7.6 权限判定的完整数据结构

export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  alwaysAllowRules: ToolPermissionRulesBySource
  alwaysDenyRules:  ToolPermissionRulesBySource
  alwaysAskRules:   ToolPermissionRulesBySource
  isBypassPermissionsModeAvailable: boolean
  isAutoModeAvailable?: boolean
  strippedDangerousRules?: ToolPermissionRulesBySource   // ★ 被剥离的危险规则
  shouldAvoidPermissionPrompts?: boolean                 // 后台任务:弹不出框
  awaitAutomatedChecksBeforeDialog?: boolean
  prePlanMode?: PermissionMode                           // 进计划模式前的模式,用于恢复
}>

两个字段值得注意:

strippedDangerousRules:被系统主动剥离的规则

用户配置里可能有一些「过于宽泛以至于危险」的规则。系统会在加载时把它们剥离掉,并把剥离的内容记录下来(这样界面上可以提示用户「你的这条规则被忽略了,因为它太宽泛」)。

源码里能看到具体的剥离逻辑,比如:

DeepImmutable:类型层面的不可变

这个包装类型让整个权限上下文在类型系统层面完全只读 —— 任何试图修改它的代码都通不过编译。权限状态的修改必须走专门的 applyPermissionUpdates() 函数,从而保证所有修改都经过统一的校验和持久化路径。

7.7 权限决策的可解释性

每一个权限决策都带一个 decisionReason(决策原因)字段:

{ type: 'rule',        rule: {...} }            // 命中了某条规则
{ type: 'mode',        mode: 'auto' }           // 因为当前模式
{ type: 'hook',        hookName: 'PermissionRequest', reason: ... }  // 钩子决定的
{ type: 'safetyCheck', classifierApprovable: false }   // 安全检查
{ type: 'asyncAgent',  reason: '...' }          // 后台任务无法交互

而且有一个专门的模块 permissionExplainer.ts 负责把这些原因翻译成人话展示给用户。

可解释性对权限系统是刚需,不是锦上添花。

当用户看到「这个操作被拒绝了」而不知道为什么时,他的第一反应是把整个权限系统关掉。而如果他看到「因为你在 ~/.claude/settings.json 第 12 行配了 deny: Bash(rm:*)」,他就知道该改哪里。

一个无法解释自己决策的安全系统,最终会被用户绕过。