Claude Code 源码解剖

Claude Code 的安全防线

从分类器输入隔离、路径和权限安全检查、Hooks 信任门控、退出码协议、沙箱适配器和 Bare Git Repo 防护,拆解 Claude Code 如何把文本风险收束到工具执行边界。

第 14 章 17 分钟

Claude Code 的安全防线

第 12 章和第 13 章分别讲了权限系统和自动权限分类器。这一章把它们放回完整安全链路里看:Claude Code 如何把外部文本、模型输出、工具调用、用户自定义 Hook 和真实 shell 执行,逐层收束到可控边界内?

源码里的安全不是一个“大开关”。它更像多段不同性质的拦截:

  • 分类器不信任主模型自由文本,只看投影后的工具调用转录。
  • 权限管线在副作用前算出 allow / deny / ask
  • 路径验证拒绝验证时和执行时语义不一致的输入。
  • Hooks 可以扩展组织规则,但必须先过信任门控和超时边界。
  • 沙箱最终用操作系统隔离限制文件系统、网络和系统调用。

核心直觉

Claude Code 的安全目标不是让模型“永远不看见坏文本”,而是防止坏文本一路变成真实副作用。每一层都只负责切断其中一段传播路径。

先看源码入口

源码定位关键符号负责什么
yoloClassifier.tsbuildTranscriptEntries()分类器只接收投影后的 user 文本和 tool_use,不接收助手自由文本
yoloClassifier.tstoAutoClassifierInput()toCompactBlock()控制工具输入字段和紧凑转录格式
permissions.tshasPermissionsToUseTool()权限系统主裁决入口
pathValidation.tsvalidatePath()isPathAllowed()路径清理、UNC 防护、TOCTOU 防护、危险路径检查
filesystem.tsDANGEROUS_FILESDANGEROUS_DIRECTORIES标记 .git.claude、shell rc 等危险文件和目录
hooks.tsexecuteHooks()shouldSkipHookDueToTrust()Hook 执行、信任门控、超时和退出码协议
types/hooks.tssyncHookResponseSchemaPermissionRequest 输出 schemaHook 结构化输出和权限更新
hooksConfigSnapshot.tscaptureHooksConfigSnapshot()updateHooksConfigSnapshot()Hook 配置快照和来源过滤
sandbox-adapter.tsSandboxManagerconvertToSandboxRuntimeConfig()把 Claude Code 配置转换成 sandbox-runtime 配置
shouldUseSandbox.tsshouldUseSandbox()containsExcludedCommand()Bash 命令是否进入沙箱
Shell.tsSandboxManager.wrapWithSandbox()执行前包装命令
sandbox-adapter.tsscrubBareGitRepoFiles()防御 Bare Git Repo 沙箱逃逸

这几组入口串起来,才是完整的安全通道。

总流程图

flowchart TD A["外部输入<br/>文件 / 网页 / 工具结果 / 用户提示"] --> B["模型生成工具调用"] B --> C["权限管线<br/>hasPermissionsToUseTool()"] C --> D{"allow / deny / ask"} D -->|deny| X["停止执行"] D -->|ask + auto| E["classifyYoloAction()"] E --> F["buildTranscriptEntries()<br/>过滤助手文本,只保留 tool_use"] F --> G{"shouldBlock?"} G -->|true 或失败| H["人工确认或阻止"] G -->|false| I["PreToolUse / PermissionRequest Hooks"] D -->|allow| I I --> J{"Hook 信任 + 退出码 / JSON 决策"} J -->|block / deny| X J -->|allow| K["Bash / 文件工具执行"] K --> L{"shouldUseSandbox()?"} L -->|否| M["直接执行"] L -->|是| N["SandboxManager.wrapWithSandbox()"] N --> O["Seatbelt / Bubblewrap / seccomp"] O --> P["cleanupAfterCommand()<br/>scrubBareGitRepoFiles()"]

这条链路有一个关键特征:越靠后越接近真实副作用,边界也越硬。前面是文本和决策隔离,后面是进程和文件系统隔离。

分类器输入隔离:不让主模型给自己开证明

自动权限分类器最重要的一层安全不是模型提示词,而是输入投影。buildTranscriptEntries() 不把主会话原文直接交给分类器。

原文里的源码注释非常直白:

// Only include tool_use blocks; assistant text is model-authored
// and could be crafted to influence the classifier's decision.

这段代码证明:分类器不信任 assistant 自由文本。原因是主模型可能被 prompt injection 诱导,自己生成“用户已经授权这次操作”之类的话。如果分类器把这段话当依据,就会变成 AI 给 AI 开安全证明。

分类器只保留:

来源保留内容安全含义
用户消息文本块用户意图仍然需要看
助手消息tool_use block只看结构化动作,不看自由解释
附件消息queued_command prompt 等必要字段保留排队命令上下文
工具输入toAutoClassifierInput() 投影每个工具只暴露必要风险字段

toCompactBlock() 还支持 JSONL 格式:

{"user":"implement a fibonacci function"}
{"Bash":"ls -la"}
{"FileEditTool":{"file_path":"/src/fib.ts","new_string":"..."}}

JSONL 的安全收益是:用户文本里的换行会被 JSON 转义,不能伪造出另一行“看起来像工具调用”的内容。

容易误解

分类器不是“看完整上下文后更聪明地判断”。它刻意看更窄、更结构化的上下文,因为安全裁决最怕被模型自由文本污染。

权限和路径检查:把动作挡在副作用之前

权限系统的主入口是 hasPermissionsToUseTool()。第 12 章已经拆过三阶段管线,这里只强调它在安全链路里的位置:它发生在工具真正执行之前。

路径验证是这层里最有安全味的一部分。validatePath() 不是努力兼容所有 shell 语法,而是拒绝那些验证时和执行时语义可能不同的路径。

flowchart TD A["路径输入"] --> B["清理引号,展开普通 ~"] B --> C{"UNC 路径?"} C -->|是| X["拒绝,防 NTLM 泄漏"] C -->|否| D{"~root / ~+ / ~-?"} D -->|是| X D -->|否| E{"$VAR / %VAR% / =cmd?"} E -->|是| X E -->|否| F{"写操作 + glob?"} F -->|是| X F -->|否| G["解析绝对路径和符号链接"] G --> H["isPathAllowed()"]

UNC 检测的源码片段:

const backslashUncPattern = /\\\\[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i
const forwardSlashUncPattern = /(?<!:)\/\/[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i

这段代码证明:Windows 上 \\server\share//server/share 都要被拦,因为访问 UNC 路径可能让系统自动发送 NTLM 凭据。第二个正则用 (?&lt;!:) 排除 https:// 这类合法 URL。

TOCTOU 防护的策略也很保守:

输入为什么危险
~root/...shell 执行时可能展开到 root 目录
$HOME/.ssh/id_rsa验证时是字面文本,执行时变成敏感路径
%USERPROFILE%Windows 环境变量展开
=rgZsh equals 展开为命令路径
写操作 glob验证对象和执行对象可能不是同一批文件

这类输入不是被“解析得更聪明”,而是被拒绝。这就是 fail-closed。

危险文件和目录:项目内也可能是执行入口

源码把危险文件和目录列成常量:

export const DANGEROUS_FILES = [
  '.gitconfig', '.gitmodules',
  '.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile',
  '.ripgreprc', '.mcp.json', '.claude.json',
] as const

export const DANGEROUS_DIRECTORIES = [
  '.git', '.vscode', '.idea', '.claude',
] as const

这段代码证明,Claude Code 不把“在项目内”简单等同于安全。.gitconfig 可以配置命令执行,shell rc 文件会自动加载,.mcp.json 会影响外部工具连接,.claude 里可能有权限和 Hook 配置。

这些路径的写操作会进入 safetyCheck,具有 bypass 免疫性。也就是说,即使用户打开 bypassPermissions,这类安全检查仍能要求确认。

用户扩展点:Hooks 本身也要被约束

Hooks 很强,因为它们能在工具执行前后运行命令、LLM prompt、HTTP 请求或 Agent 验证器。但强也意味着危险:Hook 本质上是用户配置的自动执行逻辑。

执行模型和超时

Hook 主入口是异步生成器:

async function* executeHooks({
  hookInput,
  toolUseID,
  matchQuery,
  signal,
  timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS,
  toolUseContext,
  messages,
  forceSyncExecution,
  requestPrompt,
  toolInputSummary,
}: { /* ... */ }): AsyncGenerator<AggregatedHookResult> {

这段代码证明 Hook 执行是流式的,调用者可以 for await...of 接收进度和结果。Hook 不是隐藏在后台的黑盒。

默认工具 Hook 超时是 10 分钟:

const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000

SessionEnd 只有 1.5 秒:

const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500

这说明不同生命周期事件有不同阻塞预算。构建、测试类 Hook 可以等,退出类 Hook 不能让用户按了 Ctrl+C 还等十分钟。

退出码是协议

Hook 的安全语义主要通过退出码表达:

退出码一般含义典型影响
0成功或允许当前流程继续
2阻塞错误PreToolUse 阻止工具,Stop 让对话继续
其他非阻塞错误显示给用户,操作通常继续

结构化 JSON 输出还能表达更细的控制。

export const syncHookResponseSchema = lazySchema(() =>
  z.object({
    continue: z.boolean().optional(),
    suppressOutput: z.boolean().optional(),
    stopReason: z.string().optional(),
    decision: z.enum(['approve', 'block']).optional(),
    reason: z.string().optional(),
    systemMessage: z.string().optional(),
    hookSpecificOutput: z.union([/* 按事件类型的专有输出 */]).optional(),
  }),
)

PermissionRequest 事件甚至能返回 allow / deny 决策和权限更新:

z.object({
  hookEventName: z.literal('PermissionRequest'),
  decision: z.union([
    z.object({
      behavior: z.literal('allow'),
      updatedInput: z.record(z.string(), z.unknown()).optional(),
      updatedPermissions: z.array(permissionUpdateSchema()).optional(),
    }),
    z.object({
      behavior: z.literal('deny'),
      message: z.string().optional(),
      interrupt: z.boolean().optional(),
    }),
  ]),
})

这段代码证明 Hook 不只是旁路通知,它可以参与权限决策。但正因为如此,它必须有信任门控。

信任门控:Hook 不能早于工作区信任

shouldSkipHookDueToTrust() 是 Hook 安全边界。

export function shouldSkipHookDueToTrust(): boolean {
  const isInteractive = !getIsNonInteractiveSession()
  if (!isInteractive) {
    return false
  }
  const hasTrust = checkHasTrustDialogAccepted()
  return !hasTrust
}

这段代码证明:交互模式下,所有 Hook 都需要用户接受 workspace trust。非交互 SDK 模式信任是隐含的。

executeHooks() 执行前还会集中检查:

if (shouldSkipHookDueToTrust()) {
  logForDebugging(
    `Skipping ${hookName} hook execution - workspace trust not accepted`,
  )
  return
}

原文解释过为什么要检查所有 Hook:历史上出现过 SessionEndSubagentStop 在信任确认前执行的漏洞。统一检查是纵深防御,而不是只保护“看起来会早执行”的事件。

配置快照:Hook 不能每次实时漂移

Hook 配置不是每次执行实时读取,而是启动时捕获快照。

export function captureHooksConfigSnapshot(): void {
  initialHooksConfig = getHooksFromAllowedSources()
}

用户通过 /hooks 修改后,显式更新快照:

export function updateHooksConfigSnapshot(): void {
  resetSettingsCache()
  initialHooksConfig = getHooksFromAllowedSources()
}

这段代码证明:Hook 配置变化必须经过明确更新点。resetSettingsCache() 避免读到文件监视器尚未刷新的旧配置。

来源过滤也有安全策略:

策略行为
policySettings.disableAllHooks返回空配置,禁用所有 Hook
policySettings.allowManagedHooksOnly只允许 managed hooks
strictPluginOnlyCustomization阻止 user/project/local hooks
非 managed disableAllHooks只禁用非 managed,managed 仍可运行

这说明 Hook 是可扩展面,但企业策略能从来源合并阶段就阻断不可信 Hook。

沙箱:最后一道操作系统边界

如果前面所有应用层判断都放行,Bash 命令仍然要面对沙箱。Claude Code 的沙箱由两层组成:

  • @anthropic-ai/sandbox-runtime 处理平台底层隔离。
  • sandbox-adapter.ts 把 Claude Code 设置、权限规则、路径约定转换成 runtime 配置。

平台差异:

平台文件系统隔离网络隔离
macOSsandbox-exec / Seatbelt ProfileProfile 规则和 Unix socket 路径过滤
LinuxBubblewrap,只读根 + 可写 bind mountseccomp 系统调用过滤
WSL2同 Linux同 Linux

沙箱适配器

SandboxManager 暴露统一接口:

export interface ISandboxManager {
  initialize(sandboxAskCallback?: SandboxAskCallback): Promise<void>
  isSupportedPlatform(): boolean
  isSandboxingEnabled(): boolean
  isAutoAllowBashIfSandboxedEnabled(): boolean
  areUnsandboxedCommandsAllowed(): boolean
  isSandboxRequired(): boolean
  wrapWithSandbox(command: string, binShell?: string, ...): Promise<string>
  cleanupAfterCommand(): void
  refreshConfig(): void
  reset(): Promise<void>
}

这段代码证明:Claude Code 不让工具层直接操作 Seatbelt 或 Bubblewrap,而是通过统一管理器判断平台、配置、包装和清理。

初始化时会把设置转换成 runtime 配置,并订阅设置变化:

initializationPromise = (async () => {
  if (worktreeMainRepoPath === undefined) {
    worktreeMainRepoPath = await detectWorktreeMainRepoPath(getCwdState())
  }
  const settings = getSettings_DEPRECATED()
  const runtimeConfig = convertToSandboxRuntimeConfig(settings)
  await BaseSandboxManager.initialize(runtimeConfig, wrappedCallback)
  settingsSubscriptionCleanup = settingsChangeDetector.subscribe(() => {
    const newConfig = convertToSandboxRuntimeConfig(getSettings_DEPRECATED())
    BaseSandboxManager.updateConfig(newConfig)
  })
})()

这段代码证明沙箱配置不是静态文件读一次就结束。设置变化会更新底层 sandbox-runtime 配置,但初始化本身用 initializationPromise 防重复并发初始化。

命令执行:Bash 是否使用沙箱

shouldUseSandbox() 是 Bash 进入沙箱的裁决点。

export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
  if (!SandboxManager.isSandboxingEnabled()) {
    return false
  }
  if (
    input.dangerouslyDisableSandbox &&
    SandboxManager.areUnsandboxedCommandsAllowed()
  ) {
    return false
  }
  if (!input.command) {
    return false
  }
  if (containsExcludedCommand(input.command)) {
    return false
  }
  return true
}

这段代码证明:沙箱不是无条件包所有命令。它受启用状态、dangerouslyDisableSandbox、企业策略和 excluded commands 影响。

containsExcludedCommand() 会拆复合命令,防止 docker ps &amp;&amp; curl evil.com 因为第一个子命令在排除列表中而整体逃出沙箱。

// Split compound commands to prevent a compound command from
// escaping the sandbox just because its first subcommand matches
let subcommands: string[]
try {
  subcommands = splitCommand_DEPRECATED(command)
} catch {
  subcommands = [command]
}

命令真正执行前,在 Shell.ts 里包装:

if (shouldUseSandbox) {
  commandString = await SandboxManager.wrapWithSandbox(
    commandString,
    sandboxBinShell,
    undefined,
    abortSignal,
  )
  const fs = getFsImplementation()
  await fs.mkdir(sandboxTmpDir, { mode: 0o700 })
}

这段代码证明:沙箱是在 OS 命令执行前改写命令字符串,并创建权限为 0o700 的沙箱临时目录。

网络和文件系统配置

文件系统默认是只读根 + 可写白名单。配置转换里默认写入:

const allowWrite: string[] = ['.', getClaudeTempDir()]
const denyWrite: string[] = []

随后会把设置文件和技能目录加入 denyWrite

const settingsPaths = SETTING_SOURCES.map(source =>
  getSettingsFilePathForSource(source),
).filter((p): p is string => p !== undefined)
denyWrite.push(...settingsPaths)
denyWrite.push(getManagedSettingsDropInDir())
denyWrite.push(resolve(originalCwd, '.claude', 'skills'))

这段代码证明:沙箱不会允许命令修改 Claude Code 自己的权限和技能配置。技能文件和命令/agent 有同等特权级别,所以要保护。

网络域名会从 sandbox 配置和 WebFetch(domain:...) 权限规则中提取。

if (shouldAllowManagedSandboxDomainsOnly()) {
  const policySettings = getSettingsForSource('policySettings')
  for (const domain of policySettings?.sandbox?.network?.allowedDomains || []) {
    allowedDomains.push(domain)
  }
  for (const ruleString of policySettings?.permissions?.allow || []) {
    const rule = permissionRuleValueFromString(ruleString)
    if (
      rule.toolName === WEB_FETCH_TOOL_NAME &&
      rule.ruleContent?.startsWith('domain:')
    ) {
      allowedDomains.push(rule.ruleContent.substring('domain:'.length))
    }
  }
}

这段代码证明:企业策略可以启用 managed domains only,此时用户层和项目层的域名配置会被忽略,只信 policySettings。

真实防御案例:Bare Git Repo 沙箱逃逸

Issue #29316 描述了一个很有代表性的逃逸路径:攻击者在工作目录种下 HEADobjects/refs/config 等文件,让 Git 把当前目录当成 bare repo,再利用 config 里的 core.fsmonitor 让 Claude Code 后续非沙箱 Git 操作执行恶意脚本。

防御分两条线。

如果这些 Git 文件已经存在,加入 denyWrite

const bareGitRepoFiles = ['HEAD', 'objects', 'refs', 'hooks', 'config']
for (const dir of cwd === originalCwd ? [originalCwd] : [originalCwd, cwd]) {
  for (const gitFile of bareGitRepoFiles) {
    const p = resolve(dir, gitFile)
    try {
      statSync(p)
      denyWrite.push(p)
    } catch {
      bareGitRepoScrubPaths.push(p)
    }
  }
}

如果它们原本不存在,就记录到 bareGitRepoScrubPaths,命令后清理:

function scrubBareGitRepoFiles(): void {
  for (const p of bareGitRepoScrubPaths) {
    try {
      rmSync(p, { recursive: true })
      logForDebugging(`[Sandbox] scrubbed planted bare-repo file: ${p}`)
    } catch {
      // ENOENT is the expected common case
    }
  }
}

并接入命令清理:

cleanupAfterCommand: (): void => {
  BaseSandboxManager.cleanupAfterCommand()
  scrubBareGitRepoFiles()
}

这段代码证明沙箱防御不是只靠“禁止写 .git”。它还要处理攻击者在沙箱内新建 bare repo 诱饵文件的情况,并在每条命令后清理。

状态和数据结构

状态/结构所在位置影响
分类器转录条目buildTranscriptEntries()把主会话投影成安全裁决输入
DANGEROUS_FILES / DANGEROUS_DIRECTORIESfilesystem.ts标记写操作必须谨慎处理的路径
syncHookResponseSchematypes/hooks.tsHook 结构化输出和阻塞协议
Hook 配置快照hooksConfigSnapshot.ts避免实时配置抖动和信任绕过
SandboxSettingsSchemasandboxTypes.ts沙箱配置单一事实来源
ISandboxManagersandbox-adapter.ts应用层和 sandbox-runtime 的统一接口
allowWrite / denyWritesandbox runtime config文件系统可写和禁止写边界
allowedDomains / deniedDomainssandbox network config网络访问白名单和黑名单
bareGitRepoScrubPathssandbox-adapter.ts命令后清理可能被植入的 bare repo 文件

设计取舍

取舍源码证据工程判断
分类器过滤助手文本Only include tool_use blocks不让主模型自由文本影响安全裁决
路径展开选择拒绝validatePath()$%=、危险 tilde不模拟 shell,避免 TOCTOU
危险路径 bypass 免疫DANGEROUS_FILESDANGEROUS_DIRECTORIES项目内也可能存在持久化和代码执行入口
Hook 统一信任门控shouldSkipHookDueToTrust()防止任何 Hook 在 workspace trust 前执行
Hook 用退出码作协议退出码 2 阻塞让 shell 命令型扩展也能参与控制流
Hook 配置快照captureHooksConfigSnapshot()配置读取有明确边界,更新必须显式
沙箱适配器分层SandboxManager + BaseSandboxManager平台隔离由 runtime 做,业务语义由 adapter 做
复合命令拆分后判断排除splitCommand_DEPRECATED()防止一个被排除子命令带着其他命令逃出沙箱
企业域名硬策略allowManagedDomainsOnly高优先级策略能忽略用户层网络白名单
Bare repo 清理scrubBareGitRepoFiles()防御命令执行后的延迟逃逸路径

读源码抓手

  1. 先读 buildTranscriptEntries(),看分类器如何避免读取 assistant 自由文本。
  2. 再读 hasPermissionsToUseTool()pathValidation.ts,把动作许可和路径语义检查连起来。
  3. 接着读 filesystem.ts 里的危险文件和目录,理解为什么项目内配置文件也可能危险。
  4. 然后读 executeHooks()syncHookResponseSchemashouldSkipHookDueToTrust(),看 Hook 如何参与控制流又如何被约束。
  5. 再读 captureHooksConfigSnapshot()getHooksFromAllowedSources(),确认企业策略如何过滤 Hook 来源。
  6. 最后读 shouldUseSandbox()SandboxManager.wrapWithSandbox()convertToSandboxRuntimeConfig(),追 Bash 命令如何进入操作系统隔离。
  7. 用 Bare Git Repo 防护案例收尾,检查 denyWritebareGitRepoScrubPathscleanupAfterCommand() 三个点如何配合。

小结

小结

Claude Code 的安全防线是多层收口,不是单点阻断。

  • 分类器输入层先隔离主模型自由文本,避免 AI 自己影响自己的安全裁决。
  • 权限和路径验证层在工具副作用前决定 allow / deny / ask,并拒绝 TOCTOU 风险路径。
  • Hooks 给用户和组织扩展控制点,但必须经过信任门控、超时、退出码协议和配置来源过滤。
  • 沙箱是最后一道硬边界,把文件系统、网络和进程能力限制在 runtime 配置里。
  • Bare Git Repo 防护说明真正的安全不是只防当前命令,还要防命令执行后留下的逃逸诱饵。