权限系统
权限系统
权限系统解决的问题:agent 要能自主跑命令、改文件,又不能让某一次模型幻觉把用户的仓库删了。
做法是在每个工具调用真正执行前插一道许可管道,输出三种行为:
- allow:放行。
- deny:拒绝并把原因喂回模型。
- ask:弹窗问人。
规则能定性的走规则,规则覆盖不到的兜底问用户,auto 模式下再用一个小模型分类器替人把关,拿不准时整体倾向拒绝。
Claude Code 是商业闭源实现,这篇只引签名级片段,模块路径和函数名都来自 2.1.88 的反混淆源码,可逐一对照。先看数据结构:模式、规则、上下文。
代码块收起展开
// 基于本地反混淆源码 (Claude Code 2.1.88), src/types/permissions.ts
export type ExternalPermissionMode = (typeof EXTERNAL_PERMISSION_MODES)[number]
// 数组共 5 项(源码按字母序): 'acceptEdits' | 'bypassPermissions' | 'default' | 'dontAsk' | 'plan'
// dontAsk 容易漏: 它把所有本该弹窗的 ask 直接转成 deny, 适合无人值守跑批
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
// auto: 分类器替人守门, 构建期 feature('TRANSCRIPT_CLASSIFIER') 门控, 外部发行版整个裁掉
// bubble: 子 agent 专用, 权限请求冒泡给父会话弹窗, 而非因"后台没 UI"被自动拒绝 (AgentTool/runAgent.ts)
export type PermissionBehavior = 'allow' | 'deny' | 'ask'
export type PermissionRule = {
source: PermissionRuleSource // 8 种来源分层: policySettings/userSettings/projectSettings/localSettings/flagSettings/cliArg/command/session
ruleBehavior: PermissionBehavior
// ... ruleValue: { toolName, ruleContent? }, 配置里的 "Bash(git *)" 由 permissionRuleParser.ts 解析成 { toolName:'Bash', ruleContent:'git *' }
}
// src/Tool.ts: 一次许可判断的输入上下文, 挂在 AppState 上
export type ToolPermissionContext = DeepImmutable<{
mode: PermissionMode
// ... 三张规则表 alwaysAllowRules / alwaysDenyRules / alwaysAskRules, 都按 source 分组;
// 另有 additionalWorkingDirectories(额外可写目录)、strippedDangerousRules(进 auto 模式时被剥离的危险 allow 规则) 等
}>ToolPermissionContext 定义在 Tool.ts(工具接口全貌见 01-Tool 抽象),types/permissions.ts 里还有一份纯类型副本,专门为拆 import 环。
五个外部模式:
- default:按规则判、该问就问。
- plan:只读勘察,写操作要先交方案。
- acceptEdits:自动接受工作目录内的文件编辑(FileEditTool 的
checkPermissions委托 filesystem.ts 的checkWritePermissionForTool,在 acceptEdits 且路径在工作目录内时直接 allow,目录外照样问)。 - bypassPermissions:全放行。
- dontAsk:把所有本该弹窗的请求直接拒掉。
模式切换本身也是受控操作:所有对规则和模式的修改都被建模成 PermissionUpdate(addRules/replaceRules/removeRules/setMode/addDirectories/removeDirectories 六种),带 destination 指明落到哪一层配置。
UI 弹窗和 SDK 走的是同一套结构。
规则内容的匹配是 shellRuleMatching.ts 的 matchWildcardPattern:把 * 编译成正则 .* 整串匹配,有个专门特例,单通配符结尾的 git * 也能匹配裸 git,和老式前缀语法 git:* 语义对齐。
判定逻辑分两层,都在 utils/permissions/permissions.ts:内层走规则阶梯,外层做模式转换和分类器。
代码块收起展开
// 基于本地反混淆源码 (Claude Code 2.1.88), src/Tool.ts 与 src/utils/permissions/permissions.ts
checkPermissions(
input: z.infer<Input>,
context: ToolUseContext,
// ... 只放工具自有判断(Bash 拆子命令、Edit 查路径), 通用逻辑不在这
): Promise<PermissionResult>
toAutoClassifierInput(input: z.infer<Input>): unknown
// 把入参压成分类器可读的紧凑形式: Bash 给 `ls -la`, Edit 给 `/tmp/x: new content`; 返回 '' 表示无安全相关性, 分类器跳过
// 通用管道入口, 主循环传给工具执行层的 canUseTool 就是它
export const hasPermissionsToUseTool: CanUseToolFn = async (
// ... tool, input, context, assistantMessage
) => {
// ...
}
async function hasPermissionsToUseToolInner(
// ... tool, input, context
): Promise<PermissionDecision> {
// ... 1a/1b 规则命中先返回
let toolPermissionResult: PermissionResult = {
behavior: 'passthrough', // 第四种行为: 工具不表态, 不定性, 交还管道, 走到阶梯末尾才转成 ask
}
// ...
}内层 hasPermissionsToUseToolInner 的阶梯(源码里就编着号):
- 1a:整个工具被 deny 规则命中,直接 deny。
- 1b:整个工具有 ask 规则,直接 ask(唯一例外是已进沙箱的 Bash 命令,放行到工具自查)。
- 1c:调工具自己的
checkPermissions。 - 1d:工具自己 deny 则 deny。
- 1e:
requiresUserInteraction()的工具 ask 保持 ask。 - 1f:内容级 ask 规则(如
Bash(npm publish:*))保持 ask。 - 1g:
safetyCheck(改 .git/、.claude/、.vscode/、shell 配置文件)保持 ask。 - 2a:bypassPermissions 模式放行。
- 2b:整工具 allow 规则放行。
- 3:把 passthrough 转成 ask。
顺序本身就是安全模型:1 系列全部排在 2a 之前,所以 bypass 模式压不掉任何 deny 规则和 safetyCheck,它只是便利开关,拿不到权限提升。
为什么 .claude/ 和 .git/ 要这种待遇?因为往 .claude/settings.json 写一条 hook、往 .git/hooks 放个脚本,都等于任意代码执行,这是 agent 给自己提权的正门。
所以 checkPathSafetyForAutoEdit(filesystem.ts)把这些路径标成 safetyCheck,连 bypass 也只降级为”必须问人”。
safetyCheck 还带一个 classifierApprovable 布尔:敏感文件路径设 true,auto 模式允许分类器结合上下文判。
Windows 路径绕过尝试这类硬红线设 false,谁也不能自动批。
外层 hasPermissionsToUseTool 拿到内层结果后再变换:结果是 ask 且模式为 dontAsk,转 deny;模式为 auto,进分类器。
auto 又是快路径优先:
- 先用 acceptEdits 模式干跑一遍该工具的
checkPermissions,能过说明只是工作目录内的常规编辑,直接放行(Agent 与 REPL 除外,REPL 的胶水 JS 里可能藏 VM 逃逸,必须让分类器看到)。 - 再查
isAutoModeAllowlistedTool只读工具白名单。 - 两条都不中才真正调分类器。
代码块收起展开
// 基于本地反混淆源码 (Claude Code 2.1.88), src/utils/permissions/ 下 auto 模式三件套
// yoloClassifier.ts: auto 模式安全分类器 (yolo 是历史名), 一次 side query 问小模型该不该放行
export async function classifyYoloAction(
messages: Message[], // 传整个会话历史投影成的 transcript, 分类器看的是完整上下文, 而非孤立一条命令
action: TranscriptEntry,
// ... tools: Tools, context: ToolPermissionContext, signal: AbortSignal
): Promise<YoloClassifierResult>
// classifierDecision.ts: Read/Grep/Glob/Todo 等只读与元数据工具白名单, 命中直接放行不烧 API
export function isAutoModeAllowlistedTool(toolName: string): boolean {
// ... return SAFE_YOLO_ALLOWLISTED_TOOLS.has(toolName)
}
// denialTracking.ts: 分类器连拒计数, 达阈值回退人工弹窗
export const DENIAL_LIMITS = {
maxConsecutive: 3,
maxTotal: 20,
} as const分类器挂掉时的行为并没有写死:远程开关 tengu_iron_gate_closed 默认 true,fail closed,deny 并附重试指引;开关关掉则 fail open,回退成普通弹窗。
唯一例外是 transcript 超出分类器上下文窗口,这是确定性错误(transcript 只增不减,重试必然还超),直接回退弹窗,不走拒绝。
bypassPermissions 自己也有一个远程 killswitch(bypassPermissionsKillswitch.ts 的 checkAndDisableBypassPermissionsIfNeeded,启动时查一次远程配置),官方可以在出安全事故时全网关掉这个模式。
原理串讲
拿一次真实调用走通全链。
模型输出 Bash("git push") 的 tool_use,主循环把它交给 services/tools/toolExecution.ts 的 checkPermissionsAndCallTool。
先是两道入参关:
- zod 的
inputSchema.safeParse验类型。 - 再调工具的
validateInput验语义,任一失败直接把错误作为 tool_result 回给模型,权限系统根本不启动。
然后跑 PreToolUse hooks,hook 可以直接给出 allow/deny,其结果与通用管道在 resolveHookPermissionDecision 里合并。
没有 hook 表态时,才进 hasPermissionsToUseTool。
内层阶梯 1a/1b 查的是”整个 Bash 工具”级别的规则,通常不中,走到 1c 调 BashTool 的 checkPermissions,实际实现是 bashPermissions.ts 的 bashToolHasPermission。
它先用 tree-sitter 把命令解析成 AST:要么得到干净的 SimpleCommand 列表(引号已解析、无隐藏替换),要么判为 too-complex 直接要求人工确认。
为什么用 AST 而非正则?因为 git push $(rm -rf /) 这种注入,字符串前缀匹配看到的是 git push,只有语法树能暴露命令替换;解析不了的就当危险,还是 fail-closed。
拆出的每个子命令逐条对三张规则表做匹配:
- 组合命令必须每条子命令都拿到 allow 才整体 allow。
- 任何一条 deny 则整体 deny。
- 结果打包在
decisionReason: { type: 'subcommandResults' }里,弹窗和遥测都能看到每条子命令各自的判定依据。
假设 git push 没命中任何规则,bashToolHasPermission 返回 ask,阶梯 1d 到 2b 全不拦,第 3 步定型为 ask 返回外层。
default 模式下这就是终点:弹窗问用户,用户点”总是允许”时,附带的 suggestions(PermissionUpdate 结构)会把 Bash(git push:*) 写进对应层级的 settings,下次 2b 直接放行。
规则表就是这样越用越准的:ask 既是兜底,也是新规则的生成器。
auto 模式下则接着跑分类器。
两个快路径都不中(git push 有真实副作用),classifyYoloAction 把会话历史用每个工具的 toAutoClassifierInput 压缩成紧凑 transcript,连同这条待审动作发起一次 side query。
为什么传整个 transcript 而非单条命令?因为孤立的 curl 无从判断,前一步刚 Read 过 .env 再 curl 外部地址,组合起来才是数据外传,分类器必须有上下文才能识别意图。
这也是对 prompt injection 的最后防线:注入的指令会连同来源一起出现在 transcript 里。
判 block 就 deny,并把理由喂回模型;recordDenial 累计,连续 3 次或累计 20 次触发 handleDenialLimitExceeded 回退人工弹窗。
为什么要这个回退?deny 的理由是给模型看的,模型会换个姿势重试,全自动拒绝会陷入”重试、再拒”的死循环白烧 token,连拒阈值就是把人拉回来仲裁的断路器,和熔断器半开探活是一个思路。
还有一处设计值得单说:dontAsk 的 ask 转 deny 不在内层做,而在外层收口,源码注释写明”done at the end so it can’t be bypassed by early returns”。
内层十几个 early return,如果转换逻辑散在各分支,漏掉一个就是绕过;收在唯一出口上,想漏都难。
这和 1 系列排在 2a 之前是同一个原则:安全判定的求值顺序本身就是规格,不靠每个分支的自觉。
设计取舍
- 工程重心在”怎么不调分类器”:白名单、acceptEdits 预检、
toAutoClassifierInput返回''跳过、给 action 块打 cache_control 让两阶段分类器复用前缀缓存,全是为了省下那次又慢又贵的 side query。 - 我一开始以为 dangerousPatterns.ts 是 rm -rf 黑名单,方向反了:它列的是 python/node/bash/eval 这类解释器前缀。
危险的是用户自己配的Bash(python:*)allow 规则,等于给模型留了绕过分类器跑任意代码的门,进 auto 模式时由 permissionSetup.ts 剥离进strippedDangerousRules。 - 每个决定都带
decisionReason(rule/mode/classifier/safetyCheck/hook/subcommandResults),谁定的、依据什么全程可追溯,UI 弹窗、遥测、shadowedRuleDetection.ts 的冗余规则检测共用这份解释。 - fail-closed 但不写死:分类器不可用默认拒绝,留了远程开关可翻成 fail open;bypass 模式有远程 killswitch。安全默认值加运营旋钮,比纯代码硬编码多一条止血通道。
- 三张规则表按 source 分层,policySettings 由企业管理端下发、本地不可编辑,且任何来源的 deny(阶梯 1a)都先于 allow(2b)求值,所以项目配置压不掉组织策略里的 deny。
permissionsLoader.ts 还有个allowManagedPermissionRulesOnly开关,打开后只认管理端规则。
主循环里许可在哪一步调见 03-agent 主循环 的 checkPermissionsAndCallTool,工具接口里的 checkPermissions / validateInput 见 01-Tool 抽象。