Skip to content

Layer 3: 权限系统 —— Agent 自主性与安全性的工程权衡

一个可以执行 shell 命令、编辑文件、调用外部 API 的 Agent,如何在「让用户尽量少操心」和「不做危险的事」之间找到平衡?

这是所有 Agent 框架设计中最关键的工程问题之一。Claude Code 的权限系统是目前业界最精细的 Agent 权限实现,值得深入研究。


┌───────────────────────────┐
│ hasPermissionsToUseTool │ ← 权限决策总入口
│ permissions.ts:473 │
└──────────┬────────────────┘
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ 规则引擎 │ │ 模式引擎 │ │ 分类器引擎 │
│ Rule-based │ │ Mode-based │ │ Classifier │
└─────────────┘ └──────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ allow/deny/ │ │ bypass/plan/ │ │ ML 分类器 │
│ ask 规则 │ │ acceptEdits │ │ auto 模式 │
└─────────────┘ └──────────────┘ └──────────────┘
文件职责行数
types/permissions.ts纯类型定义(打破循环依赖)~440
utils/permissions/permissions.ts权限决策主逻辑~1200
utils/permissions/PermissionMode.ts模式配置与元数据~140
utils/permissions/PermissionRule.ts规则类型定义(Zod schema)~40
utils/permissions/denialTracking.ts拒绝追踪与熔断~45
utils/permissions/classifierDecision.tsML 分类器决策feature-gated
utils/permissions/yoloClassifier.tsAuto 模式分类器feature-gated

// types/permissions.ts:44
type PermissionBehavior = 'allow' | 'deny' | 'ask'

所有权限决策最终归结为三种行为:

  • allow:静默放行,用户无感知
  • deny:静默拒绝,告诉模型「权限不足」
  • ask:弹出交互式对话框,让用户决定

★ 设计洞察:注意没有 warnlog 这类「软性」行为。每个决策都是硬性的、有确定结果的。这避免了安全系统中常见的「告警疲劳」问题。

// types/permissions.ts:28-29
type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
type PermissionMode = InternalPermissionMode
// types/permissions.ts:16-22
const EXTERNAL_PERMISSION_MODES = [
'acceptEdits', // 自动批准文件编辑,其他操作询问
'bypassPermissions', // 跳过所有权限检查(除了安全检查)
'default', // 标准模式,遇到需要权限的操作就询问
'dontAsk', // 不询问,直接拒绝需要权限的操作
'plan', // 计划模式,只允许读操作
] as const

模式之间存在层级关系,从最严格到最宽松:

plan (最严格) → dontAsk → default → acceptEdits → auto → bypassPermissions (最宽松)

★ 设计洞察autobubble 是内部模式(InternalPermissionMode),不暴露给外部用户。auto 使用 ML 分类器自动决策,通过 feature('TRANSCRIPT_CLASSIFIER') 编译时条件编译控制。这说明该功能仍在实验阶段,通过 feature gate 进行灰度发布。

// types/permissions.ts:67-79
type PermissionRuleValue = {
toolName: string // 例如 "Bash"、"mcp__server1"
ruleContent?: string // 例如 "prefix:git *"、"npm publish:*"
}
type PermissionRule = {
source: PermissionRuleSource // 规则来源
ruleBehavior: PermissionBehavior // allow | deny | ask
ruleValue: PermissionRuleValue
}

规则的来源层级(优先级从高到低):

// permissions.ts:109-114
const PERMISSION_RULE_SOURCES = [
...SETTING_SOURCES, // userSettings, projectSettings, localSettings, flagSettings, policySettings
'cliArg', // 命令行参数
'command', // 运行时命令
'session', // 当前会话临时授权
] as const

★ 设计洞察:规则来源区分了 8 个层级,这不是过度设计——它解决的是「谁说了算」的问题。企业管理员的 policySettings 可以覆盖用户的 userSettings,但用户的 session 临时授权又可以在当前会话中生效。这是一个典型的分层配置系统(类似 CSS 的特异性)。


hasPermissionsToUseToolInner() 是核心决策函数(permissions.ts:1158),执行一个精心设计的多步管线

Step 1: 规则检查(硬性边界)
├── 1a. 整个工具被 deny 规则拒绝? → DENY(不可覆盖)
├── 1b. 整个工具有 ask 规则? → ASK(除非沙箱可以自动放行)
├── 1c. 调用 tool.checkPermissions()(工具自身的权限逻辑)
├── 1d. 工具实现返回 deny? → DENY
├── 1e. 工具需要用户交互? → ASK(即使 bypass 模式也必须询问)
├── 1f. 内容级别的 ask 规则? → ASK(如 Bash(npm publish:*))
└── 1g. 安全检查(.git/、.claude/ 等)? → ASK(bypass 免疫)
Step 2: 模式检查(全局策略)
├── 2a. bypassPermissions 模式? → ALLOW
└── 2b. 工具在全局 allow 规则中? → ALLOW
Step 3: 最终处理
├── passthrough → 转换为 ask
├── dontAsk 模式?ask → DENY(静默拒绝)
├── auto 模式?→ 进入 ML 分类器流程
└── 无头模式?→ 尝试 Hook 决策,否则 DENY
// permissions.ts:1158-1318
async function hasPermissionsToUseToolInner(
tool: Tool,
input: { [key: string]: unknown },
context: ToolUseContext,
): Promise<PermissionDecision> {
// 1a. 整个工具被 deny
const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
if (denyRule) {
return { behavior: 'deny', ... }
}
// 1c. 工具自身的权限检查
const parsedInput = tool.inputSchema.parse(input)
toolPermissionResult = await tool.checkPermissions(parsedInput, context)
// 1g. 安全检查 —— bypass 免疫!
if (toolPermissionResult?.behavior === 'ask' &&
toolPermissionResult.decisionReason?.type === 'safetyCheck') {
return toolPermissionResult // 即使 bypass 模式也必须询问
}
// 2a. bypass 模式放行
if (shouldBypassPermissions) {
return { behavior: 'allow', ... }
}
// 3. passthrough → ask
const result = toolPermissionResult.behavior === 'passthrough'
? { ...toolPermissionResult, behavior: 'ask' }
: toolPermissionResult
return result
}

★ 设计洞察:注意步骤的顺序至关重要。deny 规则(1a/1d)在 bypass 模式(2a)之前检查,而安全检查(1g)被标记为「bypass 免疫」。这意味着即使用户开启了 bypassPermissions,对 .git/.claude/ 等关键路径的操作仍然需要确认。这是一个「纵深防御」的设计思想。


当用户选择 auto 模式时(或 Plan 模式下激活了 auto),系统使用一个两阶段 ML 分类器来自动决策:

权限检查返回 ask
├── 安全检查(safetyCheck 且 !classifierApprovable)?→ 直接 ASK
├── 工具需要用户交互?→ 直接 ASK
├── 快速路径 1: acceptEdits 模拟
│ └── 模拟 acceptEdits 模式下的 checkPermissions
│ 如果会放行 → ALLOW(跳过分类器,省 API 调用)
├── 快速路径 2: 安全工具白名单
│ └── isAutoModeAllowlistedTool(tool.name)
│ 如果在白名单 → ALLOW(跳过分类器)
└── 完整分类器路径
└── classifyYoloAction(messages, action, tools, context, signal)
├── shouldBlock = true → DENY + 追踪拒绝
├── shouldBlock = false → ALLOW
└── unavailable → fail-closed 或 fail-open(由 feature flag 控制)

1. 快速路径优化:在调用昂贵的 ML 分类器之前,先检查两条快速路径。如果在 acceptEdits 模式下就会被放行(如安全的文件编辑),直接放行。如果工具在安全白名单中(如 Grep、Glob),直接放行。这极大减少了分类器的调用次数。

// permissions.ts:600-649 — acceptEdits 模拟快速路径
if (result.behavior === 'ask' && tool.name !== AGENT_TOOL_NAME && tool.name !== REPL_TOOL_NAME) {
const acceptEditsResult = await tool.checkPermissions(parsedInput, {
...context,
getAppState: () => ({
...state,
toolPermissionContext: {
...state.toolPermissionContext,
mode: 'acceptEdits' as const, // 模拟 acceptEdits 模式
},
}),
})
if (acceptEditsResult.behavior === 'allow') {
return { behavior: 'allow', ... } // 跳过分类器
}
}

2. 拒绝追踪与熔断

denialTracking.ts
const DENIAL_LIMITS = {
maxConsecutive: 3, // 连续 3 次拒绝
maxTotal: 20, // 累计 20 次拒绝
}
function shouldFallbackToPrompting(state: DenialTrackingState): boolean {
return (
state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
state.totalDenials >= DENIAL_LIMITS.maxTotal
)
}

★ 设计洞察:拒绝追踪解决了一个关键问题——如果分类器反复拒绝 Agent 的操作,Agent 会陷入死循环。当连续被拒绝 3 次或累计被拒绝 20 次,系统自动「回退到人工审批」模式,让用户来决定。这是一个工程上的「熔断器」模式。同时,任何一次成功的操作都会重置连续拒绝计数器(recordSuccess),避免误触发。

3. 容错设计:分类器不可用时的处理策略由 feature flag tengu_iron_gate_closed 控制:

  • fail-closed(默认):分类器挂了就拒绝,宁可卡住也不放过
  • fail-open:分类器挂了就回退到正常的交互式审批
// permissions.ts:846-876
if (classifierResult.unavailable) {
if (getFeatureValue_CACHED_WITH_REFRESH('tengu_iron_gate_closed', true, 30 * 60 * 1000)) {
return { behavior: 'deny', ... } // fail-closed
}
return result // fail-open:回退到正常流程
}

// permissions.ts:238-269
function toolMatchesRule(tool, rule): boolean {
// 规则不能有 content(content 用于子命令匹配)
if (rule.ruleValue.ruleContent !== undefined) return false
// 直接名称匹配:规则 "Bash" 匹配工具 "Bash"
if (rule.ruleValue.toolName === nameForRuleMatch) return true
// MCP 服务器级匹配:规则 "mcp__server1" 匹配 "mcp__server1__tool1"
// 通配符支持:规则 "mcp__server1__*" 匹配该服务器所有工具
return ruleInfo?.serverName === toolInfo?.serverName
&& (ruleInfo?.toolName === undefined || ruleInfo?.toolName === '*')
}

内容级匹配由每个工具自己的 checkPermissions() 实现。例如 BashTool 解析命令前缀:

规则 "Bash(prefix:git *)" → 匹配所有 git 开头的命令
规则 "Bash(npm publish:*)" → 匹配 npm publish 相关命令
规则 "Agent(Explore)" → 匹配 Explore 类型的子 Agent
// Tool.ts:123-138
type ToolPermissionContext = DeepImmutable<{
mode: PermissionMode
additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
alwaysAllowRules: ToolPermissionRulesBySource // { userSettings: ["Bash(prefix:git *)"], ... }
alwaysDenyRules: ToolPermissionRulesBySource
alwaysAskRules: ToolPermissionRulesBySource
isBypassPermissionsModeAvailable: boolean
isAutoModeAvailable?: boolean
shouldAvoidPermissionPrompts?: boolean // 无头模式(后台 Agent)
awaitAutomatedChecksBeforeDialog?: boolean // 先跑自动化检查再弹框
prePlanMode?: PermissionMode // Plan 模式前的原始模式
}>

★ 设计洞察DeepImmutable&lt;> 包装意味着权限上下文是不可变的。修改权限状态需要创建新的 context 对象,这保证了在并发 Agent 场景下的线程安全。


后台运行的 Agent(如子 Agent、远程 Agent)无法弹出交互式对话框。系统提供了特殊处理:

// permissions.ts:400-471
async function runPermissionRequestHooksForHeadlessAgent(...) {
// 1. 先给 Hook 一个机会来决策
for await (const hookResult of executePermissionRequestHooks(...)) {
if (decision.behavior === 'allow') return allowDecision
if (decision.behavior === 'deny') return denyDecision
}
// 2. 没有 Hook 决策 → 返回 null,调用方会 auto-deny
return null
}
// permissions.ts:929-952
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
const hookDecision = await runPermissionRequestHooksForHeadlessAgent(...)
if (hookDecision) return hookDecision
return { behavior: 'deny', message: AUTO_REJECT_MESSAGE(tool.name) }
}

★ 设计洞察:这是 Hook 系统与权限系统的关键交互点。企业用户可以注册 PermissionRequest Hook,在无头 Agent 模式下自动审批特定操作。这让自动化 CI/CD 管线成为可能——Agent 在后台运行,Hook 脚本根据策略自动决策。


用户在交互式对话框中做出的选择可以持久化为规则:

// types/permissions.ts:98-131
type PermissionUpdate =
| { type: 'addRules'; destination: PermissionUpdateDestination; rules: ...; behavior: ... }
| { type: 'replaceRules'; ... }
| { type: 'removeRules'; ... }
| { type: 'setMode'; destination: ...; mode: ... }
| { type: 'addDirectories'; ... }
| { type: 'removeDirectories'; ... }
type PermissionUpdateDestination =
| 'userSettings' // 全局用户设置
| 'projectSettings' // 项目级设置
| 'localSettings' // 本地设置
| 'session' // 仅当前会话
| 'cliArg' // CLI 参数级别

用户的选择对应不同的持久化层级:

  • 「Allow Once」→ session(会话结束即失效)
  • 「Always Allow」→ userSettings(永久生效)
  • 「Deny」→ 同上

8.1 分层决策是 Agent 权限的最佳实践

Section titled “8.1 分层决策是 Agent 权限的最佳实践”

不要设计单一的 allow/deny 开关。工业级 Agent 需要:

  • 多个决策源(规则、模式、分类器、Hook)
  • 明确的优先级链(deny 优先于 allow,安全检查免疫 bypass)
  • 多级持久化(session < user < project < policy)

某些操作(修改 .git/.claude/、shell 配置文件)即使在最宽松的模式下也必须询问用户。这是纵深防御的关键层。

Claude Code 的 auto 模式展示了如何正确使用 ML 分类器做权限决策:

  • 快速路径减少调用:先用确定性规则过滤
  • 熔断机制:连续拒绝 3 次或累计 20 次就回退到人工
  • 容错设计:分类器不可用时有明确的 fail-closed/fail-open 策略
  • 可观测性:每次分类器决策都有详细的遥测数据

DeepImmutable&lt;ToolPermissionContext> 确保多个 Agent 可以安全地并发读取权限上下文,修改需要创建新对象。


  • → Tool 系统(L2):每个工具实现 checkPermissions() 方法,提供工具特定的权限逻辑
  • → Hook 系统(L4)PermissionRequest Hook 可以拦截并自动决策
  • → Sub-agent(L6):无头 Agent 使用 shouldAvoidPermissionPrompts + Hook 决策
  • → Agent Loop(L1):权限检查在工具执行管线中,位于 runToolUse() 的前置步骤