Claude Code 源码解析(05)Permission / Sandbox:工具权限、用户批准与命令隔离
这篇源码解析基于本地 Claude Code 源码,拆解 Permission 与 Sandbox 如何分工:权限层在每次 tool call 前用规则、模式、工具自检、hook 和 classifier 形成 allow/ask/deny 决策;sandbox 层只在命令执行时把网络和文件系统限制落到运行环境。重点说明 Bash、文件工具、WebFetch、MCP 的不同边界,以及 bypass、auto-allow、session rule、settings rule 的真实含义。
- 聚焦
- Permission / Sandbox 安全机制:tool call 如何触发权限检查,mode、allow/deny/ask rule、working directory、hook/classifier 如何参与决策,用户批准如何回写,Bash sandbox 如何把 settings 与 permission rules 转成执行时文件和网络隔离,以及 MCP、WebFetch、文件工具的边界。
- 源码
- assets/projects/claude-code
- 版本
- 4b9d30f
先给结论
Claude Code 的安全链路不是“执行工具前问用户一下”。它分成两层:Permission 是产品层决策协议,负责把一次 tool call 判成 allow、ask 或 deny;Sandbox 是执行层隔离,只在合适的 Bash 命令路径上把进程包进受限 runtime。两者互相配合,但不能互相替代。
默认路径
每个 tool use 都走统一 permission pipeline:schema 校验、PreToolUse hook、工具自检、规则/模式合并,最终得到 allow/ask/deny。
可选路径
bypassPermissions、acceptEdits、auto classifier、PermissionRequest hook、SDK prompt、sandbox.autoAllowBashIfSandboxed 都会改变决策来源,但不等于取消 deny 和 safety check。
源码证据地图
这条线横跨工具执行、权限规则、Bash 命令语义、文件路径安全、sandbox adapter 和权限回写。MCP、WebFetch、文件工具会共享统一 permission protocol,但它们不会自动继承 Bash sandbox。
1# 1 assets/projects/claude-code/src/Tool.ts2# 2 assets/projects/claude-code/src/services/tools/toolExecution.ts3# 3 assets/projects/claude-code/src/utils/permissions/permissions.ts4# 4 assets/projects/claude-code/src/utils/permissions/PermissionMode.ts5# 5 assets/projects/claude-code/src/utils/permissions/PermissionUpdate.ts6# 6 assets/projects/claude-code/src/tools/BashTool/BashTool.tsx7# 7 assets/projects/claude-code/src/tools/BashTool/bashPermissions.ts8# 8 assets/projects/claude-code/src/tools/BashTool/shouldUseSandbox.ts9# 9 assets/projects/claude-code/src/utils/sandbox/sandbox-adapter.ts10# 10 assets/projects/claude-code/src/utils/permissions/filesystem.ts11# 11 assets/projects/claude-code/src/tools/FileWriteTool/FileWriteTool.ts12# 12 assets/projects/claude-code/src/tools/MCPTool/MCPTool.tsTool Use 先进入统一执行器
读者最容易误解的是把权限逻辑看成某个工具的私有判断。实际入口在 toolExecution.ts:模型产出 tool use 后,统一执行器先找到 tool、解析 input,再进入 streamedCheckPermissionsAndCallTool()。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2for await (const update of streamedCheckPermissionsAndCallTool(3 tool,4 toolUse.id,5 toolInput,6 toolUseContext,7 canUseTool,8 assistantMessage,9 messageId,10 requestId,11 mcpServerType,12 mcpServerBaseUrl,13)) {14 yield update15}真正执行前还有 PreToolUse hook。hook 可以改 input、阻止继续,或者直接返回 permission result。随后 resolveHookPermissionDecision() 把 hook 结果与 canUseTool 合并成最终决策。
1// assets/projects/claude-code/src/services/tools/toolExecution.ts2const resolved = await resolveHookPermissionDecision(3 hookPermissionResult,4 tool,5 processedInput,6 toolUseContext,7 canUseTool,8 assistantMessage,9 toolUseID,10)11const permissionDecision = resolved.decision统一执行器接收模型生成的 tool use。
输入不合法时不进入权限判断。
hook 可以修改输入、阻止执行或给出权限结果。
hasPermissionsToUseTool 合并规则、模式和工具自检。
只有 allow 才执行真实工具逻辑。
拒绝或询问会变成模型可见的 tool_result。
Permission Context 是状态容器
ToolPermissionContext 是权限系统的运行态状态。它不只存当前 mode,还存 additional working directories、always allow/deny/ask rules、bypass 是否可用、异步 agent 是否应避免弹窗等。
1// assets/projects/claude-code/src/Tool.ts2export type ToolPermissionContext = DeepImmutable<{3 mode: PermissionMode4 additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>5 alwaysAllowRules: ToolPermissionRulesBySource6 alwaysDenyRules: ToolPermissionRulesBySource7 alwaysAskRules: ToolPermissionRulesBySource8 isBypassPermissionsModeAvailable: boolean9 shouldAvoidPermissionPrompts?: boolean10 awaitAutomatedChecksBeforeDialog?: boolean11 prePlanMode?: PermissionMode12}>PermissionMode.ts 把 mode 定义成产品策略:default、plan、acceptEdits、bypassPermissions、dontAsk,以及 feature-gated 的 auto。所以 mode 不是一个“是否询问”的布尔值,而是后续规则合并时的重要输入。
1// assets/projects/claude-code/src/utils/permissions/PermissionMode.ts2const PERMISSION_MODE_CONFIG = {3 default: { title: 'Default', external: 'default' },4 plan: { title: 'Plan Mode', external: 'plan' },5 acceptEdits: { title: 'Accept edits', external: 'acceptEdits' },6 bypassPermissions: { title: 'Bypass Permissions', external: 'bypassPermissions' },7 dontAsk: { title: "Don't Ask", external: 'dontAsk' },8}规则描述对象
allow/deny/ask rule 描述工具和内容,例如 Bash(prefix:git status)、Edit(path)、WebFetch(domain:example.com)、mcp__server__tool。
模式描述策略
acceptEdits 倾向放行工作区编辑;dontAsk 把 ask 变成 deny;bypassPermissions 放行大多数普通 ask,但仍不能越过强 deny 和部分 safety check。
主权限闸口的顺序
hasPermissionsToUseToolInner() 的关键不是返回类型,而是顺序。它先看整工具 deny,再看 ask,再调用工具自己的 checkPermissions()。工具自检之后,还要把 deny、用户交互要求、内容级 ask、safety check 放在 bypass/allow 前面。
1// assets/projects/claude-code/src/utils/permissions/permissions.ts2const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)3if (denyRule) return { behavior: 'deny', decisionReason: { type: 'rule', rule: denyRule } }4 5const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)6if (askRule) {7 const canSandboxAutoAllow =8 tool.name === BASH_TOOL_NAME &&9 SandboxManager.isSandboxingEnabled() &&10 SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&11 shouldUseSandbox(input)12 if (!canSandboxAutoAllow) return { behavior: 'ask', decisionReason: { type: 'rule', rule: askRule } }13}14 15toolPermissionResult = await tool.checkPermissions(parsedInput, context)这段解释了 autoAllowBashIfSandboxed 的真实含义:它只是在 Bash、sandbox enabled、auto allow enabled、且命令确实会进 sandbox 时跳过 ask rule。它不是全局免询问,更不是把 deny rule 变成 allow。
1// assets/projects/claude-code/src/utils/permissions/permissions.ts2if (toolPermissionResult?.behavior === 'deny') return toolPermissionResult3if (tool.requiresUserInteraction?.() && toolPermissionResult?.behavior === 'ask') return toolPermissionResult4if (5 toolPermissionResult?.behavior === 'ask' &&6 toolPermissionResult.decisionReason?.type === 'safetyCheck'7) {8 return toolPermissionResult9}10 11const shouldBypassPermissions =12 appState.toolPermissionContext.mode === 'bypassPermissions' ||13 (appState.toolPermissionContext.mode === 'plan' &&14 appState.toolPermissionContext.isBypassPermissionsModeAvailable)15if (shouldBypassPermissions) return { behavior: 'allow', decisionReason: { type: 'mode', mode: appState.toolPermissionContext.mode } }一个具体场景:用户开了 bypassPermissions,模型要编辑 .claude/settings.json。工具自检会返回 safety check;这类结果在 bypass 之前被拦住,所以便利模式不会自动越过敏感路径。
Bash 工具要分析命令语义
Bash 是最复杂的工具级判断,因为命令字符串可以包含环境变量、重定向、管道、compound command 和子 shell。BashTool.checkPermissions() 把判断交给 bashToolHasPermission()。
1// assets/projects/claude-code/src/tools/BashTool/BashTool.tsx2async checkPermissions(input, context): Promise<PermissionResult> {3 return bashToolHasPermission(input, context)4}Bash 规则匹配有一个重要取舍:deny/ask 要更难绕过,allow 要更保守。源码对 deny/ask 做更 aggressive 的 env var stripping,避免 FOO=bar rm ... 这类包装绕开规则;allow 则不会随便扩大匹配范围。
1// assets/projects/claude-code/src/tools/BashTool/bashPermissions.ts2const matchingDenyRules = filterRulesByContentsMatchingInput(3 input,4 denyRuleByContents,5 matchMode,6 { stripAllEnvVars: true, skipCompoundCheck: true },7)8 9const matchingAllowRules = filterRulesByContentsMatchingInput(10 input,11 allowRuleByContents,12 matchMode,13 { skipCompoundCheck },14)compound command 还会被拆开看。checkSandboxAutoAllow() 会逐个 subcommand 先查 deny/ask,避免 echo ok && rm -rf / 只因为整串命令不以 rm 开头而被放过。
1// assets/projects/claude-code/src/tools/BashTool/bashPermissions.ts2const subcommands = splitCommand(command)3if (subcommands.length > 1) {4 for (const sub of subcommands) {5 const subResult = matchingRulesForInput(6 { command: sub },7 toolPermissionContext,8 'prefix',9 )10 if (subResult.matchingDenyRules[0] !== undefined) {11 return { behavior: 'deny', decisionReason: { type: 'rule', rule: subResult.matchingDenyRules[0] } }12 }13 }14}Sandbox 是 Bash 的执行层
Sandbox 不在 permission 之前运行,也不包住整个 Claude Code 进程。shouldUseSandbox() 先判断 sandbox 是否启用、命令是否显式关闭 sandbox、是否有 command、是否命中 excluded command。
1// assets/projects/claude-code/src/tools/BashTool/shouldUseSandbox.ts2export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {3 if (!SandboxManager.isSandboxingEnabled()) return false4 if (5 input.dangerouslyDisableSandbox &&6 SandboxManager.areUnsandboxedCommandsAllowed()7 ) {8 return false9 }10 if (!input.command) return false11 if (containsExcludedCommand(input.command)) return false12 return true13}源码注释把 excluded commands 定义成用户便利功能,不是安全边界。也就是说,命令不进 sandbox 时,仍然要靠 permission prompt、规则和工具自检来保护。
Sandbox 能限制
被 sandbox 包住的 shell 进程能访问哪些文件、网络域名、unix socket、本地绑定等执行资源。
Sandbox 不能替代
它不替 MCP server 做安全承诺,不保护 WebFetch 工具自身,也不是整个 CLI 的虚拟机边界。
Sandbox Config 从权限规则翻译而来
sandbox-adapter.ts 把 Claude Code settings 和 permission rules 翻译成 @anthropic-ai/sandbox-runtime 配置。这里的关键是:同一份 permission rules 会同时影响产品层审批和执行层限制。
1// assets/projects/claude-code/src/utils/sandbox/sandbox-adapter.ts2export function convertToSandboxRuntimeConfig(3 settings: SettingsJson,4): SandboxRuntimeConfig {5 const permissions = settings.permissions || {}6 const allowedDomains: string[] = []7 const deniedDomains: string[] = []8 const allowWrite: string[] = ['.', getClaudeTempDir()]9 const denyWrite: string[] = []10 const denyRead: string[] = []11 const allowRead: string[] = []WebFetch 的 domain: rule 会变成 network allow/deny;Edit/Read 的路径规则会变成 filesystem allow/deny。adapter 还会硬挡 settings、managed settings drop-in、.claude/skills 等敏感路径。
1// assets/projects/claude-code/src/utils/sandbox/sandbox-adapter.ts2for (const ruleString of permissions.allow || []) {3 const rule = permissionRuleValueFromString(ruleString)4 if (rule.toolName === WEB_FETCH_TOOL_NAME && rule.ruleContent?.startsWith('domain:')) {5 allowedDomains.push(rule.ruleContent.substring('domain:'.length))6 }7}8 9denyWrite.push(...settingsPaths)10denyWrite.push(getManagedSettingsDropInDir())11denyWrite.push(resolve(originalCwd, '.claude', 'skills'))这条链路的例子是:用户允许 WebFetch(domain:docs.example.com),sandbox runtime 可以把这个 domain 放进 allowedDomains;用户 deny 某个读写路径,adapter 会把它转成 denyRead/denyWrite。这样执行环境和产品权限不会完全脱节。
文件工具关注路径和读写竞态
文件工具没有 Bash 的命令解析问题,但它们有路径和竞态风险。checkWritePermissionForTool() 会先查 deny,再查路径安全,再考虑 allow/ask。
1// assets/projects/claude-code/src/utils/permissions/filesystem.ts2export function checkWritePermissionForTool<Input extends AnyObject>(3 tool: Tool<Input>,4 input: z.infer<Input>,5 toolPermissionContext: ToolPermissionContext,6): PermissionDecision {7 const path = tool.getPath(input)8 const pathsToCheck = getPathsForPermissionCheck(path)9 for (const pathToCheck of pathsToCheck) {10 const denyRule = matchingRuleForInput(pathToCheck, toolPermissionContext, 'edit', 'deny')11 if (denyRule) return { behavior: 'deny', decisionReason: { type: 'rule', rule: denyRule } }12 }敏感路径 safety check 在 allow rule 之前。普通 allow 不能随便越过 .git、shell profile、settings 等高风险路径。
1// assets/projects/claude-code/src/utils/permissions/filesystem.ts2const safetyCheck = checkPathSafetyForAutoEdit(path, pathsToCheck)3if (!safetyCheck.safe) {4 return {5 behavior: 'ask',6 message: safetyCheck.message,7 decisionReason: {8 type: 'safetyCheck',9 reason: safetyCheck.message,10 classifierApprovable: safetyCheck.classifierApprovable,11 },12 }13}FileWriteTool 还有“写前必须读过”的状态检查。它从 readFileState 取上次读取时间,如果文件没读过、只读了 partial view,或读取后被用户/linter 改过,就拒绝写入。
1// assets/projects/claude-code/src/tools/FileWriteTool/FileWriteTool.ts2const readTimestamp = toolUseContext.readFileState.get(fullFilePath)3if (!readTimestamp || readTimestamp.isPartialView) {4 return { result: false, message: 'File has not been read yet. Read it first before writing to it.' }5}6if (lastWriteTime > readTimestamp.timestamp) {7 return { result: false, message: 'File has been modified since read, either by the user or by a linter.' }8}用户批准会更新权限状态
当最终决策是 ask,用户、SDK prompt、PermissionRequest hook 或 bridge 可以给出批准/拒绝。批准不是只让当前 call 继续,还可能携带 PermissionUpdate:改 mode、加规则、加工作目录。
1// assets/projects/claude-code/src/utils/permissions/PermissionUpdate.ts2export function applyPermissionUpdate(3 context: ToolPermissionContext,4 update: PermissionUpdate,5): ToolPermissionContext {6 switch (update.type) {7 case 'setMode':8 return { ...context, mode: update.mode }9 case 'addRules':10 return {11 ...context,12 [ruleKind]: {13 ...context[ruleKind],14 [update.destination]: [15 ...(context[ruleKind][update.destination] || []),16 ...ruleStrings,17 ],18 },19 }这里的状态流很重要:session rule 只影响当前会话;local/user/project settings rule 才会持久化。这样“一次允许”和“以后总是允许”不会混成同一种状态。
ask 是暂停点,不是工具失败。
前端、SDK 或 PermissionRequest hook 都能提供结果。
update 可以 setMode、addRules、addDirectories。
session 级更新立即影响后续 tool call。
local/user/project settings 才会写回配置文件。
MCP 和 WebFetch 的边界
MCP 工具默认返回 passthrough,让外层 permission pipeline 继续做 server/tool 级规则匹配。它不是天然进入 Bash sandbox 的外部程序。
1// assets/projects/claude-code/src/tools/MCPTool/MCPTool.ts2export const MCPTool = buildTool({3 isMcp: true,4 name: 'mcp',5 async checkPermissions(): Promise<PermissionResult> {6 return {7 behavior: 'passthrough',8 message: 'MCPTool requires permission.',9 }10 },11})WebFetch 也要分两层看:作为工具调用时,它走 WebFetch 自己的 permission rule;作为 sandbox runtime 的网络配置来源时,domain: rule 会被 adapter 翻译成 allowed/denied domain。一个是工具权限,一个是被 sandbox 包住的进程网络权限。
可复用设计
Claude Code 这套设计最值得复用的是职责分层。统一执行器只消费 PermissionDecision;具体风险判断放在最懂风险的工具附近;用户批准变成可审计的 PermissionUpdate;执行层 sandbox 再作为 Bash 的第二道边界。
所有工具最后都产出 allow/ask/deny、message、decisionReason 和可选 updatedInput,执行器不需要理解每个工具的内部风险。
一个工程原则很清楚:强拒绝先于便利放行。源码把 deny、requiresUserInteraction、safety check 放在 bypass/acceptEdits 之前,说明便利模式是减少打扰,不是最高权限。
边界与风险
Permission / Sandbox 不是完整 VM,不是 git 权限模型,也不是所有外部能力的单点拦截器。dangerouslyDisableSandbox、allowUnsandboxedCommands、MCP server、SDK bridge、hooks、auto classifier 都会带来额外分支。
能相信的部分
每次 tool call 会形成统一 PermissionDecision;deny 和 safetyCheck 在顺序上强于普通模式放行;Bash sandbox config 会从 settings 和 rules 翻译到 runtime。
不能误读的部分
sandbox 不包整个进程;excludedCommands 不是安全边界;WebFetch/MCP 不自动进入 shell sandbox;bypassPermissions 更接近少问权限,不是更强隔离。
迁移到自己的 agent 系统时,先复用四件事:工具执行前的统一决策协议、工具局部风险判断、临时/持久授权分离、命令执行的独立 sandbox。这样安全机制才不会变成一个越来越长的全局 if。
