claude-code · Agent 架构 · 2026-06-07

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 判成 allowaskdenySandbox 是执行层隔离,只在合适的 Bash 命令路径上把进程包进受限 runtime。两者互相配合,但不能互相替代。

Permission / Sandbox 双层架构:工具先过权限闸口,允许后才进入 tool.call;Bash 才可能继续被 sandbox adapter 包装。
Permission / Sandbox 双层架构:工具先过权限闸口,允许后才进入 tool.call;Bash 才可能继续被 sandbox adapter 包装。

默认路径

每个 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。

text
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.ts

Tool Use 先进入统一执行器

读者最容易误解的是把权限逻辑看成某个工具的私有判断。实际入口在 toolExecution.ts:模型产出 tool use 后,统一执行器先找到 tool、解析 input,再进入 streamedCheckPermissionsAndCallTool()

ts
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 合并成最终决策。

ts
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
1tool use 到达

统一执行器接收模型生成的 tool use。

2input schema 校验

输入不合法时不进入权限判断。

3PreToolUse hook

hook 可以修改输入、阻止执行或给出权限结果。

4canUseTool 权限判断

hasPermissionsToUseTool 合并规则、模式和工具自检。

5allow 进入 tool.call

只有 allow 才执行真实工具逻辑。

6ask/deny 返回 tool_result

拒绝或询问会变成模型可见的 tool_result。

Permission Context 是状态容器

ToolPermissionContext 是权限系统的运行态状态。它不只存当前 mode,还存 additional working directories、always allow/deny/ask rules、bypass 是否可用、异步 agent 是否应避免弹窗等。

ts
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 定义成产品策略:defaultplanacceptEditsbypassPermissionsdontAsk,以及 feature-gated 的 auto。所以 mode 不是一个“是否询问”的布尔值,而是后续规则合并时的重要输入。

ts
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 前面。

ts
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。

ts
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 } }
权限判定顺序:越靠前越强。deny、内容 ask、safety check 先于 mode 放行。
权限判定顺序:越靠前越强。deny、内容 ask、safety check 先于 mode 放行。

一个具体场景:用户开了 bypassPermissions,模型要编辑 .claude/settings.json。工具自检会返回 safety check;这类结果在 bypass 之前被拦住,所以便利模式不会自动越过敏感路径。

Bash 工具要分析命令语义

Bash 是最复杂的工具级判断,因为命令字符串可以包含环境变量、重定向、管道、compound command 和子 shell。BashTool.checkPermissions() 把判断交给 bashToolHasPermission()

ts
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 则不会随便扩大匹配范围。

ts
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 开头而被放过。

ts
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。

ts
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 会同时影响产品层审批和执行层限制。

ts
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 等敏感路径。

ts
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'))
Sandbox 配置映射:permission rules 与 sandbox.filesystem/network settings 会共同生成 runtime 限制。
Sandbox 配置映射:permission rules 与 sandbox.filesystem/network settings 会共同生成 runtime 限制。

这条链路的例子是:用户允许 WebFetch(domain:docs.example.com),sandbox runtime 可以把这个 domain 放进 allowedDomains;用户 deny 某个读写路径,adapter 会把它转成 denyRead/denyWrite。这样执行环境和产品权限不会完全脱节。

文件工具关注路径和读写竞态

文件工具没有 Bash 的命令解析问题,但它们有路径和竞态风险。checkWritePermissionForTool() 会先查 deny,再查路径安全,再考虑 allow/ask。

ts
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 等高风险路径。

ts
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 改过,就拒绝写入。

ts
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}
不同工具的局部安全逻辑不同:Bash 看命令语义,文件工具看路径和读写前置条件,MCP 主要回到 server/tool rule。
不同工具的局部安全逻辑不同:Bash 看命令语义,文件工具看路径和读写前置条件,MCP 主要回到 server/tool rule。

用户批准会更新权限状态

当最终决策是 ask,用户、SDK prompt、PermissionRequest hook 或 bridge 可以给出批准/拒绝。批准不是只让当前 call 继续,还可能携带 PermissionUpdate:改 mode、加规则、加工作目录。

ts
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 才会持久化。这样“一次允许”和“以后总是允许”不会混成同一种状态。

1权限结果为 ask

ask 是暂停点,不是工具失败。

2用户或 hook 决策

前端、SDK 或 PermissionRequest hook 都能提供结果。

3生成 PermissionUpdate

update 可以 setMode、addRules、addDirectories。

4更新 ToolPermissionContext

session 级更新立即影响后续 tool call。

5按 destination 持久化

local/user/project settings 才会写回配置文件。

MCP 和 WebFetch 的边界

MCP 工具默认返回 passthrough,让外层 permission pipeline 继续做 server/tool 级规则匹配。它不是天然进入 Bash sandbox 的外部程序。

ts
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 权限模型,也不是所有外部能力的单点拦截器。dangerouslyDisableSandboxallowUnsandboxedCommands、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。