从设计者的角度理解源码--ClaudeCode权限系统设计

王大爷 2026年10月03日 2次浏览

从设计者的角度理解源码--ClaudeCode的权限系统设计

引言

本篇,作为《从设计者的角度理解源码》系列的第四篇,试图回答一个安全领域最朴素、也最难的问题:Claude Code 怎么敢让一个 LLM 直接在你电脑上执行 rm -rf?

这个问题的难度在于:LLM 的能力和可信度是错配的。

  • 它的能力是"无限"的——它能写代码、能读文件、能跑命令、能派生子 Agent 去干更复杂的事。
  • 它的可信度是不可保证的——它会产生幻觉,会把"删除临时文件"理解成"删除整个目录",会因为一句模糊的指令而做出破坏性的动作。

一个能力无限但判断力不可信的"聪明人",你怎么放心把家门钥匙给他?

这正是 Claude Code 权限系统要解决的核心矛盾。它不是一个简单的"允许/禁止"开关,而是一套分层的信任机制——用规则表达你的意图,用模式表达你的信任等级,用 AI 分类器处理"没人看着"的场景,最后用自然语言把"为什么被拒绝"讲给 LLM 听。

本文的核心隐喻:权限系统是一套刹车系统。好的刹车不是"把车锁死"(那样车就没用了),而是"在正确的时机、以正确的力度减速"。Claude Code 的权限设计,全部围绕一件事展开——在"安全"和"可用"之间找到动态平衡点。

本篇按认知递进展开:先看一条权限从生到死(生命周期),再讲谁说了算(决策),然后是两种配置风格(规则 vs Hook)、信任与判断(模式 + 工具自检)、无人在场时(AI 分类器)、需要人时(ask 挂起)、拒绝之后(文案闭环),最后提炼设计原则。


一、一次工具调用的完整生命周期

哎,讲权限系统之前,能不能先告诉我:一次工具调用,权限到底是在哪个环节起作用的?

这是理解整套设计的入口。很多人以为"权限检查"就是工具执行前的一个判断,但实际它是一条跨越多个模块的流水线。

一次 tool_use 从 LLM 吐出来,到最终执行或被拒绝,要经过六个阶段:

LLM 吐出 tool_use
      │
      ▼
① 触发(runToolUse):工具定位 + Schema 验证
      │
      ▼
② 前置准备:投机分类器抢跑(提前启动 Bash 分类器)
      │
      ▼
③ PreToolUse hooks:收集意见(不裁决)
      │
      ▼
④ Hook 决策合并:resolveHookPermissionDecision(纯代码裁决)
      │
      ▼
⑤ 静态权限决策:hasPermissionsToUseToolInner(洋葱式优先级链)
      │
      ▼
   ┌──┴──────────┬──────────────┐
   ▼             ▼              ▼
 allow          deny           ask
   │             │              │
   │             │        ⑥ 挂起 Promise,多源竞速
   │             │         (对话框 / 远程 / hook / 分类器)
   │             │              │
   ▼             ▼              ▼
tool.call()   tool_result     回到 allow / deny
              (is_error)

哎,为什么 Schema 验证要在权限检查之前?

因为权限规则经常依赖输入的具体字段——比如 Bash(git *) 要读 command 字段。如果输入是非法 JSON,规则匹配就成了无源之水。所以 runToolUse()(toolExecution.ts L410-L604)在碰权限之前,先做工具定位和 Schema 验证。

哎,阶段②那个"投机分类器抢跑"是什么意思?

这是整套设计里最容易被忽略的一处优化(toolExecution.ts L912-L934):

// ========== Bash 投机分类器 ==========
// 提前启动 Bash 的 allow classifier 检查,使其与 PreToolUse hooks、
// deny/ask classifier 和权限对话框设置并行执行。
if (
  tool.name === BASH_TOOL_NAME &&
  parsedInput.data &&
  'command' in parsedInput.data
) {
  startSpeculativeClassifierCheck(
    (parsedInput.data as BashToolInput).command,
    appState.toolPermissionContext,
    toolUseContext.abortController.signal,
    toolUseContext.options.isNonInteractiveSession,
  )
}

它做了什么?在权限检查正式开始之前,就先把 Bash 分类器的 LLM 调用发出去。 因为分类器要调 API(慢),而后面还要跑 PreToolUse hooks、规则检查——既然这些互不依赖,就让分类器先跑起来,等真需要它时结果已经回来了。

这就是"投机执行"(speculative execution):赌它会被用到,先用起来。最坏情况只是白跑一次 API,最好情况是零等待。

有了:这条流水线是理解后面所有章节的"地图"——阶段③④是"Hook 怎么参与决策"(第三章),阶段⑤是"谁说了算"(第二章),阶段⑥是"ask 怎么等人"(第七章),分类器在阶段②抢跑、在阶段⑤决策(第六章)。先记住这张图,后面每一章都是放大其中一个环节。

知道了"权限在哪起作用",下一个问题是:阶段⑤那个优先级链,到底谁说了算?


二、决策的三态与优先级链

哎,最笨的办法是每次工具调用都弹窗问用户,为什么不行?

因为"每次都问"等于让权限系统退化成一个"确认机器"——用户点几十次"允许"之后,就会形成肌肉记忆,闭着眼睛按回车。这时候弹窗的安全价值已经归零了,只剩下骚扰。

哎,那反过来,全部放开行不行?

更不行。LLM 一旦可以无限制执行命令,一次幻觉就可能导致不可逆的破坏。你不可能要求用户在执行前逐行审查 LLM 生成的每一条命令——那等于让用户自己当编译器。

有了:既然"全问"和"全放开"都不行,那就把决策结果做成三态——允许(allow)、拒绝(deny)、询问(ask)。安全操作自动放行,危险操作直接拒绝,拿不准的才弹窗问用户。

// src/types/permissions.ts L44
export type PermissionBehavior = 'allow' | 'deny' | 'ask'

三态的关键价值在于把"拿不准"显式建模。很多权限系统只有"允许/禁止"两态,结果是所有中间地带都被迫归到其中一边——要么过度打扰,要么过度放权。"ask" 这一态的存在,让系统可以诚实地说:"这件事我不确定,交给人来决定。"

哎,决定权定下来了,但具体是谁在下这个决定?

这是权限系统最容易讲乱的地方,因为"决策者"其实同时有五个:

  1. 规则(settings.json 里配的 allow/deny/ask)
  2. 模式(当前处于 default / acceptEdits / bypassPermissions...)
  3. 工具自己(每个工具实现的 checkPermissions)
  4. Hook(用户配置的 PreToolUse hook)
  5. 用户(弹窗时的人工选择)

五个决策者如果各行其是,就会出现"规则说允许、模式说拒绝"的混乱。所以真正的设计难点不是"有哪些决策者",而是"它们的意见不一致时听谁的"。

有了:给这五个决策者排出一条严格的优先级链——从"最硬的约束"到"最软的默认",逐级检查,先命中的先返回。

这条优先级链写在 hasPermissionsToUseToolInner 里(permissions.ts L1191-1351):

// 1a. 整个工具被 deny 规则拒绝 —— 最硬,直接返回
const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
if (denyRule) {
  return { behavior: 'deny', decisionReason: { type: 'rule', rule: denyRule }, ... }
}

// 1b. 整个工具有 ask 规则
const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
if (askRule) { /* ...除非沙箱化命令可自动放行... */ }

// 1c. 交给工具自己的 checkPermissions
toolPermissionResult = await tool.checkPermissions(parsedInput, context)

// 1d. 工具实现拒绝
if (toolPermissionResult?.behavior === 'deny') return toolPermissionResult

// 1e. 即使用户开了 bypass,仍需要交互的工具(如 AskUserQuestion)
if (tool.requiresUserInteraction?.() && toolPermissionResult?.behavior === 'ask')
  return toolPermissionResult

// 1f. 内容特定 ask 规则:优先于 bypassPermissions
// 1g. 安全检查(.git/、.claude/、shell 配置)对 bypass 免疫

// 2a. bypassPermissions 模式放行
// 2b. 整个工具被 allow 规则允许
// 3. passthrough → ask(兜底:问用户)

读这条链,你会注意到一个反复出现的模式:"硬的约束"永远压在"用户显式授予的通行证"前面。deny 规则、内容特定 ask 规则、安全检查——这三样东西即使在 bypassPermissions 模式下也会强行拦截。这是一个刻意的设计:bypass 不是"无限权力",而是"默认放行",它仍然有不可逾越的红线。

注意这五个决策者里,"规则"和"Hook"是两种截然不同的东西——一个是被动查询的数据,一个是主动执行的程序。这是下一章的主题。

知道了"谁有权做决定",下一个问题是:规则和 Hook 这两种决策者,到底有什么区别?


三、规则与 Hook:策略层的两种风格

在展开之前,先给权限意见的来源画一张三层地图——因为规则和 Hook 只是其中一层的一部分,不知道全局位置,就容易把"两种配置"当成"权限配置的全貌"。

权限意见的三个来源层次:

机制层(系统架构师)—— 三态、优先级链 —— 第二章已讲
领域层(工具开发者)—— checkPermissions —— 第五章
策略层(用户/企业)—— 规则、Hook、模式 —— 本章 + 第四章

规则和 Hook 都属于策略层——用户可配置的那一层。它们之上有机制层的优先级链定"怎么合并",旁边有领域层的工具自检补"只有工具知道"的判断。

哎,规则和 Hook 都能表达"允许/拒绝",它们到底是不是一回事?

不是。它们是策略层里的两种完全不同的配置风格,只是恰好都能表达权限意见。

有了:一句话区分——

规则 = 声明式数据:你填条件("什么情况下允许"),系统自己判断。
Hook = 命令式程序:你写逻辑("事件发生时执行什么"),程序自己判断。

规则:填条件,系统匹配

规则长这样,写在 settings.json 的 permissions 段(permissionsLoader.ts L91-L114):

{
  "permissions": {
    "allow": ["Bash(git status)", "Read", "Bash(npm test)"],
    "deny":  ["Bash(rm -rf:*)", "Read(./.env)"],
    "ask":   ["Bash(npm publish)"]
  }
}

每条是一个字符串,格式是 ToolName 或 ToolName(content)。判断方式纯字符串匹配——零成本、快、可预测。

但它有个硬边界:只能判断"匹不匹配",没有理解能力。它写不出"命令是否触碰了敏感目录"这种需要语义理解的逻辑。

哎,ToolName(content) 这个语法,怎么处理命令里自带的括号?

因为括号是分隔符,而命令里本身就可能带括号。看这个例子(permissionRuleParser.ts L93-L133):

permissionRuleValueFromString('Bash(python -c "print\\(1\\)")')
// => { toolName: 'Bash', ruleContent: 'python -c "print(1)"' }

用户的命令里有 print(1),括号不转义就会解析错。所以定义了成对的转义函数(permissionRuleParser.ts L55-79):

export function escapeRuleContent(content: string): string {
  return content
    .replace(/\\/g, '\\\\')  // 先转义反斜杠
    .replace(/\(/g, '\\(')   // 再转义左括号
    .replace(/\)/g, '\\)')   // 再转义右括号
}

注意转义顺序是刻意的:先反斜杠,再括号。反过来就会双重转义。这种"顺序敏感"的细节,正是手写解析器必须小心的。

哎,规则配好了,从哪加载?工具改名了旧规则会不会失效?

从 8 种来源加载,且有"企业策略优先"逻辑(permissionsLoader.ts L120-133)。而工具改名的问题,靠一张映射表解决:

// permissionRuleParser.ts L21-33
const LEGACY_TOOL_NAME_ALIASES: Record<string, string> = {
  Task: AGENT_TOOL_NAME,          // Task → Agent
  KillShell: TASK_STOP_TOOL_NAME,
  ...
}
export function normalizeLegacyToolName(name: string): string {
  return LEGACY_TOOL_NAME_ALIASES[name] ?? name
}

这个映射不能省——因为一条"拒绝"规则失效,意味着原本被拦住的操作变成可执行,这是危险的。

Hook:写程序,程序判断

Hook 长这样,写在 hooks 段(schemas/hooks.ts):

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": "~/my-check.sh" }] }
    ]
  }
}

它有四种形态:command(跑 shell)、http(发请求)、prompt(调 LLM)、agent(跑子 Agent)。

关键澄清:Hook 不是权限系统的一部分,它是一个通用生命周期扩展点——在 41+ 个事件点(会话开始、工具前后、压缩前后、配置变更……)插入你的逻辑。"表达权限意见"只是它在 PreToolUse 这个事件上的一种用法。

Hook 的价值在于能力远大于规则:它可以解析命令、展开环境变量、查配置、甚至调 LLM。规则能预先枚举的简单情况用规则;需要动态判断的复杂情况用 Hook。

Hook 的"收集 vs 裁决"两阶段

哎,Hook 的意见是怎么进入最终决策的?

这分两步,职责完全不同:

阶段③:收集意见(runPreToolUseHooks)——把用户注册的所有 PreToolUse hook 跑一遍,把它们的输出规整成结构化意见。这一步不裁决,只收集。

阶段④:裁决(resolveHookPermissionDecision,toolHooks.ts L332-433)——把收集到的 hook 意见,和规则系统的意见,合并成最终决策。这一步是纯 if/else,没有 AI 参与。

裁决的核心逻辑只有一条不对称规则:

if (hookPermissionResult?.behavior === 'allow') {
  // Hook allow 跳过了交互弹窗,但 deny/ask 规则仍然生效
  const ruleCheck = await checkRuleBasedPermissions(tool, hookInput, toolUseContext)
  if (ruleCheck === null) {
    return { decision: hookPermissionResult, input: hookInput }   // 真放行
  }
  if (ruleCheck.behavior === 'deny') {
    return { decision: ruleCheck, input: hookInput }              // deny 规则覆盖 hook
  }
  // ask 规则 —— 即使 hook 批准,仍要弹窗
  return { decision: await canUseTool(...), input: hookInput }
}

if (hookPermissionResult?.behavior === 'deny') {
  return { decision: hookPermissionResult, input }                // hook 拒绝 → 直接拒绝
}
hook 意见规则检查结果最终决策谁赢
allow无规则allowhook
allowdenydeny规则
allowask弹窗规则
deny—denyhook

有了:这条不对称是权限系统的一条安全不变量——hook 的 allow 只能"免除弹窗",不能"突破规则";hook 的 deny 是"加限制",限制永远可以叠加。

为什么必须这样?因为 hook 是用户写的自动化逻辑,可能写错、可能被滥用。如果 hook 的 allow 能单方面废掉用户显式配的 deny 规则,就等于任何 hook 都能提权。

所以从过程看:规则是"数据"、Hook 是"行为"、权限是"裁决"——Hook 的返回值是意见,不是权限,它要经过裁决才变成最终结果。

从职责看:规则和 Hook 都在策略层,是用户可配置的那一层。它们之上的机制层(优先级链)决定意见怎么合并,旁边的领域层(工具自检,第五章)补上"只有工具知道"的判断。三层各司其职,谁都不越界。

规则和 Hook 提供了"逐条/逐事件"的精细控制,但用户还需要"粗粒度的信任切换"——这十分钟我信任它做任何编辑。这就是"模式"。


四、七种模式:把"信任"做成一排旋钮

哎,既然有了规则和 Hook,为什么还需要"模式"这个概念?

因为规则是"逐条配置"的,而用户经常需要的是"粗粒度的信任切换"。比如我正准备让 Claude 帮我重构一个模块,这十分钟里我信任它做的任何编辑——我不想为每一次文件修改单独配一条规则。我要的是"这个阶段,放开一点"。

有了:用一个"模式"(PermissionMode)来表达这种粗粒度的信任等级,用户可以一键切换当前所处的信任档位。

// src/types/permissions.ts L16-29
export const EXTERNAL_PERMISSION_MODES = [
  'acceptEdits',        // 自动接受文件编辑,其他照旧
  'bypassPermissions',  // 跳过所有权限提示(仍有安全红线)
  'default',            // 默认:按规则和弹窗决策
  'dontAsk',            // 不问,需要问的一律拒绝
  'plan',               // 计划模式:只读探索,不改任何东西
] as const

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

七种模式,每一种都在回答"现在这个场景,我该多信任 LLM":

模式信任等级适用场景遇到"该问的事"怎么处理
default中日常使用弹窗问用户
acceptEdits中高集中改代码文件编辑自动放行,其他照旧
plan低先看后动只读,写操作一律拦
bypassPermissions最高高度自动化脚本全部放行(除硬红线)
dontAsk特殊无人交互的批处理该问的一律转成"拒绝"
auto特殊无头 Agent交给 AI 分类器判断(见第六章)
bubble特殊子 Agent把权限请求"冒泡"给父线程

哎,dontAsk 和 bypassPermissions 看起来都是"不问",为什么是两个模式?

这是这套设计里很精妙的一处区分,它们代表了完全相反的两种"不问":

  • bypassPermissions:不问,默认允许——信任 LLM。
  • dontAsk:不问,默认拒绝——不信任 LLM。

dontAsk 的实现只有三行,但语义非常清晰(permissions.ts L546-558):

// 应用 dontAsk 模式转换:将 'ask' 转换为 'deny'
if (result.behavior === 'ask') {
  const appState = context.getAppState()
  if (appState.toolPermissionContext.mode === 'dontAsk') {
    return {
      behavior: 'deny',
      decisionReason: { type: 'mode', mode: 'dontAsk' },
      message: DONT_ASK_REJECT_MESSAGE(tool.name),
    }
  }
}

有了:这两个模式的对比,揭示了一个设计洞察——"不问"不是一种态度,而是一个动作的省略。省略之后默认倒向哪一边,才是真正表达信任的地方。 所以它们必须是两个模式,因为它们的默认值截然相反。

哎,plan 模式和 bypassPermissions 有什么关系?我看代码里它俩会互相干扰。

这正是设计精妙之处。看 step 2a:

// 2a. 检查模式是否允许该工具运行
const shouldBypassPermissions =
  appState.toolPermissionContext.mode === 'bypassPermissions' ||
  (appState.toolPermissionContext.mode === 'plan' &&
    appState.toolPermissionContext.isBypassPermissionsModeAvailable)

如果用户最初以 bypass 模式启动(isBypassPermissionsModeAvailable 为 true),然后切进 plan 模式——那么 plan 模式下依然保留 bypass 的放行能力。因为 plan 模式的语义是"先规划再执行",用户只是先看看方案,不该突然收紧权限,否则体验割裂。

有了:模式不是"当前信任等级"这么简单,它还需要记住"用户从哪个信任等级来的"。isBypassPermissionsModeAvailable 就是这个记忆。

模式是粗粒度的信任旋钮,但有一种判断,连规则和 Hook 都写不出来——"这个文件路径是不是在 .claude/ 目录下",只有工具自己知道。


五、有些决定,只有工具自己知道

哎,我想配一条规则:"禁止 Claude 自动修改 .claude/settings.json"。这种规则怎么用 ToolName(content) 语法写?

写不出来。因为这不是"命令内容"级别的问题,而是"文件路径 + 当前模式 + 路径安全性"的综合判断。Bash(git status) 这种语法只能匹配字符串,无法表达"路径是否指向敏感目录"。

有了:把这类决策下放给工具自己实现——每个工具都提供一个 checkPermissions 方法,主权限系统在检查完通用规则后,把决策权交给工具。

这就是 hasPermissionsToUseToolInner 里 step 1c 做的事:

// 1c. 向工具实现询问权限结果
let toolPermissionResult: PermissionResult = {
  behavior: 'passthrough',
  message: createPermissionRequestMessage(tool.name),
}
try {
  const parsedInput = tool.inputSchema.parse(input)
  toolPermissionResult = await tool.checkPermissions(parsedInput, context)
} catch (e) {
  if (e instanceof AbortError || e instanceof APIUserAbortError) throw e
  logError(e)
}

注意默认值是 passthrough——如果工具没有特别反对,就表示"我没有额外意见,交给上层继续判断"。这是一个积极的默认值:工具不需要为了"不干预"而写任何代码。

看 FileEditTool 的实现(FileEditTool.ts L125-133):

async checkPermissions(input, context): Promise<PermissionDecision> {
  const appState = context.getAppState()
  return checkWritePermissionForTool(FileEditTool, input, appState.toolPermissionContext)
}

真正的逻辑在共享的 checkWritePermissionForTool 里(filesystem.ts L1205-1300),它内部依次做了一串"只有文件工具才懂"的判断:

// 1. 检查 deny 规则(同时检查原始路径和符号链接解析后的路径)
for (const pathToCheck of pathsToCheck) {
  const denyRule = matchingRuleForInput(pathToCheck, toolPermissionContext, 'edit', 'deny')
  if (denyRule) return { behavior: 'deny', ... }
}

// 1.5. 允许写入内部可编辑路径(plan 文件、scratchpad)
// 注意:必须在"危险路径"检查之前,因为 .claude 本身就是危险目录
const internalEditResult = checkEditableInternalPath(absolutePathForEdit, input)
if (internalEditResult.behavior !== 'passthrough') return internalEditResult

两个细节值得注意:

第一,路径检查同时查"原始路径"和"符号链接解析后的路径"。 因为攻击者可能用符号链接指向受保护文件,只查原始路径就能绕过。这是典型的防御纵深。

第二,"内部可编辑路径"白名单必须在"危险目录检查"之前。 注释明确写了原因:.claude 是危险目录,但 .claude/ 下有 plan 文件、scratchpad 这些系统自己要写的内容。如果先做危险目录检查,系统就写不了自己的 plan 文件了。顺序即语义——改一行顺序,功能就崩。

哎,那 step 1g 说的"安全检查对 bypass 免疫",是指什么?

指这类判断的结果具有最高优先级,即使用户开了 bypassPermissions 也不能绕过(permissions.ts L1287-L1292):

// 1g. 安全检查(如 .git/、.claude/、.vscode/、shell 配置文件)
// 对 bypass 模式免疫——即使在 bypassPermissions 模式下也必须弹窗。
if (
  toolPermissionResult?.behavior === 'ask' &&
  toolPermissionResult.decisionReason?.type === 'safetyCheck'
) {
  return toolPermissionResult
}

有了:这里藏着一个重要的设计原则——"信任"和"安全"是两个不同的维度。bypassPermissions 表达的是"我信任 LLM 的日常判断",但 .git/、.claude/、shell 配置文件这些一旦改错就会破坏整个开发环境的路径,属于"再怎么信任也不能放手"的红线。

区分这两个维度,是权限系统能做好的前提。如果混为一谈,你要么过度限制(把普通操作也管死),要么留下致命漏洞(把红线也放行)。

到这里,所有的决策都假设"有人在旁边点确认"。但如果 LLM 是在无人值守的后台跑呢?没人点弹窗,权限系统该怎么工作?


六、Auto 模式:两类 AI 分类器

哎,无头 Agent(后台任务、CI 环境)没有人盯着屏幕点"确认",这时候权限系统怎么办?

最保守的做法是"一律拒绝"(dontAsk 模式),但那样无头 Agent 连读文件都要被拒。最激进的做法是"一律允许"(bypassPermissions),但那等于放弃所有安全检查。

有了:既然没有"人"来判断,那就用另一个 AI 来判断——启动一个轻量的分类器,让它看着对话上下文,判断"这个工具调用危险吗"。

但这里有个容易混淆的点:Claude Code 里其实有两种完全不同的分类器。

两类分类器

维度Bash 分类器YOLO 分类器
回答的问题"这条命令符合用户写的自然语言规则吗?""这个动作安全吗?"
输入单条命令 + 规则描述整段对话 transcript + 动作
触发场景用户配了 Bash(prompt: ...) 规则auto 模式
输出{matches, confidence, reason}{shouldBlock, thinking, reason}
阶段单阶段两阶段(fast → thinking)

Bash 分类器解决的是"规则的表达力缺口":用户想允许"安装 npm 依赖",但枚举不完 npm install / npm i / yarn add……于是用自然语言描述意图(Bash(prompt: allow installing npm dependencies)),让分类器判断实际命令是否落在描述里。它的输出带 confidence 字段(bashClassifier.ts L3-L10):

export const PROMPT_PREFIX = 'prompt:'

export type ClassifierResult = {
  matches: boolean              // 这条命令是否符合描述
  matchedDescription?: string   // 命中了哪条规则描述
  confidence: 'high' | 'medium' | 'low'  // 置信度
  reason: string
}

注意 confidence 的作用:把"AI 判断"降级成可分级使用的东西——只有 matches && confidence === 'high' 才允许零延迟放行,中低置信度老实弹窗。

YOLO 分类器解决的是"无人在场":它用整段对话 transcript 判断动作安全性。它不是用一个文本 prompt 问"危险吗",而是定义一个结构化工具强制输出(yoloClassifier.ts L262-L285):

const YOLO_CLASSIFIER_TOOL_SCHEMA: BetaToolUnion = {
  type: 'custom',
  name: 'classify_result',
  description: 'Report the security classification result for the agent action',
  input_schema: {
    properties: {
      thinking: { type: 'string', description: 'Brief step-by-step reasoning.' },
      shouldBlock: { type: 'boolean', description: 'Whether the action should be blocked (true) or allowed (false)' },
      reason: { type: 'string', description: 'Brief explanation of the classification decision' },
    },
    required: ['thinking', 'shouldBlock', 'reason'],
  },
}

为什么喂整段对话,而不是只给当前命令? 因为命令的安全性取决于上下文——同样是 rm -rf build,用户说"清理 build 目录"是安全,说"分析项目结构"就是危险(模型自作主张)。纯规则永远判断不了这个。

多级快速路径:分类器是最后手段

分类器有成本(每次都是 API 调用),所以设计上先用零成本手段消化大部分请求(permissions.ts L561-L724):

// 快速路径 0:非分类器可审批的安全检查对自动批准免疫
// 快速路径 1:先看 acceptEdits 模式会不会允许(省一次分类器调用)
// 快速路径 2:白名单工具直接放行(SAFE_YOLO_ALLOWLISTED_TOOLS)
// 慢速路径:真正调用分类器
classifierResult = await classifyYoloAction(
  context.messages, action, context.options.tools, appState.toolPermissionContext, signal,
)

白名单就是 classifierDecision.ts 里那个 SAFE_YOLO_ALLOWLISTED_TOOLS(classifierDecision.ts L56-L98)——只读工具、任务管理、plan 模式切换等,全跳过分类器。

YOLO 两阶段:怎么省钱

分类器贵,所以 YOLO 分类器分两阶段(yoloClassifier.ts L695-L710):

'both'(默认):
  Stage 1「fast」:max_tokens=64 + stop_sequences,只要一个是/否
     ├─ 允许 → 立即返回(快路径)
     └─ 拦截 → 升级到 Stage 2
  Stage 2「thinking」:带 chain-of-thought,减少误报

看 stage 1 的配置(yoloClassifier.ts L779-L794):

stage1Opts = {
  model,
  max_tokens: (mode === 'fast' ? 256 : 64) + thinkingPadding,   // 只要 64 token
  system: systemBlocks,
  temperature: 0,
  messages: [...prefixMessages, { role: 'user', content: stage1Content }],
  ...(mode !== 'fast' && { stop_sequences: ['</block>'] }),      // 一吐结论就停
  querySource: 'auto_mode',
}

而 stage 1 的提示词后缀是(yoloClassifier.ts L550):

const XML_S1_SUFFIX = '\nErr on the side of blocking. <block> immediately.'

"宁可错杀"这四个字是关键。 它让 stage 1 产生了一个漂亮的不对称:

stage 1 结论含义可信度处理
允许"我本被要求偏保守,但我明确认为安全"高直接采信,廉价定案
拦截"我可能只是拿不准才偏保守"低升级 stage 2 复核

有了:因为 stage 1 被要求"疑罪从有",所以它的"允许"是违背自身保守倾向的强信号,可以廉价定案;它的"拦截"可能是保守倾向的产物,需要复核。这让"省钱"和"减少误拦"两个目标同时达成——而不是互相妥协。

而省钱靠四件事:① 多数请求止步 stage 1;② stage 1 输出被 max_tokens: 64 + stop_sequences 锁死(对比 stage 2 的 4096);③ 两个 stage 输入一致 → prompt cache 共享;④ 成本与可疑程度成正比,而不是与调用次数成正比。

(stage 2 的配置在 yoloClassifier.ts L860-L880,max_tokens: 4096,无 stop_sequences。)

拒绝追踪与 fail 双轨

哎,用 AI 判断 AI,那 AI 判断错了怎么办?会不会一路错下去?

系统配了一个拒绝追踪器(denialTracking.ts L12-L15):

export const DENIAL_LIMITS = {
  maxConsecutive: 3,   // 连续拒绝 3 次
  maxTotal: 20,        // 或累计拒绝 20 次
} as const

两个维度各有用处:连续 3 次防"死循环"(分类器一直拒、LLM 一直重试);累计 20 次防"慢性失控"(整个会话自动模式整体不靠谱)。超限后 handleDenialLimitExceeded 接管——无头模式直接中止,有交互则回退到人。

哎,分类器自己挂掉了怎么办?放行还是拦截?

这是 fail-open(失败放行)vs fail-closed(失败拦截)的经典问题。Claude Code 做了双轨决策:API 挂了(网络问题)→ fail-open(回退到普通权限流程,依然会弹窗问人,所以不危险);分类器说"我判断不了"→ fail-closed(这是明确的负信号,交给人)。

有了:fail-open 和 fail-closed 不是非此即彼,而应该根据**"失败的原因是否携带安全信号"**来决定。API 超时是"没有信号",判断不了是"负信号"——处理方式理应不同。

权限系统里的 5 处 LLM 调用

盘点一下,整个权限流程里 LLM 只出现在这些位置:

#名称影响裁决?场景
1YOLO 分类器✅auto 模式安全审查
2Bash 分类器✅Bash(prompt:) 自然语言规则
3子 Agent 完工审查✅子 Agent 交还控制权时(agentToolUtils.ts L521-L535)
4权限解释器❌ 给人看用户按 Ctrl+E(permissionExplainer.ts L147-L186)
5通用命令描述❌ 给人看弹窗里帮用户写规则

有了:LLM 只出现在两个位置——"规则表达力的缺口"和"给人看"。其余全是纯代码。这就是判断"该不该用 LLM"的准绳:能不用就不用,一定要用就用最便宜的(Bash 分类器固定用 Haiku),贵的按需升级(YOLO 两阶段)。

分类器处理了"无人在场"的场景。但还有一种场景:人在场,但系统拿不准,需要"问"——这就是 ask,也是整个权限系统里最复杂的一环。


七、ask 的挂起:把答案交给别人写

哎,ask 和 allow/deny 到底有什么本质区别?

这是一个关键分界:allow/deny 是"算出答案",ask 是"把答案交给别人写"。

allow/deny 时,函数立即返回一个决策。ask 时,函数不返回——它返回一个尚未兑现的 Promise,把执行挂起,等外部输入来兑现。

看它的骨架(useCanUseTool.tsx L32):

return new Promise(resolve => {
  const ctx = createPermissionContext(...)
  ...
  case "ask": {
    ...  // 各种应答路径,谁先到谁调 resolve()
  }
})

多应答者竞速

ask 挂起之后,有多条路径可以兑现这个 Promise,谁先应答谁赢:

应答者触发方式
本地用户对话框点"允许/拒绝"
用户中止Ctrl+C
远程 CCRclaude.ai 网页点了
渠道审批手机/IM 审批
后台分类器自动批准(用户没动的话)
PermissionRequest hookhook 决策
模式切换重查权限模式变了重新判断

哎,为什么要设计这么多应答者?

因为 ask 的本质不是"弹窗",而是"开放一个待认领的决策槽位"。决定可能来自任何地方——你在终端前就弹窗,你在手机上就远程应答,你配了 hook 就自动决定,分类器能判断就自动批准。弹窗只是最后的兜底手段。

claim:原子抢答

多源竞速,就必须保证只有一个能赢。这靠 claim()(PermissionContext.ts L75-L94):

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
    },
  }
}

哎,为什么不能直接 if (isResolved()) return?

因为那是 TOCTOU 漏洞(Time-of-check to time-of-use)——检查和执行之间有时间窗口:

// ❌ 危险写法
if (isResolved()) return       // ① 检查:还没人应答
await somethingSlow()          // ② 这期间别人应答了!
resolve(...)                   // ③ 重复应答,逻辑错乱

// ✅ 正确写法
if (!claim()) return           // ① 检查和标记是"一次原子操作"
await somethingSlow()
resolve(...)                   // ② 安全:我已经抢到了

所以每个应答回调开头都是 if (!claim()) return。

有了:这里有个容易混淆的点——Promise 只能 settle 一次,这是 JS 语言保证;但 claim() 保护的却是另一件事:副作用。

resolve 天然幂等(第二次调用被忽略),但 persistPermissions(写规则)、sendResponse(通知远程)这些副作用不幂等。claim() 的作用是在"产生副作用之前"先抢锁,确保副作用只执行一次。

富返回:ask 的应答不只是投票

用户点"允许"之后,不只是返回一个 allow,还会附带三类信息(PermissionContext.ts L291-L318):

async handleUserAllow(updatedInput, permissionUpdates, feedback?, ...) {
  const acceptedPermanentUpdates = await this.persistPermissions(permissionUpdates)   // ① 写进 settings.json
  const userModified = tool.inputsEquivalent
    ? !tool.inputsEquivalent(input, updatedInput)      // ② 判断用户是否改了输入
    : false
  return this.buildAllow(updatedInput, {
    userModified,                                       // ② 标记"被改过"
    acceptFeedback: feedback?.trim() || undefined,      // ③ 反馈文字
    contentBlocks,                                      // ③ 粘贴的图片
  })
}
  • permissionUpdates 被持久化 → 用户选"总是允许",规则写进 settings.json,改变未来所有同类调用(这就是"回写")。
  • userModified → 用户可能手动改了文件路径,这个标记让下游知道"输入被人改过"。
  • feedback / contentBlocks → 用户拒绝时可以附文字和图片("你看,我说的是这个文件")。

有了:allow/deny 只需要一个布尔判断,但 ask 面对的是人,人会改输入、给理由、贴证据、表达长期意愿。这些附加信息是"人参与决策"相对"规则自动决策"独有的价值。ask 这一层之所以复杂,正是要为这些"人的表达"预留通道。

中止也是一种应答——Ctrl+C 走同一条竞速通道(interactiveHandler.ts L137-L153),它调用 cancelAndAbort,最终也是 resolve 一个决策,而不是 reject。因为在这条路径上,"失败"不是异常,而是一种决策值——用户取消,也是一个有效的决策。

无论决策结果如何,最后都会产生一个动作:如果被拒绝,得让 LLM 知道"这条路走不通"。否则它只会一遍遍地重试。


八、拒绝不是终点:怎么让 LLM 知道"此路不通"

哎,权限系统拒绝了某个工具调用之后,LLM 会怎么反应?它会不会一直重试?

如果直接把拒绝当成一个"错误"抛给 LLM,它很可能会尝试用别的方式重试同一个目标——毕竟它的训练目标就是"完成任务"。如果它不知道"为什么被拒",就会陷入"换个写法再试一次"的死循环。

有了:把拒绝翻译成一段给 LLM 看的自然语言消息,注入到 tool_result 里。这样 LLM 在下一轮就能读到"我被拒绝了,原因是 XXX,我该怎么办"。

这沿用了消息机制里的核心思想——用上下文代替状态机(见系列第三篇)。权限系统的拒绝,本质上不是给程序看的错误码,而是给 LLM 看的"行为指导"。

Claude Code 针对不同的拒绝场景,准备了三套递进的文案(messages.ts L385-L412):

// 1. 用户手动拒绝:明确告诉 LLM "用户主动拒绝,停下来等指令"
export const REJECT_MESSAGE =
  "The user doesn't want to proceed with this tool use. The tool use was rejected " +
  "(eg. if it was a file edit, the new_string was NOT written to the file). " +
  "STOP what you are doing and wait for the user to tell you how to proceed."

// 2. 子 Agent 被拒绝:结尾不同——"别停下来,换个方法"
export const SUBAGENT_REJECT_MESSAGE =
  'Permission for this tool use was denied. The tool use was rejected ' +
  '(eg. if it was a file edit, the new_string was NOT written to the file). ' +
  'Try a different approach or report the limitation to complete your task.'

// 3. 自动拒绝 / dontAsk 拒绝:拼接通用变通指引
export function AUTO_REJECT_MESSAGE(toolName: string): string {
  return `Permission to use ${toolName} has been denied. ${DENIAL_WORKAROUND_GUIDANCE}`
}

哎,我注意到"用户拒绝"和"子 Agent 拒绝"的结尾完全相反——一个说 STOP,一个说"换个方法"。为什么?

这是一个极其精妙的区分,它体现了同一事件在不同角色下语义完全不同:

  • 主 Agent 被用户拒绝 → 用户就在旁边,他的拒绝是一个明确的意图信号。LLM 应该停下来等指令,自作主张"换个方法继续"反而违背用户意愿。
  • 子 Agent 被拒绝 → 拒绝来自权限系统(父线程、分类器、或自动化规则),不是终端用户本人的实时意图。子 Agent 不能"等用户",因为它够不到用户——它只能换个路径完成任务,或把限制上报给父 Agent。

有了:文案设计不是遣词造句,而是**"这个事件在谁的视角下意味着什么"**。同一个"拒绝",在"用户面前"和"无人值守"两种语境下,对 LLM 的正确指令是相反的。

哎,那个拼在自动拒绝后面的 DENIAL_WORKAROUND_GUIDANCE,为什么写得那么长?

因为它要一次性堵住 LLM 的"歪脑筋"(messages.ts L399-L405):

export const DENIAL_WORKAROUND_GUIDANCE =
  `IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomplish this goal, ` +
  `e.g. using head instead of cat. But you *should not* attempt to work around this denial in malicious ways, ` +
  `e.g. do not use your ability to run tests to execute non-test actions. ` +
  `You should only try to work around this restriction in reasonable ways that do not attempt to bypass the intent behind this denial. ` +
  `If you believe this capability is essential to complete the user's request, STOP and explain to the user ` +
  `what you were trying to do and why you need this permission. Let the user decide how to proceed.`

拆解它的四层结构:

  1. 允许什么:可以用其他自然的方式达成目标(head 代替 cat)。
  2. 禁止什么:不能用恶意方式绕过(用"跑测试"的能力执行非测试操作)。
  3. 划清界限:只能做"不违背拒绝本意"的变通。
  4. 兜底出口:如果这能力对任务必需,停下来向用户解释,让用户决定。

有了:当你要给一个"聪明但不了解语境"的执行者下禁令时,光说"不行"是不够的。你必须同时告诉它:边界在哪、为什么有这条边界、以及正确的求助路径。 否则它要么卡死,要么用自己的方式"聪明地"绕过——而后者往往更危险。

同样的思路也体现在分类器拒绝文案里那句 At the end of your session, recommend what permission rules to add so you don't get blocked again.——系统借"被拒绝"的时机,让 LLM 反过来帮用户完善规则。这是一个"负反馈闭环":权限系统不只是被动拦截,还把"被拦截"转化成了"优化配置的建议"。

至此,权限系统的完整闭环建立起来了:生命周期 → 决策 → 配置风格 → 信任与判断 → 自动化 → 挂起 → 拒绝闭环。下面提炼可迁移的原则。


九、可迁移的设计原则

回顾整个权限系统,有八条原则值得迁移到任何"要托管一个不可信执行者"的系统里。

第一,把"不确定"显式建模,而不是硬塞进二元判断。

allow / deny / ask 三态的核心价值,是让系统有资格说"我不知道"。二元权限系统被迫把所有中间地带归到一边,结果是过度打扰或过度放权。任何需要"在安全和可用之间平衡"的系统,都应该保留一个"交给上游决策"的显式状态——这也正是 passthrough 的意义。

第二,"信任"和"安全"是两个正交的维度,别混。

bypassPermissions 表达的是信任,但 .git/、.claude/、shell 配置这些红线对信任免疫。如果你把两者混成一个"权限等级",就必然要么管得太死,要么在最高信任级别下留下致命漏洞。正确的做法是在优先级链里把"安全红线"放在"用户授权"之前。

第三,能不下放的成本,就不下放;必须下放的成本,才下放。

权限检查的优先级链本身就是一条成本递增的排序:规则匹配(零成本)→ 工具自检(零成本)→ 安全检查(零成本)→ 分类器(一次 API 调用)→ 用户弹窗(人的时间)。系统的设计目标始终是"用最便宜的手段消化掉最多的请求"。

第四,自动化决策必须配一条"回退到人"的逃生通道。

Auto 模式的分类器会被拒绝追踪器监控,连续 3 次或累计 20 次拒绝就回退到人。这个设计承认了一个前提:AI 判断力有边界。任何把决策权交给 AI 的系统,都必须回答:"当 AI 持续判断错误时,谁来接管?"

第五,给执行者的禁令,必须附带意图。

DENIAL_WORKAROUND_GUIDANCE 的四层结构说明:光说"不行",对理解语境的执行者毫无意义。你必须告诉它边界在哪、为什么有这条边界、以及正确的求助路径。

第六,把"读状态"和"推进状态"分开。

投机分类器的 peek(看一眼)和 consume(认领)是两个不同的动作。混在一起,就会在"只看不用"的场景下误删结果——比如 2 秒宽限期超时了却把结果删掉,导致后面重复调用。这是"旁路优化"的代价:签名干净换来的,需要用在别处的复杂度偿还。

第七,分级付费:成本与"可疑程度"成正比,而不是与"调用次数"成正比。

YOLO 两阶段的本质是"分级付费"——绝大多数请求走廉价路径(64 token),只有可疑的才升级到昂贵复核(4096)。更精妙的是那个不对称:让 stage 1"宁可错杀",于是它的"允许"是高置信信号(可廉价定案),"拦截"是低置信信号(必须复核)。省钱和减少误拦,通过一个不对称设计同时达成。

第八,多源竞速要配原子抢答。

ask 挂起后有七条应答路径竞速,必须靠 claim() 做"原子 check-and-mark",关闭 TOCTOU 窗口。任何"多个异步输入源竞争一个决策"的场景,都不能用 isResolved() 检查——检查和执行之间必须有原子性。

贯穿这八条原则的,是这套权限系统最根本的设计哲学:

权限系统不是一堵墙,而是一套沟通机制。

它的目标不是"挡住 LLM",而是"让 LLM 理解现在该做什么、不该做什么、以及为什么"。

墙只会被绕过,沟通才能被遵守。

而这,又绕回了整个系列反复出现的那个主题——用上下文代替状态机。无论是消息的中断、错误恢复,还是权限的拒绝,Claude Code 都选择把"发生了什么"翻译成 LLM 能读懂的自然语言,让它成为行为决策者。权限系统只是这个哲学在安全领域的又一次应用。