utils/permissions/ 目录下有 21 个文件,核心的 permissions.ts 有 51 KB。这一章讲清楚「凭什么让这个工具调用跑起来」这个问题的完整答案。
const PERMISSION_MODE_CONFIG: Partial<Record<PermissionMode, PermissionModeConfig>> = {
default: { title: 'Default', symbol: '', color: 'text' },
plan: { title: 'Plan Mode', symbol: '⏸', color: 'planMode' },
acceptEdits: { title: 'Accept edits', symbol: '⏵⏵', color: 'autoAccept' },
bypassPermissions: { title: 'Bypass Permissions', symbol: '⏵⏵', color: 'error' },
dontAsk: { title: "Don't Ask", symbol: '⏵⏵', color: 'error' },
...(feature('TRANSCRIPT_CLASSIFIER') ? {
auto: { title: 'Auto mode', symbol: '⏵⏵', color: 'warning' },
} : {}),
}
claude-code/src/utils/permissions/PermissionMode.ts
| 模式 | 行为 |
|---|---|
default | 默认。危险操作弹确认框问用户 |
plan计划模式 | 只允许只读操作。模型先做调研、出方案,用户批准后才切回执行模式。用来防止「模型理解错了就直接动手」 |
acceptEdits接受编辑 | 文件编辑类操作自动放行,其他仍然要问。比默认宽松,比 bypass 严格 |
bypassPermissions跳过权限 | 对应 --dangerously-skip-permissions。但仍有一层绕不过,见 7.2 |
dontAsk不要问我 | 把所有「需要询问」直接转成「拒绝」。和 bypass 相反 —— bypass 是「都放行」,这个是「都拒绝」。适合完全不想被打扰又不想冒险的场景 |
auto自动模式 | 内部版特性。用模型分类器代替人来做安全判断,见 7.3 |
还有一个类型上的区分很讲究:
export function isExternalPermissionMode(mode: PermissionMode): mode is ExternalPermissionMode {
if (process.env.USER_TYPE !== 'ant') return true // 外部用户没有 auto,所以永远为真
return mode !== 'auto' && mode !== 'bubble'
}
export function toExternalPermissionMode(mode: PermissionMode): ExternalPermissionMode {
return getModeConfig(mode).external // auto 对外映射成 default
}
内部模式对外要有一个映射。auto 模式对外部接口报告成 default —— 这样开发工具包的使用者不会看到一个他们理解不了、也无法设置的模式值。
核心函数 hasPermissionsToUseToolInner() 是一条严格有序的判定链,从上到下逐条检查,第一个命中的直接决定结果:
| 步骤 | 检查什么 | 结果 |
|---|---|---|
| 0 | 中止信号已拉 | 拒绝 |
| 1a | 整个工具被拒绝规则命中 | DENY |
| 1b | 整个工具被询问规则命中 | ASK 例外:如果这条 Bash 命令能在沙箱里安全执行,跳过继续往下 |
| 1c | 调用工具自己的 checkPermissions() | 拿到工具自己的判断,不直接出结果 |
| ↓ ↓ ↓ 以下四步是 bypass 免疫层 ↓ ↓ ↓ | ||
| 1d | 工具自己明确说了「拒绝」 | DENY |
| 1e | 工具声明「必须有人在场」 | ASK |
| 1f | 用户显式配了内容级询问规则 | ASK |
| 1g | 安全检查:碰到敏感路径 | ASK |
| ↑ ↑ ↑ 以上四步是 bypass 免疫层 ↑ ↑ ↑ | ||
| 2a | bypassPermissions 模式 | ALLOW |
| 2b | 整个工具被允许规则命中 | ALLOW |
| 3 | 都没命中 | ASK(默认落到人工确认) |
// 1d. Tool implementation denied (catches bash subcommand denies wrapped ...)
// 1e. Tool requires user interaction even in bypass mode
// 1f. Content-specific ask rules from tool.checkPermissions take precedence
// over bypassPermissions mode. When a user explicitly configures a
// content-specific ask rule (e.g. Bash(npm publish:*)), the tool's
// checkPermissions returns {behavior:'ask', ...}. This must be respected
// even in bypass mode, just as deny rules are respected at step 1d.
// 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
// bypass-immune — they must prompt even in bypassPermissions mode.
| 步骤 | 守的是什么 |
|---|---|
| 1d | 工具作者的判断。工具最了解自己的操作有多危险,它的拒绝不能被外部覆盖 |
| 1e | 物理必要性。「向用户提问」这个工具,没人在场就无法完成,放行也没意义 |
| 1f | 用户更具体的意图。用户开 bypass 是想说「别拿常规操作烦我」,但他专门配了 Bash(npm publish:*) 要问,说明这一条是他特意留的闸门。更具体的配置优先于更笼统的配置 |
| 1g | 不可挽回的破坏。删掉 .git/ 等于丢掉整个版本历史;改掉 .claude/ 等于智能体自己改自己的权限配置;改 shell 启动脚本等于留后门 |
「绕过权限」不等于「绕过一切」。这是一个成熟的产品判断:给用户「关掉烦人确认」的自由,但不给「一键自毁」的自由。如果不留这个底座,第一个不小心让智能体删掉自己 .git 的用户,会永久失去对这个产品的信任。
当判定落到 ASK 且当前是自动模式时,Claude Code 不弹窗,而是再调一次模型来判断这个动作安不安全。这个专用调用叫「分类器」。
但分类器不便宜 —— 每个工具调用一次额外的接口请求。所以前面挡了几层:
if (feature('TRANSCRIPT_CLASSIFIER') &&
(appState.toolPermissionContext.mode === 'auto' ||
(appState.toolPermissionContext.mode === 'plan' && isAutoModeActive()))) {
// 拦截 1:安全检查命中、且这类检查"分类器无权批准"
if (result.decisionReason?.type === 'safetyCheck' &&
!result.decisionReason.classifierApprovable) {
if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
return { behavior:'deny', message: result.message,
decisionReason: { type:'asyncAgent',
reason:'Safety check requires interactive approval and permission '
+ 'prompts are not available in this context' } }
}
return result // 保持 ASK
}
// 拦截 2:工具声明"必须有人在场"
if (tool.requiresUserInteraction?.() && result.behavior === 'ask') return result
// 拦截 3:PowerShell(除非编译期开了特殊开关)
if (tool.name === POWERSHELL_TOOL_NAME && !feature('POWERSHELL_AUTO_MODE')) { ... }
// 快速通道 ①:伪装成 acceptEdits 模式再问一次工具
if (result.behavior === 'ask' &&
tool.name !== AGENT_TOOL_NAME && tool.name !== REPL_TOOL_NAME) {
const acceptEditsResult = await tool.checkPermissions(parsedInput, {
...context,
getAppState: () => ({ ...context.getAppState(),
toolPermissionContext: { ...state.toolPermissionContext,
mode: 'acceptEdits' as const } }),
})
if (acceptEditsResult.behavior === 'allow') {
logEvent('tengu_auto_mode_decision', { decision:'allowed', fastPath:'acceptEdits', ... })
return { behavior:'allow', updatedInput: acceptEditsResult.updatedInput ?? input,
decisionReason: { type:'mode', mode:'auto' } }
}
}
// 快速通道 ②:安全工具白名单
if (classifierDecisionModule!.isAutoModeAllowlistedTool(tool.name)) {
logEvent('tengu_auto_mode_decision', { decision:'allowed', fastPath:'allowlist', ... })
return { behavior:'allow', updatedInput: input, decisionReason: { type:'mode', mode:'auto' } }
}
// 都没命中 → 真的调分类器
const action = formatActionForClassifier(tool.name, input)
setClassifierChecking(toolUseID)
let classifierResult
try {
classifierResult = await classifyYoloAction(
context.messages, // ★ 完整对话记录
action,
context.options.tools,
appState.toolPermissionContext,
context.abortController.signal)
} finally {
clearClassifierChecking(toolUseID)
}
...
}
claude-code/src/utils/permissions/permissions.ts
它把权限模式临时伪装成 acceptEdits,再问工具一次。逻辑是:如果连宽松模式都会放行这个操作,说明它确实无害,没必要付费问分类器。
但两个工具被明确排除:
「Skip for Agent and REPL — their checkPermissions returns 'allow' for acceptEdits mode, which would silently bypass the classifier. REPL code can contain VM escapes between inner tool calls; the classifier must see the glue JavaScript, not just the inner tool calls.」
译:跳过 Agent 和 REPL 工具 —— 它们的权限检查在 acceptEdits 模式下返回「允许」,会静默绕过分类器。REPL 工具的代码可能在内部工具调用之间夹带虚拟机逃逸;分类器必须看到那些胶水 JavaScript 代码,而不只是内部的工具调用。
这是「组合安全 ≠ 单元安全」的绝佳例子。A 安全、B 安全,不代表「先 A 再 B,中间夹一段自定义逻辑」也安全。
注意 classifyYoloAction(context.messages, ...) 的第一个参数:整个对话记录,不只是这一条命令。
这很关键。一条 rm -rf build/ 命令,在「用户要求清理构建产物」的语境下是安全的,脱离语境就无法判断。但这也带来一个问题:完整对话记录可能有几万 token,塞进分类器会爆。
所以工具接口里有一个专门为此服务的方法:
/**
* Returns a compact representation of this tool use for the auto-mode
* security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content`
* for Edit. Return '' to skip this tool in the classifier transcript
* (e.g. tools with no security relevance). May return an object to avoid
* double-encoding when the caller JSON-wraps the value.
*/
toAutoClassifierInput(input: z.infer<Input>): unknown
每个工具自己提供只保留安全语义的压缩表示:Bash 给命令行文本,Edit 给「路径 + 新内容」,没有安全含义的工具返回空串直接不进视野。
// 任何一次成功放行都重置连续拒绝计数
if (result.behavior === 'allow') {
const currentDenialState = context.localDenialTracking ?? appState.denialTracking
if (appState.toolPermissionContext.mode === 'auto' &&
currentDenialState && currentDenialState.consecutiveDenials > 0) {
const newDenialState = recordSuccess(currentDenialState)
persistDenialState(context, newDenialState)
}
return result
}
连续被拒达到阈值时,系统不再信任分类器,回退到人工确认。防的是这种僵局:分类器因某种误判一直拒绝,模型不明白为什么,就一直换写法重试 —— 双方都在烧钱但永远推进不了。
注意 context.localDenialTracking ?? appState.denialTracking 这个回退:
「Local denial tracking state for async subagents whose setAppState is a no-op. Without this, the denial counter never accumulates and the fallback-to-prompting threshold is never reached.」
译:为异步子智能体准备的本地拒绝追踪状态 —— 它们的全局状态写入函数是空操作。没有这个,拒绝计数永远不会累加,「回退到人工确认」的阈值也就永远达不到。
这是一个典型的「架构隔离带来的副作用」:为了让子智能体不污染主线程状态,它的 setAppState 被设成了空操作。但这也意味着任何依赖状态累加的机制在子智能体里都失效了。所以要给它一个本地的副本。
用户可以在配置文件里写权限规则。语法有两个层次:
| 写法 | 含义 |
|---|---|
Bash | 整工具级。匹配所有 Bash 调用 |
Bash(git:*) | 内容级。只匹配 git 开头的命令 |
Bash(npm publish:*) | 只匹配 npm publish 开头的命令 |
Edit(src/**) | 只匹配 src 目录下的文件编辑 |
mcp__server | MCP 服务级前缀。匹配该服务下的所有工具 |
三类规则:alwaysAllowRules(总是允许)、alwaysDenyRules(总是拒绝)、alwaysAskRules(总是询问)。
/**
* Filters out tools that are blanket-denied by the permission context.
* A tool is filtered out if there's a deny rule matching its name with no
* ruleContent (i.e., a blanket deny for that tool).
*
* Uses the same matcher as the runtime permission check (step 1a), so MCP
* server-prefix rules like `mcp__server` strip all tools from that server
* before the model sees them — not just at call time.
*/
export function filterToolsByDenyRules<T>(tools, permissionContext): T[] {
return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}
这是一个重要的区分:「整工具级拒绝」不是在调用时拦截,而是让这个工具根本不出现在模型的工具清单里。
两者的差别很大:
· 调用时拦截 → 模型会尝试调用、被拒、然后困惑地换个方式再试,浪费好几轮
· 不出现在清单里 → 模型压根不知道有这个能力,直接走别的路
而内容级规则(Bash(git:*))无法在清单层面过滤 —— 因为 Bash 工具本身要保留,只是某些参数要拦。所以它只能在调用时判定。
utils/permissions/ 里有一个文件叫 shadowedRuleDetection.ts(影子规则检测)。它解决的问题是:
utils/shell/readOnlyCommandValidation.ts,66.7 KB。它的职责是判断「这条 shell 命令是不是只读的」。如果是,就可以自动放行,不打扰用户。
这件事比看起来难得多,因为要处理:
ls | grep foo 是只读的,ls > out.txt 不是echo $(rm -rf /) 里面藏着写操作cd /tmp && ls 里有两条命令,都要判断ls 别名成了别的东西所以 utils/bash/ 目录下有一个完整的 shell 语法解析器:bashParser.ts(128 KB)+ ast.ts(109 KB)。它把 shell 命令解析成抽象语法树,然后在树上做分析,而不是用正则匹配字符串。
而且还有一个实验性的替代实现:编译期开关里能看到 TREE_SITTER_BASH 和 TREE_SITTER_BASH_SHADOW —— 后者的命名(shadow,影子)说明他们在用影子模式验证新解析器:两个解析器同时跑,结果不一致时记录下来,但仍然用旧的那个的结果。这样可以在零风险的前提下收集新实现的准确率数据。
在 macOS 上,Claude Code 使用系统自带的 sandbox-exec 机制(也叫 seatbelt)。它可以在进程启动时施加一份策略文件,限制这个进程能访问哪些路径、能不能联网。
权限判定链的第 1b 步有一个特殊分支就和沙箱有关:
// 1b. Check if the entire tool should always ask for permission
const askRule = getAskRuleForTool(...)
if (askRule) {
// 当"沙箱内自动放行"开启时,能被沙箱化的命令跳过询问规则,
// 通过 Bash 的 checkPermissions 自动放行。
// 那些不会被沙箱化的命令(排除列表里的、显式禁用沙箱的)仍然遵守询问规则。
if (!canSandboxAutoAllow) {
return { behavior:'ask', ... }
}
// 否则继续往下,让 Bash 的 checkPermissions 处理具体命令的规则
}
逻辑是:如果这条命令会在沙箱里跑,那么即使它「看起来危险」也没关系 —— 沙箱会兜住。所以可以跳过询问。这是「用更强的隔离手段换取更少的打扰」。
export type ToolPermissionContext = DeepImmutable<{
mode: PermissionMode
additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
alwaysAllowRules: ToolPermissionRulesBySource
alwaysDenyRules: ToolPermissionRulesBySource
alwaysAskRules: ToolPermissionRulesBySource
isBypassPermissionsModeAvailable: boolean
isAutoModeAvailable?: boolean
strippedDangerousRules?: ToolPermissionRulesBySource // ★ 被剥离的危险规则
shouldAvoidPermissionPrompts?: boolean // 后台任务:弹不出框
awaitAutomatedChecksBeforeDialog?: boolean
prePlanMode?: PermissionMode // 进计划模式前的模式,用于恢复
}>
两个字段值得注意:
strippedDangerousRules:被系统主动剥离的规则用户配置里可能有一些「过于宽泛以至于危险」的规则。系统会在加载时把它们剥离掉,并把剥离的内容记录下来(这样界面上可以提示用户「你的这条规则被忽略了,因为它太宽泛」)。
源码里能看到具体的剥离逻辑,比如:
isOverlyBroadPowerShellAllowRule —— 剥离 PowerShell(*) 这种放行一切的规则isDangerousPowerShellPermission —— 剥离 iex(下载执行)、Start-Process 等前缀的放行规则DeepImmutable:类型层面的不可变这个包装类型让整个权限上下文在类型系统层面完全只读 —— 任何试图修改它的代码都通不过编译。权限状态的修改必须走专门的 applyPermissionUpdates() 函数,从而保证所有修改都经过统一的校验和持久化路径。
每一个权限决策都带一个 decisionReason(决策原因)字段:
{ type: 'rule', rule: {...} } // 命中了某条规则
{ type: 'mode', mode: 'auto' } // 因为当前模式
{ type: 'hook', hookName: 'PermissionRequest', reason: ... } // 钩子决定的
{ type: 'safetyCheck', classifierApprovable: false } // 安全检查
{ type: 'asyncAgent', reason: '...' } // 后台任务无法交互
而且有一个专门的模块 permissionExplainer.ts 负责把这些原因翻译成人话展示给用户。
可解释性对权限系统是刚需,不是锦上添花。
当用户看到「这个操作被拒绝了」而不知道为什么时,他的第一反应是把整个权限系统关掉。而如果他看到「因为你在 ~/.claude/settings.json 第 12 行配了 deny: Bash(rm:*)」,他就知道该改哪里。
一个无法解释自己决策的安全系统,最终会被用户绕过。