Claude Code 的安全防线
第 12 章和第 13 章分别讲了权限系统和自动权限分类器。这一章把它们放回完整安全链路里看:Claude Code 如何把外部文本、模型输出、工具调用、用户自定义 Hook 和真实 shell 执行,逐层收束到可控边界内?
源码里的安全不是一个“大开关”。它更像多段不同性质的拦截:
- 分类器不信任主模型自由文本,只看投影后的工具调用转录。
- 权限管线在副作用前算出
allow / deny / ask。 - 路径验证拒绝验证时和执行时语义不一致的输入。
- Hooks 可以扩展组织规则,但必须先过信任门控和超时边界。
- 沙箱最终用操作系统隔离限制文件系统、网络和系统调用。
核心直觉
Claude Code 的安全目标不是让模型“永远不看见坏文本”,而是防止坏文本一路变成真实副作用。每一层都只负责切断其中一段传播路径。
先看源码入口
| 源码定位 | 关键符号 | 负责什么 |
|---|---|---|
yoloClassifier.ts | buildTranscriptEntries() | 分类器只接收投影后的 user 文本和 tool_use,不接收助手自由文本 |
yoloClassifier.ts | toAutoClassifierInput()、toCompactBlock() | 控制工具输入字段和紧凑转录格式 |
permissions.ts | hasPermissionsToUseTool() | 权限系统主裁决入口 |
pathValidation.ts | validatePath()、isPathAllowed() | 路径清理、UNC 防护、TOCTOU 防护、危险路径检查 |
filesystem.ts | DANGEROUS_FILES、DANGEROUS_DIRECTORIES | 标记 .git、.claude、shell rc 等危险文件和目录 |
hooks.ts | executeHooks()、shouldSkipHookDueToTrust() | Hook 执行、信任门控、超时和退出码协议 |
types/hooks.ts | syncHookResponseSchema、PermissionRequest 输出 schema | Hook 结构化输出和权限更新 |
hooksConfigSnapshot.ts | captureHooksConfigSnapshot()、updateHooksConfigSnapshot() | Hook 配置快照和来源过滤 |
sandbox-adapter.ts | SandboxManager、convertToSandboxRuntimeConfig() | 把 Claude Code 配置转换成 sandbox-runtime 配置 |
shouldUseSandbox.ts | shouldUseSandbox()、containsExcludedCommand() | Bash 命令是否进入沙箱 |
Shell.ts | SandboxManager.wrapWithSandbox() | 执行前包装命令 |
sandbox-adapter.ts | scrubBareGitRepoFiles() | 防御 Bare Git Repo 沙箱逃逸 |
这几组入口串起来,才是完整的安全通道。
总流程图
这条链路有一个关键特征:越靠后越接近真实副作用,边界也越硬。前面是文本和决策隔离,后面是进程和文件系统隔离。
分类器输入隔离:不让主模型给自己开证明
自动权限分类器最重要的一层安全不是模型提示词,而是输入投影。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 语法,而是拒绝那些验证时和执行时语义可能不同的路径。
UNC 检测的源码片段:
const backslashUncPattern = /\\\\[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i
const forwardSlashUncPattern = /(?<!:)\/\/[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i
这段代码证明:Windows 上 \\server\share 和 //server/share 都要被拦,因为访问 UNC 路径可能让系统自动发送 NTLM 凭据。第二个正则用 (?<!:) 排除 https:// 这类合法 URL。
TOCTOU 防护的策略也很保守:
| 输入 | 为什么危险 |
|---|---|
~root/... | shell 执行时可能展开到 root 目录 |
$HOME/.ssh/id_rsa | 验证时是字面文本,执行时变成敏感路径 |
%USERPROFILE% | Windows 环境变量展开 |
=rg | Zsh 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:历史上出现过 SessionEnd 和 SubagentStop 在信任确认前执行的漏洞。统一检查是纵深防御,而不是只保护“看起来会早执行”的事件。
配置快照: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 配置。
平台差异:
| 平台 | 文件系统隔离 | 网络隔离 |
|---|---|---|
| macOS | sandbox-exec / Seatbelt Profile | Profile 规则和 Unix socket 路径过滤 |
| Linux | Bubblewrap,只读根 + 可写 bind mount | seccomp 系统调用过滤 |
| 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 && 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 描述了一个很有代表性的逃逸路径:攻击者在工作目录种下 HEAD、objects/、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_DIRECTORIES | filesystem.ts | 标记写操作必须谨慎处理的路径 |
syncHookResponseSchema | types/hooks.ts | Hook 结构化输出和阻塞协议 |
| Hook 配置快照 | hooksConfigSnapshot.ts | 避免实时配置抖动和信任绕过 |
SandboxSettingsSchema | sandboxTypes.ts | 沙箱配置单一事实来源 |
ISandboxManager | sandbox-adapter.ts | 应用层和 sandbox-runtime 的统一接口 |
allowWrite / denyWrite | sandbox runtime config | 文件系统可写和禁止写边界 |
allowedDomains / deniedDomains | sandbox network config | 网络访问白名单和黑名单 |
bareGitRepoScrubPaths | sandbox-adapter.ts | 命令后清理可能被植入的 bare repo 文件 |
设计取舍
| 取舍 | 源码证据 | 工程判断 |
|---|---|---|
| 分类器过滤助手文本 | Only include tool_use blocks | 不让主模型自由文本影响安全裁决 |
| 路径展开选择拒绝 | validatePath() 拦 $、%、=、危险 tilde | 不模拟 shell,避免 TOCTOU |
| 危险路径 bypass 免疫 | DANGEROUS_FILES、DANGEROUS_DIRECTORIES | 项目内也可能存在持久化和代码执行入口 |
| Hook 统一信任门控 | shouldSkipHookDueToTrust() | 防止任何 Hook 在 workspace trust 前执行 |
| Hook 用退出码作协议 | 退出码 2 阻塞 | 让 shell 命令型扩展也能参与控制流 |
| Hook 配置快照 | captureHooksConfigSnapshot() | 配置读取有明确边界,更新必须显式 |
| 沙箱适配器分层 | SandboxManager + BaseSandboxManager | 平台隔离由 runtime 做,业务语义由 adapter 做 |
| 复合命令拆分后判断排除 | splitCommand_DEPRECATED() | 防止一个被排除子命令带着其他命令逃出沙箱 |
| 企业域名硬策略 | allowManagedDomainsOnly | 高优先级策略能忽略用户层网络白名单 |
| Bare repo 清理 | scrubBareGitRepoFiles() | 防御命令执行后的延迟逃逸路径 |
读源码抓手
- 先读
buildTranscriptEntries(),看分类器如何避免读取 assistant 自由文本。 - 再读
hasPermissionsToUseTool()和pathValidation.ts,把动作许可和路径语义检查连起来。 - 接着读
filesystem.ts里的危险文件和目录,理解为什么项目内配置文件也可能危险。 - 然后读
executeHooks()、syncHookResponseSchema、shouldSkipHookDueToTrust(),看 Hook 如何参与控制流又如何被约束。 - 再读
captureHooksConfigSnapshot()和getHooksFromAllowedSources(),确认企业策略如何过滤 Hook 来源。 - 最后读
shouldUseSandbox()、SandboxManager.wrapWithSandbox()和convertToSandboxRuntimeConfig(),追 Bash 命令如何进入操作系统隔离。 - 用 Bare Git Repo 防护案例收尾,检查
denyWrite、bareGitRepoScrubPaths、cleanupAfterCommand()三个点如何配合。
小结
小结
Claude Code 的安全防线是多层收口,不是单点阻断。
- 分类器输入层先隔离主模型自由文本,避免 AI 自己影响自己的安全裁决。
- 权限和路径验证层在工具副作用前决定
allow / deny / ask,并拒绝 TOCTOU 风险路径。 - Hooks 给用户和组织扩展控制点,但必须经过信任门控、超时、退出码协议和配置来源过滤。
- 沙箱是最后一道硬边界,把文件系统、网络和进程能力限制在 runtime 配置里。
- Bare Git Repo 防护说明真正的安全不是只防当前命令,还要防命令执行后留下的逃逸诱饵。