本章总览
utils/permissions/ 实现 Claude Code 的工具权限大脑:从 settings / policy / managed 源加载 allow·deny·ask 规则,调用各 Tool 的 checkPermissions,在 auto 模式走 transcript classifier,在 dontAsk 模式把 ask 折叠为 deny,并通过 hasPermissionsToUseTool 暴露为 CanUseToolFn 供 query 与 hooks/useCanUseTool 消费。本章以 permissions.ts 为主线,串联 loader、setup、classifier 与 PermissionRule 类型。
学完本章你应该能
- 画出 hasPermissionsToUseTool 外层包装与 Inner 求值顺序
- 说明 deny / ask 规则与工具自带 checkPermissions 的优先级
- 理解 dontAsk 与 auto 模式对 ask 的不同变换
- 能在 permissionSetup 中找到规则加载与模式切换入口
- 区分 PermissionRequest hook(utils/hooks.ts)与规则引擎
核心概念(先读懂这些)
PermissionDecision 三分支
行为只有 allow、deny、ask。allow 可携带更新后的 input(例如自动修正路径)。deny 带 message 与 decisionReason(rule、hook、safetyCheck、mode 等)。ask 触发 UI 或 classifier。hasPermissionsToUseTool 在外层处理 auto 模式 consecutive denials 重置、dontAsk 变换、classifier 与 PowerShell 特殊策略,Inner 函数做规则与工具检查。
规则源与 allowManagedPermissionRulesOnly
permissionsLoader 从多 SettingSource 读取 JSON 规则;policySettings 可开启 allowManagedPermissionRulesOnly,此时仅 managed 规则生效,且 UI 隐藏「始终允许」类选项(shouldShowAlwaysAllowOptions)。企业部署与本地开发的行为差异往往来自这里,而非 permissions.ts 核心逻辑。
与 useCanUseTool 的分层
permissions.ts 不做 React、不入队弹窗。hooks/useCanUseTool 在 ask 时 push PermissionRequest。调试时先 log Inner 返回的 decisionReason,再看 UI 是否入队。PermissionRequest 事件 还可被 utils/hooks.ts 用户脚本拦截(headless agent 路径在 permissions.ts 前段也有 hook 尝试)。
建议学习步骤
- 阅读 hasPermissionsToUseTool 导出与 auto/dontAsk 包装
- 阅读 hasPermissionsToUseToolInner 步骤 1a–1d
- 阅读 PermissionRule 的 allow/deny/ask 语义
- 阅读 permissionSetup 的加载与模式迁移
- 阅读 permissionsLoader 的 managed-only 开关
常见误区
注意
bypassPermissions 与 acceptEdits 快路径在 Inner 更深处,勿与 1a deny 混淆
注意
classifierApprovable 的 safetyCheck 在 auto 模式仍可能保持 ask
注意
PowerShell 在 auto 模式有单独门控(POWERSHELL_AUTO_MODE feature)
目录结构与职责划分
utils/permissions/ 核心文件:
| 文件 | 职责 |
|---|---|
| permissions.ts | hasPermissionsToUseTool、规则匹配、classifier 集成 |
| permissionsLoader.ts | 从磁盘/settings 加载规则 |
| permissionSetup.ts | 启动时组装 ToolPermissionContext、模式切换 |
| PermissionRule.ts / permissionRuleParser.ts | 规则 AST 与序列化 |
| bashClassifier.ts / yoloClassifier.ts | auto 模式分类器 |
| denialTracking.ts | 连续拒绝计数 |
| pathValidation.ts / dangerousPatterns.ts | 路径与安全模式 |
约 24 个 TS 文件协同,permissions.ts 单行数最多,是阅读入口。
hasPermissionsToUseTool 外层:模式变换与计数
导出函数(约 473 行)先 await Inner,再:
- allow 且 auto 模式: 若 consecutiveDenials > 0,recordSuccess 并 persistDenialState(TRANSCRIPT_CLASSIFIER feature)
- ask + dontAsk 模式: 转为 deny,message 用 DONT_ASK_REJECT_MESSAGE(来自 messages.ts)
- ask + auto/plan+auto: 走 classifier 分支;非 classifierApprovable 的 safetyCheck 在 shouldAvoidPermissionPrompts 时直接 deny
- requiresUserInteraction 工具: 保持 ask,不走 classifier 自动批准
这段包装保证「模式开关」无法被 Inner 的早期 allow 绕过(注释:dontAsk 在末尾转换以免被 bypass)。
阅读时对照 ToolPermissionContext.mode 枚举(PermissionMode.ts)。
源码引用: src/utils/permissions/permissions.ts · 第 473–560 行(共 1487 行)
473| export const hasPermissionsToUseTool: CanUseToolFn = async (
474| tool,
475| input,
476| context,
477| assistantMessage,
478| toolUseID,
479| ): Promise<PermissionDecision> => {
480| const result = await hasPermissionsToUseToolInner(tool, input, context)
481|
482|
483| // Reset consecutive denials on any allowed tool use in auto mode.
484| // This ensures that a successful tool use (even one auto-allowed by rules)
485| // breaks the consecutive denial streak.
486| if (result.behavior === 'allow') {
487| const appState = context.getAppState()
488| if (feature('TRANSCRIPT_CLASSIFIER')) {
489| const currentDenialState =
490| context.localDenialTracking ?? appState.denialTracking
491| if (
492| appState.toolPermissionContext.mode === 'auto' &&
493| currentDenialState &&
494| currentDenialState.consecutiveDenials > 0
495| ) {
496| const newDenialState = recordSuccess(currentDenialState)
497| persistDenialState(context, newDenialState)
498| }
499| }
500| return result
501| }
502|
503| // Apply dontAsk mode transformation: convert 'ask' to 'deny'
504| // This is done at the end so it can't be bypassed by early returns
505| if (result.behavior === 'ask') {
506| const appState = context.getAppState()
507|
508| if (appState.toolPermissionContext.mode === 'dontAsk') {
509| return {
510| behavior: 'deny',
511| decisionReason: {
512| type: 'mode',
513| mode: 'dontAsk',
514| },
515| message: DONT_ASK_REJECT_MESSAGE(tool.name),
516| }
517| }
518| // Apply auto mode: use AI classifier instead of prompting user
519| // Check this BEFORE shouldAvoidPermissionPrompts so classifiers work in headless mode
520| if (
521| feature('TRANSCRIPT_CLASSIFIER') &&
522| (appState.toolPermissionContext.mode === 'auto' ||
523| (appState.toolPermissionContext.mode === 'plan' &&
524| (autoModeStateModule?.isAutoModeActive() ?? false)))
525| ) {
526| // Non-classifier-approvable safetyCheck decisions stay immune to ALL
527| // auto-approve paths: the acceptEdits fast-path, the safe-tool allowlist,
528| // and the classifier. Step 1g only guards bypassPermissions; this guards
529| // auto. classifierApprovable safetyChecks (sensitive-file paths) fall
530| // through to the classifier — the fast-paths below naturally don't fire
531| // because the tool's own checkPermissions still returns 'ask'.
532| if (
533| result.decisionReason?.type === 'safetyCheck' &&
534| !result.decisionReason.classifierApprovable
535| ) {
536| if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
537| return {
538| behavior: 'deny',
539| message: result.message,
540| decisionReason: {
541| type: 'asyncAgent',
542| reason:
543| 'Safety check requires interactive approval and permission prompts are not available in this context',
544| },
545| }
546| }
547| return result
548| }
549| if (tool.requiresUserInteraction?.() && result.behavior === 'ask') {
550| return result
551| }
552|
553| // Use local denial tracking for async subagents (whose setAppState
554| // is a no-op), otherwise read from appState as before.
555| const denialState =
556| context.localDenialTracking ??
557| appState.denialTracking ??
558| createDenialTrackingState()
559|
560| // PowerShell requires explicit user permission in auto mode unless
hasPermissionsToUseToolInner:规则与工具检查
Inner(约 1158 行起)按序:
1. 规则层
- 1a denyRule:整工具拒绝
- 1b askRule:整工具强制询问;沙箱 autoAllowBash 可跳过 Bash ask
- 1c 调用 tool.inputSchema.parse + tool.checkPermissions
- 1d 工具返回 deny 则立即 deny
2. 后续步骤(本段后继续读文件) 包括 allow 规则、acceptEdits、bypassPermissions killswitch、shadowed rule 检测等。
abort 时抛 AbortError,不返回 deny。无效 schema 记 logError 并可能 passthrough。
实战: 若 Bash 总是 ask,先查 askRule 与 checkPermissions,再看 sandbox 配置。
源码引用: src/utils/permissions/permissions.ts · 第 1158–1228 行(共 1487 行)
1158| async function hasPermissionsToUseToolInner(
1159| tool: Tool,
1160| input: { [key: string]: unknown },
1161| context: ToolUseContext,
1162| ): Promise<PermissionDecision> {
1163| if (context.abortController.signal.aborted) {
1164| throw new AbortError()
1165| }
1166|
1167| let appState = context.getAppState()
1168|
1169| // 1. Check if the tool is denied
1170| // 1a. Entire tool is denied
1171| const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
1172| if (denyRule) {
1173| return {
1174| behavior: 'deny',
1175| decisionReason: {
1176| type: 'rule',
1177| rule: denyRule,
1178| },
1179| message: `Permission to use ${tool.name} has been denied.`,
1180| }
1181| }
1182|
1183| // 1b. Check if the entire tool should always ask for permission
1184| const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
1185| if (askRule) {
1186| // When autoAllowBashIfSandboxed is on, sandboxed commands skip the ask rule and
1187| // auto-allow via Bash's checkPermissions. Commands that won't be sandboxed (excluded
1188| // commands, dangerouslyDisableSandbox) still need to respect the ask rule.
1189| const canSandboxAutoAllow =
1190| tool.name === BASH_TOOL_NAME &&
1191| SandboxManager.isSandboxingEnabled() &&
1192| SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
1193| shouldUseSandbox(input)
1194|
1195| if (!canSandboxAutoAllow) {
1196| return {
1197| behavior: 'ask',
1198| decisionReason: {
1199| type: 'rule',
1200| rule: askRule,
1201| },
1202| message: createPermissionRequestMessage(tool.name),
1203| }
1204| }
1205| // Fall through to let Bash's checkPermissions handle command-specific rules
1206| }
1207|
1208| // 1c. Ask the tool implementation for a permission result
1209| // Overridden unless tool input schema is not valid
1210| let toolPermissionResult: PermissionResult = {
1211| behavior: 'passthrough',
1212| message: createPermissionRequestMessage(tool.name),
1213| }
1214| try {
1215| const parsedInput = tool.inputSchema.parse(input)
1216| toolPermissionResult = await tool.checkPermissions(parsedInput, context)
1217| } catch (e) {
1218| // Rethrow abort errors so they propagate properly
1219| if (e instanceof AbortError || e instanceof APIUserAbortError) {
1220| throw e
1221| }
1222| logError(e)
1223| }
1224|
1225| // 1d. Tool implementation denied permission
1226| if (toolPermissionResult?.behavior === 'deny') {
1227| return toolPermissionResult
1228| }
PermissionRule:allow / deny / ask
PermissionRule 由 toolName + ruleContent 组成,behavior 为 allow | deny | ask。
- allow: 匹配则倾向放行(仍可能被子sequent safety 拦截)
- deny: 硬拒绝,message 通常固定
- ask: 强制弹窗;Bash 规则可带命令模式,由 shellRuleMatching 解析
permissionRuleValueSchema 用 zod lazySchema,类型定义已抽到 types/permissions.ts 打破循环依赖。
用户可见的 /permissions 命令修改规则后,通过 PermissionUpdate 应用,permissionSetup 负责刷新 context。
源码引用: src/utils/permissions/PermissionRule.ts · 第 19–40 行(共 41 行)
19| /**
20| * ToolPermissionBehavior is the behavior associated with a permission rule.
21| * 'allow' means the rule allows the tool to run.
22| * 'deny' means the rule denies the tool from running.
23| * 'ask' means the rule forces a prompt to be shown to the user.
24| */
25| export const permissionBehaviorSchema = lazySchema(() =>
26| z.enum(['allow', 'deny', 'ask']),
27| )
28|
29| /**
30| * PermissionRuleValue is the content of a permission rule.
31| * @param toolName - The name of the tool this rule applies to
32| * @param ruleContent - The optional content of the rule.
33| * Each tool may implement custom handling in `checkPermissions()`
34| */
35| export const permissionRuleValueSchema = lazySchema(() =>
36| z.object({
37| toolName: z.string(),
38| ruleContent: z.string().optional(),
39| }),
40| )
permissionSetup:启动与模式迁移
permissionSetup.ts 连接 bootstrap state、settings、growthbook feature gate:
- loadAllPermissionRulesFromDisk 合并各源规则
- applyPermissionRulesToPermissionContext 写入 ToolPermissionContext
- handleAutoModeTransition / handlePlanModeTransition 与 plan 模式、auto 互斥逻辑
- 危险模式 DANGEROUS_BASH_PATTERNS、CROSS_PLATFORM_CODE_EXEC 用于规则建议与安全提示
- isOverlyBroadPowerShellAllowRule 等防止 PowerShell(*) 过宽 allow
阅读建议: 搜索 applyPermissionUpdate 看 UI 确认后如何持久化到 settings 文件。
源码引用: src/utils/permissions/permissionSetup.ts · 第 1–80 行(共 1533 行)
1| import { feature } from 'bun:bundle'
2| import { relative } from 'path'
3| import {
4| getOriginalCwd,
5| handleAutoModeTransition,
6| handlePlanModeTransition,
7| setHasExitedPlanMode,
8| setNeedsAutoModeExitAttachment,
9| } from '../../bootstrap/state.js'
10| import type {
11| ToolPermissionContext,
12| ToolPermissionRulesBySource,
13| } from '../../Tool.js'
14| import { getCwd } from '../cwd.js'
15| import { isEnvTruthy } from '../envUtils.js'
16| import type { SettingSource } from '../settings/constants.js'
17| import { SETTING_SOURCES } from '../settings/constants.js'
18| import {
19| getSettings_DEPRECATED,
20| getSettingsFilePathForSource,
21| getUseAutoModeDuringPlan,
22| hasAutoModeOptIn,
23| } from '../settings/settings.js'
24| import {
25| type PermissionMode,
26| permissionModeFromString,
27| } from './PermissionMode.js'
28| import { applyPermissionRulesToPermissionContext } from './permissions.js'
29| import { loadAllPermissionRulesFromDisk } from './permissionsLoader.js'
30|
31| /* eslint-disable @typescript-eslint/no-require-imports */
32| const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER')
33| ? (require('./autoModeState.js') as typeof import('./autoModeState.js'))
34| : null
35|
36| import { resolve } from 'path'
37| import {
38| checkSecurityRestrictionGate,
39| checkStatsigFeatureGate_CACHED_MAY_BE_STALE,
40| getDynamicConfig_BLOCKS_ON_INIT,
41| getFeatureValue_CACHED_MAY_BE_STALE,
42| } from 'src/services/analytics/growthbook.js'
43| import {
44| addDirHelpMessage,
45| validateDirectoryForWorkspace,
46| } from '../../commands/add-dir/validation.js'
47| import {
48| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
49| logEvent,
50| } from '../../services/analytics/index.js'
51| import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'
52| import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
53| /* eslint-enable @typescript-eslint/no-require-imports */
54| import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
55| import { getToolsForDefaultPreset, parseToolPreset } from '../../tools.js'
56| import {
57| getFsImplementation,
58| safeResolvePath,
59| } from '../../utils/fsOperations.js'
60| import { modelSupportsAutoMode } from '../betas.js'
61| import { logForDebugging } from '../debug.js'
62| import { gracefulShutdown } from '../gracefulShutdown.js'
63| import { getMainLoopModel } from '../model/model.js'
64| import {
65| CROSS_PLATFORM_CODE_EXEC,
66| DANGEROUS_BASH_PATTERNS,
67| } from './dangerousPatterns.js'
68| import type {
69| PermissionRule,
70| PermissionRuleSource,
71| PermissionRuleValue,
72| } from './PermissionRule.js'
73| import {
74| type AdditionalWorkingDirectory,
75| applyPermissionUpdate,
76| } from './PermissionUpdate.js'
77| import type { PermissionUpdateDestination } from './PermissionUpdateSchema.js'
78| import {
79| normalizeLegacyToolName,
80| permissionRuleValueFromString,
源码引用: src/utils/permissions/permissionSetup.ts · 第 64–76 行(共 1533 行)
64| import {
65| CROSS_PLATFORM_CODE_EXEC,
66| DANGEROUS_BASH_PATTERNS,
67| } from './dangerousPatterns.js'
68| import type {
69| PermissionRule,
70| PermissionRuleSource,
71| PermissionRuleValue,
72| } from './PermissionRule.js'
73| import {
74| type AdditionalWorkingDirectory,
75| applyPermissionUpdate,
76| } from './PermissionUpdate.js'
permissionsLoader:磁盘与 managed-only
shouldAllowManagedPermissionRulesOnly 读 policySettings.allowManagedPermissionRulesOnly。
shouldShowAlwaysAllowOptions 取反,控制 PermissionRequest UI 是否展示「始终允许」。
SUPPORTED_RULE_BEHAVIORS 限定 allow/deny/ask。load 流程解析字符串规则(permissionRuleValueFromString)并标注 SettingSource,供 shadowedRuleDetection 提示用户哪条规则被覆盖。
企业策略文件变更应触发 ConfigChange hook(见 shell-hooks 章)做审计。
源码引用: src/utils/permissions/permissionsLoader.ts · 第 27–50 行(共 297 行)
27| /**
28| * Returns true if allowManagedPermissionRulesOnly is enabled in managed settings (policySettings).
29| * When enabled, only permission rules from managed settings are respected.
30| */
31| export function shouldAllowManagedPermissionRulesOnly(): boolean {
32| return (
33| getSettingsForSource('policySettings')?.allowManagedPermissionRulesOnly ===
34| true
35| )
36| }
37|
38| /**
39| * Returns true if "always allow" options should be shown in permission prompts.
40| * When allowManagedPermissionRulesOnly is enabled, these options are hidden.
41| */
42| export function shouldShowAlwaysAllowOptions(): boolean {
43| return !shouldAllowManagedPermissionRulesOnly()
44| }
45|
46| const SUPPORTED_RULE_BEHAVIORS = [
47| 'allow',
48| 'deny',
49| 'ask',
50| ] as const satisfies PermissionBehavior[]
Headless 与 PermissionRequest hook
permissions.ts 前部(约 450 行)在 headless agent 路径尝试 PermissionRequest hook:hook 可返回 allow/deny 附加 message;失败则 logError 并 fall through。
这与 utils/hooks.ts 的 executePermissionRequestHooks 是同一事件名的不同调用上下文。REPL 内由 useCanUseTool → interactiveHandler 触发 hook;无 UI 时由引擎直接 ask hook。
调试: 若 CI agent 无故 deny,搜 PermissionRequest hook 配置与 shouldAvoidPermissionPrompts。
源码引用: src/utils/permissions/permissions.ts · 第 420–471 行(共 1487 行)
420| }
421| const decision = hookResult.permissionRequestResult
422| if (decision.behavior === 'allow') {
423| const finalInput = decision.updatedInput ?? input
424| // Persist permission updates if provided
425| if (decision.updatedPermissions?.length) {
426| persistPermissionUpdates(decision.updatedPermissions)
427| context.setAppState(prev => ({
428| ...prev,
429| toolPermissionContext: applyPermissionUpdates(
430| prev.toolPermissionContext,
431| decision.updatedPermissions!,
432| ),
433| }))
434| }
435| return {
436| behavior: 'allow',
437| updatedInput: finalInput,
438| decisionReason: {
439| type: 'hook',
440| hookName: 'PermissionRequest',
441| },
442| }
443| }
444| if (decision.behavior === 'deny') {
445| if (decision.interrupt) {
446| logForDebugging(
447| `Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`,
448| )
449| context.abortController.abort()
450| }
451| return {
452| behavior: 'deny',
453| message: decision.message || 'Permission denied by hook',
454| decisionReason: {
455| type: 'hook',
456| hookName: 'PermissionRequest',
457| reason: decision.message,
458| },
459| }
460| }
461| }
462| } catch (error) {
463| // If hooks fail, fall through to auto-deny rather than crashing
464| logError(
465| new Error('PermissionRequest hook failed for headless agent', {
466| cause: toError(error),
467| }),
468| )
469| }
470| return null
471| }
源码目录
点击 permissions/ 展开 classifier、denialTracking、filesystem 等子文件。yolo-classifier-prompts/ 下 txt 为分类器系统提示词,改文案需同步 classifier 版本测试。
动手练习
- 在 settings 添加 Bash(deny) 规则,触发 Bash tool_use,确认 decisionReason.type === 'rule'
- 切换 dontAsk,观察 ask 是否变为 deny 且 message 含 don't ask mode
- 在 hasPermissionsToUseTool 外层打日志,区分 Inner 与 classifier 改写
- 阅读 hooks 章 useCanUseTool,画一张从 hasPermissionsToUseTool 到弹窗的序列图
本章小结与延伸
permissions/ = 策略求值。UI 接缝在 hooks/useCanUseTool;扩展点在 settings 规则与 PermissionRequest hook。 继续学习: