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 两个核心设计哲学
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 模式转换状态机
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 层优先级(由低到高)
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 步走,任一步命中即短路。
5.1 为什么 deny 在 allow 之前
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 决策树
-
路径合法性
必须是绝对路径;没有 null byte / shell 元字符的 escape 攻击
-
dangerous path 黑名单
~/.ssh、~/.aws、/etc/passwd、/etc/shadow 等硬编码
-
additionalWorkingDirectories 白名单
用户显式授权的额外目录(/add-dir 命令)
-
cwd + worktree 自动允许
当前工作目录总是被允许
-
用户规则匹配
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 流程
- 构建 transcript:buildTranscriptForClassifier 把命令 + 上下文组装成 prompt
- 调用 Haiku:用小模型分类(成本 < 1/100 主模型)
- 解析结果:输出
YOLO/ASK/BLOCK - 缓存决策:同一命令不重复分类
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 为什么这样设计
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 生命周期
-
进入
用户 Shift+Tab 或 EnterPlanMode tool → mode = 'plan'
-
Plan 期
模型只能 Read / Grep / Glob / WebFetch / Agent(仅 Explore/Plan 类只读 agent)
-
产出计划
ExitPlanModeV2Tool 生成计划文档 → 让用户 approve
-
批准
用户 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")
| 模式 | 结果 | 原因 |
|---|---|---|
default | prompt | 无 allow 规则 → classifier → ASK → 对话框 |
plan | deny | plan 模式禁写 |
acceptEdits | prompt | acceptEdits 只对 Edit/Write 工具短路,Bash 不在其内 |
bypassPermissions | allow | bypass 全放行(killswitch 未触发时) |
auto | 取决于 classifier | transcript classifier 判断,可能 allow 可能 prompt |
16.2 Bash("rm -rf /")
| 模式 | 结果 | 原因 |
|---|---|---|
| 所有 | deny | 匹配 DANGEROUS_PATTERNS 硬编码,即使 bypass 也拒 |
dangerousPatterns.ts 就是这个系统的"安全网"。
17可迁移设计原则
- Fail-closed by default — 不明确就拒绝,比不明确就放行安全得多
- Layered authority — 企业 > 用户 > 会话,低层无法覆盖高层的 deny
- deny always beats allow — 即使同层,deny 永远胜
- 硬编码危险模式 — 某些命令应该无论规则怎么配都禁,作为最后安全网
- Mode 是快捷方式 — plan/bypass 等 mode 是"快速调整一组规则"的语法糖
- 分类器 + 规则融合 — 规则精确但不全,分类器覆盖长尾;融合时取最严
- 子 agent 只能提升限制 — 用户的信任决策不被子系统撤销
- 来源追溯 — deny 时告诉用户"是哪一层禁的",便于沟通和修复