Claude Code 的沙箱系统
特性实现方案源码位置跨平台一致性统一配置接口,底层适配bubblewrap(Linux)和Seatbelt(macOS)嵌套命名空间apply-seccomp在socat之后应用seccomp,解决socket创建冲突非存在路径保护预创建/dev/null挂载点阻止未来攻击符号链接追踪isSymlinkOutsideBoundary防止symlink绕过延迟挂载点清理activeSandboxCo
Claude Code 的沙箱系统是一个企业级安全隔离解决方案,用于在受控环境中执行潜在不可信的命令。该系统基于 `@anthropic-ai/sandbox-runtime` 包构建,提供了文件系统隔离、网络访问控制、权限管理等多层安全防护机制,代码中大量的注释都指向具体的 GitHub Issue(如 #30067、#34044、#29316),说明每个复杂逻辑背后都有真实的问题驱动。
核心包 sandbox-runtime
- GitHub: https://github.com/anthropic-experimental/sandbox-runtime
- npm 包名:
@anthropic-ai/sandbox-runtime - 可以作为独立工具使用(
srt命令) - 也可以作为库集成到其他项目中(Claude Code 中集成)
- 用途: 为AI代理(agents)、MCP服务器、bash命令等提供轻量级沙箱隔离
一个Anthropic开发的沙箱运行时系统(Sandbox Runtime),专门为Claude Code设计,用于在操作系统级别对任意进程实施安全限制,这个沙箱系统通过操作系统级别的强制访问控制、多层防御机制和精细的权限管理,为AI代理提供了安全可靠的最小权限执行环境。
核心架构
双重隔离模型
┌─────────────────────────────────
│ 沙箱运行时 (Host)
│ ┌──────────┐ ┌─────────────────
│ │ HTTP代理 │ │ SOCKS5代理
│ │ (过滤HTTP) │ │ (过滤TCP/SSH等)
│ └────┬─────┘ └────────┬────────
│ │
│ ┌────▼───────────────────▼─────
│ │ 网络过滤层 (Domain Allowlist)
│ └───────────────────────────────
└─────────────────────────────────
│ Unix Socket / TCP
┌──────────▼─────────────────────
│ 沙箱进程 (Sandboxed)
│ ┌───────────────────────────────
│ │ 文件系统隔离 (OS级强制访问控制)
│ └────────────────────────────────
│ ┌────────────────────────────────
│ │ 网络命名空间隔离 (Linux)
│ │ 或端口限制 (macOS)
│ └────────────────────────────────
└──────────────────────────────────
平台特定实现
| 平台 | 文件系统隔离 | 网络隔离 |
|---|---|---|
| macOS | sandbox-exec + Seatbelt配置文件 |
限制只能连接到localhost代理端口 |
| Linux | bubblewrap (bwrap) + 绑定挂载 |
--unshare-net 隔离网络命名空间 + socat桥接 |
🔐 安全机制详解
1. 文件系统隔离
1. sandbox-schemas.ts - 双层权限模型
读取权限 (Deny-then-Allow模式):
- 默认允许所有读取
- 可拒绝大范围区域(如
/Users),再允许特定路径(如.) allowRead优先级高于denyRead
写入权限 (Allow-Only模式):
- 默认拒绝所有写入
- 必须显式允许路径(如
.,/tmp) denyWrite优先级高于allowWrite
| 权限类型 | 模式 | 默认行为 | 配置接口 |
|---|---|---|---|
| 读取 | Deny-then-Allow | 开放(空deny = 允许所有) | denyOnly[] + allowWithinDeny[] |
| 写入 | Allow-then-Deny | 封闭(空allow = 拒绝所有) | allowOnly[] + denyWithinAllow[] |
| 网络 | Allow-only | 封闭(空allow = 拒绝所有) | allowedHosts[] + deniedHosts[] |
关键区别:读取和写入采用相反的默认策略,这是因为:
-
读取:用户通常希望访问大多数文件,只需保护敏感区域
-
写入:危险操作,默认应拒绝,仅开放必要路径
强制保护路径 (Mandatory Deny Paths):
// 始终阻止写入的关键文件
.bashrc, .bash_profile, .zshrc, .gitconfig
.mcp.json, .ripgreprc
// 始终阻止写入的目录
.vscode/, .idea/, .claude/commands/, .git/hooks/
2. 网络隔离
源码位置: http-proxy.ts 和 socks-proxy.ts
// ============ HTTP代理 (拦截HTTP/HTTPS流量) ============
// 文件: src/sandbox/http-proxy.ts
export function createHttpProxyServer(options: HttpProxyServerOptions): Server {
const server = createServer()
// 处理HTTPS的CONNECT请求 (隧道模式)
server.on('connect', async (req, socket, head) => {
const target = parseConnectTarget(req.url!)
const { hostname, port } = target
// 🔒 关键: 调用过滤器验证域名是否在白名单中
const allowed = await options.filter(port, hostname, socket)
if (!allowed) {
// 拒绝访问,返回403
socket.end('HTTP/1.1 403 Forbidden\r\n...Connection blocked by network allowlist')
return
}
// 决定上游路由: MITM代理 > 父代理 > 直连
const mitmSocketPath = options.getMitmSocketPath?.(hostname)
let upstream: Socket
if (mitmSocketPath) {
// 路由到MITM代理进行检查
upstream = await openConnectTunnel({
dial: () => connect({ path: mitmSocketPath }),
destHost: hostname,
destPort: port,
})
} else {
upstream = await dialDirect(hostname, port)
}
// 建立隧道,双向pipe数据流
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
upstream.pipe(socket)
socket.pipe(upstream)
})
// 处理普通HTTP请求
server.on('request', async (req, res) => {
const url = new URL(req.url!)
const hostname = stripBrackets(url.hostname)
// 🔒 同样需要过滤器验证
const allowed = await options.filter(port, hostname, req.socket)
if (!allowed) {
res.writeHead(403)
res.end('Connection blocked by network allowlist')
return
}
// ...转发请求
})
}
// ============ SOCKS5代理 (处理其他TCP连接) ============
// 文件: src/sandbox/socks-proxy.ts
export function createSocksProxyServer(options: SocksProxyServerOptions): SocksProxyWrapper {
const socksServer = createServer()
// 🔒 规则验证器 - 在建立连接前检查域名
socksServer.setRulesetValidator(async conn => {
const hostname = conn.destAddress
const port = conn.destPort
// 安全检查: 拒绝包含控制字符的主机名
if (!isValidHost(hostname)) {
logForDebugging(`Rejecting malformed SOCKS host: ${JSON.stringify(hostname)}`)
return false
}
// 调用过滤器验证域名白名单
const allowed = await options.filter(port, hostname)
return allowed
})
// 连接处理器 - 建立TCP隧道
socksServer.setConnectionHandler((conn, sendStatus) => {
const host = conn.destAddress
const port = conn.destPort
// 直连或通过父代理连接
const open = options.parentProxy
? connectViaParentProxy(parentUrl, host, port)
: dialDirect(host, port)
open.then(upstream => {
sendStatus('REQUEST_GRANTED') // SOCKS5成功响应
upstream.pipe(conn.socket) // 双向转发
conn.socket.pipe(upstream)
})
})
}
代理架构:
- HTTP代理: 拦截HTTP/HTTPS流量,验证域名白名单
- SOCKS5代理: 处理其他TCP连接(SSH、数据库等)
- MITM代理支持: 可路由特定域名到自定义MITM代理进行检查
域名过滤逻辑:
源码位置: sandbox-manager.ts
// 文件: src/sandbox/sandbox-manager.ts
/**
* 域名过滤核心逻辑
* 检查顺序: 拒绝列表 → 允许列表 → 用户回调 → 默认拒绝
*/
async function filterNetworkRequest(
port: number,
host: string,
sandboxAskCallback?: SandboxAskCallback,
): Promise<boolean> {
if (!config) {
return false // 无配置,默认拒绝
}
// 🛡️ 安全措施1: 拒绝包含控制字符的主机名
// 防止 evil.com\x00.allowed.com 这样的攻击
if (!isValidHost(host)) {
logForDebugging(`Denying malformed host: ${JSON.stringify(host)}:${port}`)
return false
}
// 🛡️ 安全措施2: 主机名规范化
// 防止 2852039166 (=169.254.169.254) 绕过IP拒绝列表
const canonicalHost = canonicalizeHost(host) ?? host
// ✅ 步骤1: 检查拒绝列表 (优先级最高)
for (const deniedDomain of config.network.deniedDomains) {
if (matchesDomainPattern(canonicalHost, deniedDomain)) {
logForDebugging(`Denied by config rule: ${host}:${port}`)
return false // 匹配拒绝规则,立即拒绝
}
}
// ✅ 步骤2: 检查允许列表
for (const allowedDomain of config.network.allowedDomains) {
if (matchesDomainPattern(canonicalHost, allowedDomain)) {
logForDebugging(`Allowed by config rule: ${host}:${port}`)
return true // 匹配允许规则,放行
}
}
// ✅ 步骤3: 调用用户授权回调 (动态决策)
if (!sandboxAskCallback) {
logForDebugging(`No matching config rule, denying: ${host}:${port}`)
return false // 无回调,默认拒绝
}
const userAllowed = await sandboxAskCallback({ host, port })
if (userAllowed) {
logForDebugging(`User allowed: ${host}:${port}`)
return true // 用户允许,放行
} else {
logForDebugging(`User denied: ${host}:${port}`)
return false // 用户拒绝
}
}
/**
* 域名模式匹配 - 支持通配符
*/
function matchesDomainPattern(hostname: string, pattern: string): boolean {
const h = hostname.toLowerCase()
// 支持 *.example.com 通配符模式
if (pattern.startsWith('*.')) {
// 🛡️ 安全措施: 不对IP地址应用通配符匹配
// 防止 ::ffff:1.2.3.4%x.allowed.com 绕过检查
if (isIP(stripBrackets(h))) return false
const baseDomain = pattern.substring(2).toLowerCase()
return h.endsWith('.' + baseDomain) // 后缀匹配
}
// 精确匹配
return h === pattern.toLowerCase()
}
// 检查顺序:拒绝列表 → 允许列表 → 用户回调
1. 检查 deniedDomains (优先拒绝)
2. 检查 allowedDomains (允许访问)
3. 调用 sandboxAskCallback (请求用户授权)
4. 默认拒绝
安全措施:
源码位置: parent-proxy.ts
// 文件: src/sandbox/parent-proxy.ts
/**
* 🛡️ 安全措施1: 主机名验证
* 目的: 阻止控制字符 (CRLF注入、空字节DNS截断)
*/
export function isValidHost(h: string): boolean {
if (!h || h.length > 255) return false
const bare = stripBrackets(h)
// 拒绝IPv6 zone标识符 (包含%)
// 防止 ::ffff:1.2.3.4%x.allowed.com 绕过白名单
if (bare.includes('%')) return false
// 如果是IP地址,直接通过
if (isIP(bare)) return true
// DNS标签字符集验证 (只允许字母、数字、点、下划线、连字符)
return /^[A-Za-z0-9._-]+$/.test(bare)
}
/**
* 🛡️ 安全措施2: 主机名规范化
* 目的: 统一格式,防止绕过检查
*/
export function canonicalizeHost(h: string): string | undefined {
try {
const bare = stripBrackets(h)
// WHATWG URL解析器自动规范化:
// - inet_aton简写: 127.1 → 127.0.0.1
// - 十进制简写: 2130706433 → 127.0.0.1
// - 十六进制/八进制: 0x7f.0.0.1 → 127.0.0.1
// - IPv6压缩: 0:0:0:0:0:0:0:1 → ::1
const bracketed = isIP(bare) === 6 ? `[${bare}]` : bare
const out = new URL(`http://${bracketed}/`).hostname
return stripBrackets(out).replace(/\.$/, '')
} catch {
return undefined
}
}
- 主机名规范化(防止
2852039166绕过IP拒绝) - 控制字符检测(防止
evil.com\x00.allowed.com攻击) - 通配符支持(
*.example.com),但禁止过宽模式(*.com)
源码位置: sandbox-config.ts
// 文件: src/sandbox/sandbox-config.ts
/**
* 🛡️ 安全措施3: 域名模式验证 (Zod Schema)
* 目的: 防止配置过于宽通的通配符
*/
const domainPatternSchema = z.string().refine(
val => {
// 拒绝协议、路径、端口
if (val.includes('://') || val.includes('/') || val.includes(':')) {
return false
}
// 允许localhost
if (val === 'localhost') return true
// 允许 *.example.com 形式的通配符
if (val.startsWith('*.')) {
const domain = val.slice(2)
// 🛡️ 必须有至少一个点,防止 *.com 这样过于宽泛的模式
if (!domain.includes('.') || domain.startsWith('.') || domain.endsWith('.')) {
return false
}
// 必须有至少两部分 (如 example.com)
const parts = domain.split('.')
return parts.length >= 2 && parts.every(p => p.length > 0)
}
// 拒绝其他任何通配符使用
if (val.includes('*')) {
return false
}
// 普通域名必须有至少一个点
return val.includes('.') && !val.startsWith('.') && !val.endsWith('.')
},
{
message: 'Invalid domain pattern. Overly broad patterns like "*.com" are not allowed'
}
)
3. Unix Socket限制(Linux)
双层架构设计
源码位置: linux-sandbox-utils.ts
// 文件: src/sandbox/linux-sandbox-utils.ts
/**
* 在Linux上包装命令,应用沙箱限制
*
* 架构设计 (双层隔离):
*
* Stage 1: 外层bwrap - 网络和文件系统隔离 (不应用seccomp)
* ├─ bubblewrap启动,隔离网络命名空间 (--unshare-net)
* ├─ 应用PID命名空间隔离 (--unshare-pid和--proc)
* ├─ 应用文件系统限制 (只读挂载、绑定挂载等)
* └─ socat进程启动并连接Unix socket桥接 (可以调用socket(AF_UNIX, ...))
*
* Stage 2: apply-seccomp - 嵌套PID命名空间 + seccomp过滤器
* ├─ apply-seccomp创建嵌套的user+PID+mount命名空间
* ├─ 在嵌套命名空间内,apply-seccomp成为PID 1 (不可dump的init/reaper)
* ├─ 分叉后,设置PR_SET_NO_NEW_PRIVS,通过prctl(PR_SET_SECCOMP)应用seccomp
* ├─ 执行用户命令,seccomp激活 (无法创建新的Unix socket)
* └─ 用户命令无法看到或ptrace bwrap/bash/socat (独立的PID命名空间)
*/
export async function wrapCommandWithSandboxLinux(
params: LinuxSandboxParams,
): Promise<string> {
const {
command,
needsNetworkRestriction,
httpSocketPath,
socksSocketPath,
httpProxyPort,
socksProxyPort,
readConfig,
writeConfig,
allowAllUnixSockets,
seccompConfig,
} = params
const bwrapArgs: string[] = ['--new-session', '--die-with-parent']
let applySeccompPrefix: string | undefined
try {
// ========== SECCOMP过滤器 (阻止Unix Socket创建) ==========
// apply-seccomp包装工作负载并应用内置的BPF过滤器
// 该过滤器阻止 socket(AF_UNIX, ...) 系统调用
if (!allowAllUnixSockets) {
// 解析apply-seccomp二进制路径
applySeccompPrefix = resolveApplySeccompPrefix(
seccompConfig?.applyPath,
seccompConfig?.argv0,
)
if (!applySeccompPrefix) {
logForDebugging(
'[Sandbox Linux] apply-seccomp binary not available - unix socket blocking disabled',
{ level: 'warn' }
)
} else {
logForDebugging('[Sandbox Linux] Applying seccomp filter for Unix socket blocking')
}
}
// ========== 网络限制 ==========
if (needsNetworkRestriction) {
// 总是unshare网络命名空间以隔离网络访问
// 这会移除所有网络接口,有效阻止所有网络
bwrapArgs.push('--unshare-net')
// 如果提供了代理socket,将它们绑定到沙箱内
if (httpSocketPath && socksSocketPath) {
// 验证socket文件仍然存在
if (!fs.existsSync(httpSocketPath)) {
throw new Error(`Linux HTTP bridge socket does not exist: ${httpSocketPath}`)
}
if (!fs.existsSync(socksSocketPath)) {
throw new Error(`Linux SOCKS bridge socket does not exist: ${socksSocketPath}`)
}
// 将两个socket绑定到沙箱内
bwrapArgs.push('--bind', httpSocketPath, httpSocketPath)
bwrapArgs.push('--bind', socksSocketPath, socksSocketPath)
// 添加代理环境变量
const proxyEnv = generateProxyEnvVars(3128, 1080)
bwrapArgs.push(
...proxyEnv.flatMap((env: string) => {
const firstEq = env.indexOf('=')
const key = env.slice(0, firstEq)
const value = env.slice(firstEq + 1)
return ['--setenv', key, value]
})
)
}
}
// ========== 文件系统限制 ==========
const fsArgs = await generateFilesystemArgs(readConfig, writeConfig, ...)
bwrapArgs.push(...fsArgs)
// 总是绑定/dev
bwrapArgs.push('--dev', '/dev')
// ========== PID命名空间隔离 ==========
// 重要: 这些必须在文件系统绑定之后,以便嵌套bwrap工作
bwrapArgs.push('--unshare-pid')
if (!enableWeakerNestedSandbox) {
// 挂载新的/proc (安全模式)
bwrapArgs.push('--proc', '/proc')
} else {
// 在Docker环境中的弱沙箱模式
bwrapArgs.push('--unshare-user', '--bind', '/proc', '/proc')
}
// ========== 命令构建 ==========
const shellName = binShell || 'bash'
const shell = whichSync(shellName)
if (!shell) {
throw new Error(`Shell '${shellName}' not found in PATH`)
}
bwrapArgs.push('--', shell, '-c')
// 如果有网络限制,通过buildSandboxCommand路由命令
// 这样socat在应用seccomp之前启动
if (needsNetworkRestriction && httpSocketPath && socksSocketPath) {
const sandboxCommand = buildSandboxCommand(
httpSocketPath,
socksSocketPath,
command,
applySeccompPrefix, // 👈 seccomp前缀传递到这里
shell,
)
bwrapArgs.push(sandboxCommand)
} else if (applySeccompPrefix) {
const applySeccompCmd =
applySeccompPrefix + shellquote.quote([shell, '-c', command])
bwrapArgs.push(applySeccompCmd)
} else {
bwrapArgs.push(command)
}
const wrappedCommand = shellquote.quote(['bwrap', ...bwrapArgs])
return wrappedCommand
} catch (error) {
if (activeSandboxCount > 0) {
activeSandboxCount--
}
throw error
}
}
/**
* 构建沙箱内执行的命令
* 在端口3128设置HTTP代理,在端口1080设置SOCKS代理
*/
function buildSandboxCommand(
httpSocketPath: string,
socksSocketPath: string,
userCommand: string,
applySeccompPrefix: string | undefined, // 👈 seccomp前缀
shell?: string,
): string {
const shellPath = shell || 'bash'
// socat命令: 在沙箱内启动监听器,转发到Unix socket
const socatCommands = [
`socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:${httpSocketPath} >/dev/null 2>&1 &`,
`socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:${socksSocketPath} >/dev/null 2>&1 &`,
'trap "kill %1 %2 2>/dev/null; exit" EXIT',
]
// 👇 关键: apply-seccomp在socat之后运行
// 这样socat仍然可以创建Unix socket
if (applySeccompPrefix) {
const applySeccompCmd =
applySeccompPrefix + shellquote.quote([shellPath, '-c', userCommand])
// 脚本顺序:
// 1. 启动socat监听器 (可以创建Unix socket)
// 2. 通过apply-seccomp执行用户命令 (seccomp激活,阻止创建新socket)
const innerScript = [...socatCommands, applySeccompCmd].join('\n')
return `${shellPath} -c ${shellquote.quote([innerScript])}`
} else {
// 没有seccomp,直接执行用户命令
const innerScript = [
...socatCommands,
`eval ${shellquote.quote([userCommand])}`,
].join('\n')
return `${shellPath} -c ${shellquote.quote([innerScript])}`
}
}
/**
* 解析apply-seccomp前缀
* 返回可以直接用于shell的字符串
*/
function resolveApplySeccompPrefix(
applyPath: string | undefined,
argv0: string | undefined,
): string | undefined {
if (argv0) {
// argv0模式: apply-seccomp作为multicall二进制文件
// 通过ARGV0环境变量分发
if (!applyPath) {
throw new Error('seccompConfig.argv0 requires seccompConfig.applyPath')
}
return `ARGV0=${shellquote.quote([argv0])} ${shellquote.quote([applyPath])} `
}
// 标准模式: 直接使用apply-seccomp二进制文件
const binary = getApplySeccompBinaryPath(applyPath)
return binary ? `${shellquote.quote([binary])} ` : undefined
}
使用seccomp BPF过滤器在系统调用级别阻止socket(AF_UNIX, ...):
外层bwrap (网络+文件系统隔离)
└─ socat桥接进程 (允许Unix socket)
└─ apply-seccomp (嵌套PID命名空间)
└─ 用户命令 (seccomp激活,阻止Unix socket创建)
apply-seccomp 内层(系统调用过滤)
核心问题:网络代理需要创建 Unix socket 进行通信,但安全要求阻止用户代码创建任意 Unix socket。
解决方案(来自代码注释):
"apply-seccomp creates a nested user+PID+mount namespace... becomes PID 1 (non-dumpable init/reaper)... applies seccomp via prctl(PR_SET_SECCOMP)... Execs user command with seccomp active (cannot create new Unix sockets)"
* 核心安全问题与解决方案:
*
* 问题: 网络代理需要创建Unix socket进行通信,
* 但安全要求阻止用户代码创建任意Unix socket。
*
* 传统方案的困境:
* - 如果在bwrap层应用seccomp → socat无法创建socket桥接 → 网络代理失效
* - 如果不应用seccomp → 用户代码可以创建任意Unix socket → 安全风险
*
* 创新解决方案: 嵌套命名空间 + 延迟seccomp应用
*
* 1. Stage 1 (bwrap):
* - 创建隔离环境 (网络、PID、文件系统)
* - 启动socat桥接进程 (无seccomp限制)
* - socat成功创建Unix socket并连接到主机代理
*
* 2. Stage 2 (apply-seccomp):
* - 创建嵌套的user+PID+mount命名空间
* - 在嵌套命名空间中应用seccomp BPF过滤器
* - 执行用户命令 (受到seccomp限制)
*
* 精妙之处:
* ✓ 用户代码无法创建新的Unix socket (seccomp阻止)
* ✓ 用户代码可以继承使用Stage 1已创建的socket FD
* ✓ 用户代码无法看到或ptrace bwrap/socat进程 (PID隔离)
* ✓ socat可以正常通信 (在seccomp之前启动)
* ✓ 攻击面最小化 (用户进程完全隔离)
┌────────────────────────────
│ Host Linux
│ ┌──────────────────────────
│ │ bwrap (Stage 1)
│ │ - 网络命名空间已隔离
│ │ - socat 启动(可创建 socket)
│ │ ┌────────────────────────
│ │ │ apply-seccomp (Stage 2)
│ │ │ - 嵌套 PID 命名空间
│ │ │ - seccomp-BPF 过滤
│ │ │ - 用户代码运行于此
│ │ │ - 无法创建新 Unix socket
│ │ │ - 但继承的 socket FD 可用
│ │ └────────────────────────
│ └──────────────────────────
└────────────────────────────
这种设计的精妙之处在于:用户代码无法创建新的 Unix socket,但可以使用 Stage 1 已创建的 socket 与代理通信。
┌──────────────────────────────────────────────────
│ Host Linux
│
│ ┌──────────────────────────────────────────────
│ │ Stage 1: bwrap (外层沙箱)
│ │
│ │ 参数:
│ │ --unshare-net # 隔离网络命名空间
│ │ --unshare-pid # 隔离PID命名空间
│ │ --proc /proc # 挂载新的/proc
│ │ --bind socket socket # 绑定Unix socket
│ │ --ro-bind / / # 只读根文件系统
│ │
│ │ ┌────────────────────────────────────────
│ │ │ Shell脚本执行:
│ │ │
│ │ │ 1. socat TCP-LISTEN:3128 ...
│ │ │ UNIX-CONNECT:/tmp/http.sock &
│ │ │ ↑ 可以创建Unix socket (无seccomp)
│ │ │
│ │ │ 2. socat TCP-LISTEN:1080 ...
│ │ │ UNIX-CONNECT:/tmp/socks.sock &
│ │ │ ↑ 可以创建Unix socket (无seccomp)
│ │ │
│ │ │ 3. apply-seccomp bash -c '用户命令'
│ │ │ ┌───────────────────────────────
│ │ │ │ Stage 2: apply-seccomp
│ │ │ │
│ │ │ │ - 创建嵌套user+PID+mount ns
│ │ │ │ - 成为PID 1 (不可dump)
│ │ │ │ - prctl(PR_SET_SECCOMP)
│ │ │ │ - 应用BPF过滤器:
│ │ │ │ 阻止 socket(AF_UNIX, ...)
│ │ │ │
│ │ │ │ ┌────────────────────────
│ │ │ │ │ 用户命令执行
│ │ │ │ │
│ │ │ │ │ ✓ 可以连接继承的socket
│ │ │ │ │ ✗ 不能创建新Unix socket
│ │ │ │ │ ✗ 不能看到外层进程
│ │ │ │ │ (独立PID命名空间)
│ │ │ │ └─────────────────────────
│ │ │ └──────────────────────────────
│ │ └───────────────────────────────────────
│ └────────────────────────────────────────────
└──────────────────────────────────────────────────
Linux 网络桥接架构
Linux 使用 bwrap --unshare-net 完全隔离网络命名空间后,沙箱内进程失去所有网络访问能力。但为了支持受控的网络访问,需要建立从沙箱到主机代理的通信通道。
双层 Socat 桥接方案
源码位置: linux-sandbox-utils.ts
// 文件: src/sandbox/linux-sandbox-utils.ts
/**
* 初始化 Linux 网络桥接
*
* 架构设计:
* 主机侧: socat监听Unix socket → 转发到主机代理(TCP)
* 沙箱侧: socat监听TCP端口 → 转发到Unix socket(通过bind挂载)
*
* 数据流: 沙箱进程 → TCP:3128 → socat → Unix Socket → socat → TCP:代理端口
*/
export async function initializeLinuxNetworkBridge(
httpProxyPort: number,
socksProxyPort: number,
): Promise<LinuxNetworkBridgeContext> {
// 生成唯一的socket文件名
const socketId = randomBytes(8).toString('hex')
const httpSocketPath = join(tmpdir(), `claude-http-${socketId}.sock`)
const socksSocketPath = join(tmpdir(), `claude-socks-${socketId}.sock`)
// ========== 主机侧: 启动 HTTP 桥接 ==========
// socat: Unix socket监听 → TCP转发到主机HTTP代理
const httpSocatArgs = [
`UNIX-LISTEN:${httpSocketPath},fork,reuseaddr`, // 监听Unix socket
`TCP:localhost:${httpProxyPort},keepalive,keepidle=10,keepintvl=5,keepcnt=3`, // 转发到TCP
]
const httpBridgeProcess = spawn('socat', httpSocatArgs, { stdio: 'ignore' })
// ========== 主机侧: 启动 SOCKS 桥接 ==========
const socksSocatArgs = [
`UNIX-LISTEN:${socksSocketPath},fork,reuseaddr`,
`TCP:localhost:${socksProxyPort},keepalive,keepidle=10,keepintvl=5,keepcnt=3`,
]
const socksBridgeProcess = spawn('socat', socksSocatArgs, { stdio: 'ignore' })
// 等待socket文件创建完成
const maxAttempts = 5
for (let i = 0; i < maxAttempts; i++) {
if (fs.existsSync(httpSocketPath) && fs.existsSync(socksSocketPath)) {
break
}
await new Promise(resolve => setTimeout(resolve, i * 100))
}
return {
httpSocketPath,
socksSocketPath,
httpBridgeProcess,
socksBridgeProcess,
httpProxyPort,
socksProxyPort,
}
}
沙箱内的桥接配置 (同文件 buildSandboxCommand 函数):
/**
* 构建沙箱内执行的命令
* 在沙箱内部启动socat监听器,连接到绑定的Unix socket
*/
function buildSandboxCommand(
httpSocketPath: string,
socksSocketPath: string,
userCommand: string,
applySeccompPrefix: string | undefined,
shell?: string,
): string {
const shellPath = shell || 'bash'
// 沙箱内的socat命令: TCP监听 → Unix socket连接
const socatCommands = [
// HTTP代理: 监听3128端口,转发到Unix socket
`socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:${httpSocketPath} >/dev/null 2>&1 &`,
// SOCKS代理: 监听1080端口,转发到Unix socket
`socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:${socksSocketPath} >/dev/null 2>&1 &`,
// 退出时清理socat进程
'trap "kill %1 %2 2>/dev/null; exit" EXIT',
]
// 在socat之后执行用户命令(可能带seccomp限制)
if (applySeccompPrefix) {
const applySeccompCmd =
applySeccompPrefix + shellquote.quote([shellPath, '-c', userCommand])
const innerScript = [...socatCommands, applySeccompCmd].join('\n')
return `${shellPath} -c ${shellquote.quote([innerScript])}`
} else {
const innerScript = [
...socatCommands,
`eval ${shellquote.quote([userCommand])}`,
].join('\n')
return `${shellPath} -c ${shellquote.quote([innerScript])}`
}
}
1.3 环境变量注入
源码位置: sandbox-utils.ts
// 文件: src/sandbox/sandbox-utils.ts
/**
* 生成代理环境变量,确保沙箱内所有网络流量经过代理
*/
export function generateProxyEnvVars(
httpProxyPort?: number,
socksProxyPort?: number,
): string[] {
const envVars: string[] = [`SANDBOX_RUNTIME=1`, `TMPDIR=${tmpdir}`]
// 设置NO_PROXY排除本地和私有网络
const noProxyAddresses = [
'localhost', '127.0.0.1', '::1', '*.local', '.local',
'169.254.0.0/16', // Link-local
'10.0.0.0/8', // Private network
'172.16.0.0/12', // Private network
'192.168.0.0/16', // Private network
].join(',')
envVars.push(`NO_PROXY=${noProxyAddresses}`)
// HTTP/HTTPS代理 (端口3128)
if (httpProxyPort) {
envVars.push(`HTTP_PROXY=http://localhost:${httpProxyPort}`)
envVars.push(`HTTPS_PROXY=http://localhost:${httpProxyPort}`)
envVars.push(`http_proxy=http://localhost:${httpProxyPort}`)
envVars.push(`https_proxy=http://localhost:${httpProxyPort}`)
}
// SOCKS5代理 (端口1080)
if (socksProxyPort) {
// 使用socks5h://确保DNS解析也通过代理
envVars.push(`ALL_PROXY=socks5h://localhost:${socksProxyPort}`)
envVars.push(`all_proxy=socks5h://localhost:${socksProxyPort}`)
// Git SSH通过代理
const platform = getPlatform()
if (platform === 'macos') {
// macOS: 使用nc的SOCKS5支持
envVars.push(
`GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`
)
} else if (platform === 'linux' && httpProxyPort) {
// Linux: 使用socat通过HTTP代理隧道
envVars.push(
`GIT_SSH_COMMAND=ssh -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=${httpProxyPort}'`
)
}
// 其他工具代理配置
envVars.push(`FTP_PROXY=socks5h://localhost:${socksProxyPort}`)
envVars.push(`GRPC_PROXY=socks5h://localhost:${socksProxyPort}`)
envVars.push(`DOCKER_HTTP_PROXY=http://localhost:${httpProxyPort || socksProxyPort}`)
// Google Cloud SDK特殊配置
if (httpProxyPort) {
envVars.push(`CLOUDSDK_PROXY_TYPE=https`)
envVars.push(`CLOUDSDK_PROXY_ADDRESS=localhost`)
envVars.push(`CLOUDSDK_PROXY_PORT=${httpProxyPort}`)
}
}
return envVars
}
二、macOS Seatbelt 沙箱实现
2.1 SBPL配置文件生成
源码位置: macos-sandbox-utils.ts
// 文件: src/sandbox/macos-sandbox-utils.ts
/**
* 生成完整的macOS Seatbelt配置文件 (SBPL - Sandbox Profile Language)
*/
function generateSandboxProfile({
readConfig,
writeConfig,
httpProxyPort,
socksProxyPort,
needsNetworkRestriction,
allowUnixSockets,
allowAllUnixSockets,
allowLocalBinding,
logTag,
}: {...}): string {
const profile: string[] = [
'(version 1)',
// 🔒 默认拒绝所有操作 (白名单模式)
`(deny default (with message "${logTag}"))`,
'',
'; Essential permissions - 基于Chrome沙箱策略',
'(allow process-exec)', // 允许执行进程
'(allow process-fork)', // 允许fork
'(allow process-info* (target same-sandbox))', // 只允许查看沙箱内进程
'(allow signal (target same-sandbox))', // 只允许向沙箱内进程发信号
'',
'; Mach IPC - 仅允许特定的系统服务',
'(allow mach-lookup',
' (global-name "com.apple.audio.systemsoundserver")',
' (global-name "com.apple.system.logger")',
' (global-name "com.apple.securityd.xpc")',
// ... 更多系统服务
')',
]
// ========== 网络规则 ==========
profile.push('; Network')
if (!needsNetworkRestriction) {
profile.push('(allow network*)') // 不限制网络
} else {
// 允许本地绑定 (如果配置)
if (allowLocalBinding) {
// 使用"*:*"而非"localhost:*"以支持IPv6双栈
profile.push('(allow network-bind (local ip "*:*"))')
profile.push('(allow network-inbound (local ip "*:*"))')
profile.push('(allow network-outbound (local ip "*:*"))')
}
// Unix Socket控制
if (allowAllUnixSockets) {
profile.push('(allow system-socket (socket-domain AF_UNIX))')
profile.push('(allow network-bind (local unix-socket (path-regex #"^/")))')
profile.push('(allow network-outbound (remote unix-socket (path-regex #"^/")))')
} else if (allowUnixSockets && allowUnixSockets.length > 0) {
profile.push('(allow system-socket (socket-domain AF_UNIX))')
for (const socketPath of allowUnixSockets) {
const normalizedPath = normalizePathForSandbox(socketPath)
profile.push(
`(allow network-bind (local unix-socket (subpath ${escapePath(normalizedPath)})))`
)
profile.push(
`(allow network-outbound (remote unix-socket (subpath ${escapePath(normalizedPath)})))`
)
}
}
// 允许连接到HTTP代理
if (httpProxyPort !== undefined) {
profile.push(
`(allow network-bind (local ip "localhost:${httpProxyPort}"))`,
`(allow network-inbound (local ip "localhost:${httpProxyPort}"))`,
`(allow network-outbound (remote ip "localhost:${httpProxyPort}"))`
)
}
// 允许连接到SOCKS代理
if (socksProxyPort !== undefined) {
profile.push(
`(allow network-bind (local ip "localhost:${socksProxyPort}"))`,
`(allow network-inbound (local ip "localhost:${socksProxyPort}"))`,
`(allow network-outbound (remote ip "localhost:${socksProxyPort}"))`
)
}
}
// ========== 文件读取规则 ==========
profile.push('; File read')
profile.push(...generateReadRules(readConfig, logTag, writeConfig?.allowOnly))
// ========== 文件写入规则 ==========
profile.push('; File write')
profile.push(...generateWriteRules(writeConfig, logTag))
return profile.join('\n')
}
2.2 文件读取规则生成
/**
* 生成文件读取规则
* 策略: 默认允许 → 拒绝特定路径 → 重新允许被拒绝路径中的子路径
*
* Seatbelt规则: 后面的规则优先级更高
*/
function generateReadRules(
config: FsReadRestrictionConfig | undefined,
logTag: string,
writeAllowPaths?: string[],
): string[] {
if (!config) {
return [`(allow file-read*)`] // 无限制
}
const rules: string[] = []
// 1. 默认允许所有读取
rules.push(`(allow file-read*)`)
// 2. 拒绝特定路径
for (const pathPattern of config.denyOnly || []) {
const normalizedPath = normalizePathForSandbox(pathPattern)
if (containsGlobChars(normalizedPath)) {
// 使用正则匹配glob模式
const regexPattern = globToRegex(normalizedPath)
rules.push(
`(deny file-read*`,
` (regex ${escapePath(regexPattern)})`,
` (with message "${logTag}"))`
)
} else {
// 使用子路径匹配
rules.push(
`(deny file-read*`,
` (subpath ${escapePath(normalizedPath)})`,
` (with message "${logTag}"))`
)
}
}
// 3. 重新允许被拒绝区域中的特定路径 (allowRead优先于denyRead)
for (const pathPattern of config.allowWithinDeny || []) {
const normalizedPath = normalizePathForSandbox(pathPattern)
if (containsGlobChars(normalizedPath)) {
const regexPattern = globToRegex(normalizedPath)
rules.push(
`(allow file-read*`,
` (regex ${escapePath(regexPattern)})`,
` (with message "${logTag}"))`
)
} else {
rules.push(
`(allow file-read*`,
` (subpath ${escapePath(normalizedPath)})`,
` (with message "${logTag}"))`
)
}
}
// 4. 阻止通过mv/rename绕过读取限制
rules.push(...generateMoveBlockingRules(config.denyOnly || [], logTag))
// 5. 对写入允许的路径,重新允许删除操作
if (writeAllowPaths && writeAllowPaths.length > 0) {
for (const pathPattern of writeAllowPaths) {
const normalizedPath = normalizePathForSandbox(pathPattern)
if (containsGlobChars(normalizedPath)) {
const regexPattern = globToRegex(normalizedPath)
rules.push(
`(allow file-write-unlink`,
` (regex ${escapePath(regexPattern)})`,
` (with message "${logTag}"))`
)
} else {
rules.push(
`(allow file-write-unlink`,
` (subpath ${escapePath(normalizedPath)})`,
` (with message "${logTag}"))`
)
}
}
}
return rules
}
2.3 命令包装
/**
* 使用sandbox-exec包装macOS命令
*/
export function wrapCommandWithSandboxMacOS(params: MacOSSandboxParams): string {
const { command, needsNetworkRestriction, httpProxyPort, socksProxyPort, ... } = params
// 如果没有限制,直接返回原命令
if (!needsNetworkRestriction && !hasReadRestrictions && !hasWriteRestrictions) {
return command
}
// 生成日志标签 (用于违规追踪)
const logTag = generateLogTag(command)
// 生成Seatbelt配置文件
const profile = generateSandboxProfile({ ... })
// 生成代理环境变量
const proxyEnvArgs = generateProxyEnvVars(httpProxyPort, socksProxyPort)
// 包装命令: env设置环境变量 + sandbox-exec应用配置
const wrappedCommand = shellquote.quote([
'env',
...proxyEnvArgs,
'sandbox-exec',
'-p', // 内联配置文件
profile, // SBPL配置
shell, // 用户shell (bash/zsh)
'-c',
command, // 用户命令
])
return wrappedCommand
}
三、配置系统 (Zod验证)
3.1 域名模式验证
源码位置: sandbox-config.ts
// 文件: src/sandbox/sandbox-config.ts
/**
* 域名模式验证Schema
* 防止配置过于宽泛的通配符
*/
const domainPatternSchema = z.string().refine(
val => {
// 拒绝协议、路径、端口
if (val.includes('://') || val.includes('/') || val.includes(':')) {
return false
}
// 允许localhost
if (val === 'localhost') return true
// 允许 *.example.com 形式的通配符
if (val.startsWith('*.')) {
const domain = val.slice(2)
// 🛡️ 安全: 必须有至少一个点,防止 *.com 这样过于宽泛的模式
if (!domain.includes('.') || domain.startsWith('.') || domain.endsWith('.')) {
return false
}
// 必须至少两部分 (如 example.com)
const parts = domain.split('.')
return parts.length >= 2 && parts.every(p => p.length > 0)
}
// 拒绝其他任何通配符
if (val.includes('*')) return false
// 普通域名必须有至少一个点
return val.includes('.') && !val.startsWith('.') && !val.endsWith('.')
},
{
message: 'Invalid domain pattern. Overly broad patterns like "*.com" are not allowed'
}
)
3.2 完整配置Schema
/**
* 网络配置Schema
*/
export const NetworkConfigSchema = z.object({
allowedDomains: z.array(domainPatternSchema)
.describe('允许的域名列表 (如 ["github.com", "*.npmjs.org"])'),
deniedDomains: z.array(domainPatternSchema)
.describe('拒绝的域名列表'),
allowUnixSockets: z.array(z.string()).optional()
.describe('macOS: 允许的Unix socket路径。Linux上被忽略(seccomp无法按路径过滤)'),
allowAllUnixSockets: z.boolean().optional()
.describe('如果为true,允许所有Unix socket(两平台都禁用阻塞)'),
allowLocalBinding: z.boolean().optional()
.describe('是否允许绑定本地端口 (默认: false)'),
httpProxyPort: z.number().int().min(1).max(65535).optional()
.describe('使用外部HTTP代理的端口,而非启动本地代理'),
socksProxyPort: z.number().int().min(1).max(65535).optional()
.describe('使用外部SOCKS代理的端口,而非启动本地代理'),
mitmProxy: MitmProxyConfigSchema.optional()
.describe('MITM代理配置,将匹配的路由到上游MITM代理'),
parentProxy: ParentProxyConfigSchema.optional()
.describe('上游HTTP代理,用于企业代理环境'),
})
/**
* 文件系统配置Schema
*/
export const FilesystemConfigSchema = z.object({
denyRead: z.array(filesystemPathSchema)
.describe('拒绝读取的路径'),
allowRead: z.array(filesystemPathSchema).optional()
.describe('在被拒绝区域中重新允许读取的路径 (优先级高于denyRead)'),
allowWrite: z.array(filesystemPathSchema)
.describe('允许写入的路径'),
denyWrite: z.array(filesystemPathSchema)
.describe('拒绝写入的路径 (优先级高于allowWrite)'),
allowGitConfig: z.boolean().optional()
.describe('允许写入.git/config文件 (默认: false)'),
})
/**
* 主配置Schema
*/
export const SandboxRuntimeConfigSchema = z.object({
network: NetworkConfigSchema,
filesystem: FilesystemConfigSchema,
ignoreViolations: IgnoreViolationsConfigSchema.optional(),
enableWeakerNestedSandbox: z.boolean().optional()
.describe('启用较弱的嵌套沙箱模式 (用于Docker环境)'),
enableWeakerNetworkIsolation: z.boolean().optional()
.describe('允许访问com.apple.trustd.agent (macOS,用于Go程序TLS验证)'),
ripgrep: RipgrepConfigSchema.optional(),
mandatoryDenySearchDepth: z.number().int().min(1).max(10).optional()
.describe('Linux上搜索危险文件的最大目录深度 (默认: 3)'),
allowPty: z.boolean().optional()
.describe('允许伪终端(pty)操作 (仅macOS)'),
seccomp: SeccompConfigSchema.optional()
.describe('自定义seccomp二进制路径 (仅Linux)'),
})
四、安全加固机制
4.1 危险文件保护
源码位置: sandbox-utils.ts
// 文件: src/sandbox/sandbox-utils.ts
/**
* 危险文件列表 - 可用于代码执行或数据泄露
*/
export const DANGEROUS_FILES = [
'.gitconfig', '.gitmodules', // Git配置
'.bashrc', '.bash_profile', // Bash配置
'.zshrc', '.zprofile', '.profile', // Zsh配置
'.ripgreprc', // Ripgrep配置
'.mcp.json', // MCP服务器配置
] as const
/**
* 危险目录列表
*/
export const DANGEROUS_DIRECTORIES = ['.git', '.vscode', '.idea'] as const
/**
* 获取危险目录列表 (排除.git,改为保护特定子路径)
*/
export function getDangerousDirectories(): string[] {
return [
...DANGEROUS_DIRECTORIES.filter(d => d !== '.git'),
'.claude/commands',
'.claude/agents',
]
}
4.2 符号链接攻击防护
/**
* 检查符号链接解析是否跨越预期路径边界
*
* 攻击场景:
* 1. .claude -> /tmp/decoy (符号链接)
* 2. 用户允许写入 .claude/settings.json
* 3. 实际写入 /tmp/decoy/settings.json
* 4. 攻击者删除符号链接,创建真实 .claude 目录
* 5. 现在 .claude/settings.json 指向恶意文件
*
* 防护: 检测并阻止这种边界跨越
*/
export function isSymlinkOutsideBoundary(
originalPath: string,
resolvedPath: string,
): boolean {
const normalizedOriginal = path.normalize(originalPath)
const normalizedResolved = path.normalize(resolvedPath)
// 相同路径 - 安全
if (normalizedResolved === normalizedOriginal) {
return false
}
// 处理macOS /tmp -> /private/tmp 的规范解析 (系统级符号链接)
if (
normalizedOriginal.startsWith('/tmp/') &&
normalizedResolved === '/private' + normalizedOriginal
) {
return false // 允许
}
// 如果解析到根目录或单组件路径 - 危险
if (normalizedResolved === '/') return true
const resolvedParts = normalizedResolved.split('/').filter(Boolean)
if (resolvedParts.length <= 1) return true
// 如果原始路径以解析路径开头 - 解析路径是祖先,危险
if (normalizedOriginal.startsWith(normalizedResolved + '/')) {
return true
}
// 严格检查: 只允许在预期路径树内的解析
const resolvedStartsWithOriginal = normalizedResolved.startsWith(
normalizedOriginal + '/'
)
const resolvedIsSame = normalizedResolved === normalizedOriginal
if (!resolvedIsSame && !resolvedStartsWithOriginal) {
return true // 解析超出边界
}
return false
}
4.3 非存在路径保护
源码位置: linux-sandbox-utils.ts
/**
* 找到第一个不存在的路径组件
*
* 攻击场景: ~/.bashrc 不存在,攻击者可以创建它
* 解决方案:
* 1. 找到第一个不存在的路径组件
* 2. 用 --ro-bind /dev/null <路径> 挂载
* 3. 阻止 mkdir 创建父目录
*
* 示例: 阻止 ~/.config/malware/script.sh
* - ~/.config 存在,~/.config/malware 不存在
* - 挂载 /dev/null 到 ~/.config/malware
* - 现在无法创建 malware 目录
*/
function findFirstNonExistentComponent(targetPath: string): string {
const parts = targetPath.split(path.sep)
let currentPath = ''
for (const part of parts) {
if (!part) continue
const nextPath = currentPath + path.sep + part
if (!fs.existsSync(nextPath)) {
return nextPath // 返回第一个不存在的组件
}
currentPath = nextPath
}
return targetPath
}
4.4 符号链接替换攻击防护
/**
* 查找路径中是否在允许写入范围内存在符号链接
*
* 攻击场景:
* 1. .claude -> /tmp/decoy (符号链接)
* 2. 攻击者删除符号链接,创建真实 .claude 目录
* 3. 写入恶意内容
*
* 防护: 在符号链接处挂载 /dev/null 阻止替换
*/
function findSymlinkInPath(
targetPath: string,
allowedWritePaths: string[],
): string | null {
const parts = targetPath.split(path.sep)
let currentPath = ''
for (const part of parts) {
if (!part) continue
const nextPath = currentPath + path.sep + part
try {
const stats = fs.lstatSync(nextPath)
if (stats.isSymbolicLink()) {
// 检查此符号链接是否在允许写入范围内
const isWithinAllowedPath = allowedWritePaths.some(
allowedPath =>
nextPath.startsWith(allowedPath + '/') || nextPath === allowedPath
)
if (isWithinAllowedPath) {
return nextPath // 发现符号链接,需要保护
}
}
} catch {
break // 路径不存在
}
currentPath = nextPath
}
return null
}
五、上游代理支持 (Parent Proxy)
5.1 NO_PROXY解析
源码位置: parent-proxy.ts
// 文件: src/sandbox/parent-proxy.ts
/**
* 解析NO_PROXY规则
* 支持: 通配符(*)、主机名后缀、CIDR范围
*/
function parseNoProxy(raw: string): NoProxyRules {
const rules: NoProxyRules = {
all: false,
suffixes: [],
cidr: new BlockList(),
}
for (let entry of raw.split(',')) {
entry = entry.trim()
if (!entry) continue
// "*" 匹配所有 - 完全绕过代理
if (entry === '*') {
rules.all = true
continue
}
// CIDR范围匹配 (如 192.168.0.0/16)
const slash = entry.indexOf('/')
if (slash !== -1) {
const ip = entry.slice(0, slash)
const prefixStr = entry.slice(slash + 1)
const fam = isIP(ip)
if (fam && prefixStr !== '' && /^\d+$/.test(prefixStr)) {
const prefix = Number(prefixStr)
const max = fam === 6 ? 128 : 32
if (prefix >= 0 && prefix <= max) {
try {
rules.cidr.addSubnet(ip, prefix, fam === 6 ? 'ipv6' : 'ipv4')
} catch {
// 无效CIDR - 忽略
}
continue
}
}
continue
}
// 主机名后缀匹配
let v = entry.toLowerCase()
// 处理 [ipv6]:port 格式
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(v)
if (bracketed) v = bracketed[1]!
// 去除开头的 *.
if (v.startsWith('*.')) v = v.slice(1)
// 去除端口号 (除非是IP字面量)
const bareFam = isIP(v)
if (!bareFam) {
const colon = v.lastIndexOf(':')
if (colon !== -1 && /^\d+$/.test(v.slice(colon + 1))) {
v = v.slice(0, colon)
}
} else {
// IP字面量 - 作为精确匹配添加到CIDR列表
try {
rules.cidr.addAddress(v, bareFam === 6 ? 'ipv6' : 'ipv4')
continue
} catch {
// 回退到后缀匹配
}
}
rules.suffixes.push(v)
}
return rules
}
5.2 NO_PROXY匹配逻辑
/**
* 判断是否应该绕过父代理直接连接
* 遵循golang语义: .example.com 和 example.com 都匹配 foo.example.com
*/
export function shouldBypassParentProxy(
resolved: ResolvedParentProxy,
host: string,
): boolean {
const h = stripBrackets(host.toLowerCase().replace(/\.$/, ''))
// 总是绕过回环地址
if (h === 'localhost') return true
const fam = isIP(h)
if (fam) {
if (LOOPBACK.check(h, fam === 6 ? 'ipv6' : 'ipv4')) return true
}
// "*" 匹配所有
if (resolved.noProxy.all) return true
// CIDR匹配
if (fam) {
if (resolved.noProxy.cidr.check(h, fam === 6 ? 'ipv6' : 'ipv4')) return true
}
// 后缀匹配
for (const v of resolved.noProxy.suffixes) {
if (v.startsWith('.')) {
// .example.com 匹配 foo.example.com 和 example.com
if (h === v.slice(1) || h.endsWith(v)) return true
} else {
// example.com 匹配 example.com 和 foo.example.com (golang语义)
if (h === v || h.endsWith('.' + v)) return true
}
}
return false
}
// 回环地址BlockList (127.0.0.0/8, ::1, ::ffff:127.0.0.0/104)
const LOOPBACK = (() => {
const bl = new BlockList()
bl.addSubnet('127.0.0.0', 8, 'ipv4')
bl.addAddress('::1', 'ipv6')
bl.addSubnet('::ffff:127.0.0.0', 104, 'ipv6') // IPv4映射的IPv6回环
return bl
})()
5.3 CONNECT隧道
/**
* 通用CONNECT隧道: 通过代理建立TCP连接
* 支持Unix socket、TCP、TLS传输
*/
export function openConnectTunnel(opts: ConnectTunnelOptions): Promise<Socket> {
const { destHost, destPort } = opts
// 🛡️ CRLF注入防护: 验证目标主机名
const bare = stripBrackets(destHost)
if (!isValidHost(bare)) {
return Promise.reject(
new Error(`Invalid destination host for CONNECT: ${JSON.stringify(destHost)}`)
)
}
const authority = isIP(bare) === 6 ? `[${bare}]:${destPort}` : `${bare}:${destPort}`
return new Promise((resolve, reject) => {
const sock = opts.dial()
let settled = false
sock.setTimeout(opts.timeoutMs ?? CONNECT_TIMEOUT_MS, () =>
fail(new Error('CONNECT handshake timed out'))
)
sock.once(opts.readyEvent, () => {
// 发送CONNECT请求
sock.write(
`CONNECT ${authority} HTTP/1.1\r\n` +
`Host: ${authority}\r\n` +
(opts.authHeader ? `Proxy-Authorization: ${opts.authHeader}\r\n` : '') +
'\r\n'
)
// 读取响应
let buf = ''
const onData = (chunk: Buffer) => {
buf += chunk.toString('latin1')
const end = buf.indexOf('\r\n\r\n')
if (end === -1) {
// 限制响应头大小,防止恶意代理
if (buf.length > 16 * 1024)
fail(new Error('CONNECT response header too large'))
return
}
sock.pause()
sock.removeListener('data', onData)
const statusLine = buf.slice(0, buf.indexOf('\r\n'))
// 检查2xx状态码
if (!/^HTTP\/1\.[01] 2\d\d(?:\s|$)/.test(statusLine)) {
return fail(new Error(`Proxy refused CONNECT: ${statusLine.trim()}`))
}
// 将剩余数据推回流中
const rest = buf.slice(end + 4)
if (rest.length) sock.unshift(Buffer.from(rest, 'latin1'))
settled = true
resolve(sock) // 隧道建立成功
}
sock.on('data', onData)
})
})
}
六、代理实现 (HTTP & SOCKS5)
6.1 HTTP代理核心逻辑
源码位置: http-proxy.ts
// 文件: src/sandbox/http-proxy.ts
export function createHttpProxyServer(options: HttpProxyServerOptions): Server {
const server = createServer()
// ========== 处理HTTPS CONNECT请求 ==========
server.on('connect', async (req, socket, head) => {
const target = parseConnectTarget(req.url!)
if (!target) {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
return
}
const { hostname, port } = target
// 🔒 域名过滤检查
const allowed = await options.filter(port, hostname, socket)
if (!allowed) {
socket.end(
'HTTP/1.1 403 Forbidden\r\n' +
'X-Proxy-Error: blocked-by-allowlist\r\n' +
'\r\n' +
'Connection blocked by network allowlist'
)
return
}
// 决定上游路由: MITM代理 > 父代理 > 直连
const mitmSocketPath = options.getMitmSocketPath?.(hostname)
const parentUrl = !mitmSocketPath && options.parentProxy &&
!shouldBypassParentProxy(options.parentProxy, hostname)
? selectParentProxyUrl(options.parentProxy, { isHttps: true })
: undefined
let upstream: Socket
if (mitmSocketPath) {
// 路由到MITM代理进行流量检查
upstream = await openConnectTunnel({
dial: () => connect({ path: mitmSocketPath }),
readyEvent: 'connect',
destHost: hostname,
destPort: port,
})
} else if (parentUrl) {
// 通过父代理隧道
upstream = await connectViaParentProxy(parentUrl, hostname, port)
} else {
// 直连
upstream = await dialDirect(hostname, port)
}
// 建立双向数据流
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length) upstream.write(head) // 转发CONNECT请求中的数据
upstream.pipe(socket)
socket.pipe(upstream)
})
// ========== 处理普通HTTP请求 ==========
server.on('request', async (req, res) => {
const url = new URL(req.url!)
const hostname = stripBrackets(url.hostname)
const port = url.port ? parseInt(url.port, 10) :
url.protocol === 'https:' ? 443 : 80
// 🔒 域名过滤检查
const allowed = await options.filter(port, hostname, req.socket)
if (!allowed) {
res.writeHead(403, { 'X-Proxy-Error': 'blocked-by-allowlist' })
res.end('Connection blocked by network allowlist')
return
}
// 重构绝对URI,防止URL解析器差异绕过
const absUrl = `${url.protocol}//${url.host}${url.pathname}${url.search}`
// 决定上游路由
const mitmSocketPath = options.getMitmSocketPath?.(hostname)
let proxyReq
if (mitmSocketPath) {
// 通过MITM代理
const mitmAgent = new Agent({ socketPath: mitmSocketPath })
proxyReq = httpRequest({
agent: mitmAgent,
path: absUrl,
method: req.method,
headers: fwdHeaders,
}, proxyRes => {
res.writeHead(proxyRes.statusCode!, stripHopByHop(proxyRes.headers))
proxyRes.pipe(res)
})
} else {
// 直连或通过父代理
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest
proxyReq = requestFn({
hostname,
port,
path: url.pathname + url.search,
method: req.method,
headers: fwdHeaders,
}, proxyRes => {
res.writeHead(proxyRes.statusCode!, stripHopByHop(proxyRes.headers))
proxyRes.pipe(res)
})
}
req.pipe(proxyReq)
})
return server
}
6.2 SOCKS5代理实现
源码位置: socks-proxy.ts
// 文件: src/sandbox/socks-proxy.ts
export function createSocksProxyServer(options: SocksProxyServerOptions): SocksProxyWrapper {
const socksServer = createServer()
// ========== 规则验证器 (连接前检查) ==========
socksServer.setRulesetValidator(async conn => {
const hostname = conn.destAddress
const port = conn.destPort
// 🛡️ 安全检查: SOCKS5 DOMAINNAME是原始字节串,零验证
// 拒绝控制字符(空字节、CRLF),防止绕过允许列表匹配
if (!isValidHost(hostname)) {
logForDebugging(`Rejecting malformed SOCKS host: ${JSON.stringify(hostname)}`)
return false
}
// 🔒 域名过滤检查
const allowed = await options.filter(port, hostname)
return allowed
})
// ========== 连接处理器 (建立TCP隧道) ==========
socksServer.setConnectionHandler((conn, sendStatus) => {
const host = conn.destAddress
const port = conn.destPort
// SOCKS是不透明TCP隧道,语义等同于HTTP CONNECT
// 总是优先使用HTTPS_PROXY
const parentUrl = options.parentProxy &&
!shouldBypassParentProxy(options.parentProxy, host)
? selectParentProxyUrl(options.parentProxy, { isHttps: true })
: undefined
const open = parentUrl
? connectViaParentProxy(parentUrl, host, port)
: dialDirect(host, port)
open.then(upstream => {
sendStatus('REQUEST_GRANTED') // SOCKS5成功响应
upstream.pipe(conn.socket) // 双向数据转发
conn.socket.pipe(upstream)
}).catch(err => {
if (!clientGone) {
sendStatus('HOST_UNREACHABLE')
}
})
})
return { server: socksServer, getPort, listen, close, unref }
}
七、Seccomp BPF过滤器生成
7.1 架构支持检测
源码位置: generate-seccomp-filter.ts
// 文件: src/sandbox/generate-seccomp-filter.ts
/**
* 映射Node.js process.arch到vendor目录架构名称
* 返回null表示不支持的架构
*/
function getVendorArchitecture(): string | null {
const arch = process.arch
switch (arch) {
case 'x64':
case 'x86_64':
return 'x64'
case 'arm64':
case 'aarch64':
return 'arm64'
case 'ia32':
case 'x86':
// ❌ 不支持32位x86
// 原因: socketcall()系统调用无法被当前seccomp过滤器处理
// 在32位x86上,所有socket操作都通过socketcall()多路复用
// 需要复杂的BPF逻辑检查socketcall的子函数参数
logForDebugging(
`[SeccompFilter] 32-bit x86 (ia32) not supported - missing socketcall() blocking`
)
return null
default:
logForDebugging(`[SeccompFilter] Unsupported architecture: ${arch}`)
return null
}
}
7.2 二进制文件查找
/**
* 获取apply-seccomp二进制文件路径
* 预编译的BPF过滤器按架构组织:
* - vendor/seccomp/{x64,arm64}/apply-seccomp
*/
export function getApplySeccompBinaryPath(seccompBinaryPath?: string): string | null {
// 0. 检查显式指定路径
if (seccompBinaryPath && fs.existsSync(seccompBinaryPath)) {
return seccompBinaryPath
}
const arch = getVendorArchitecture()
if (!arch) return null
// 1-3. 检查本地路径 (bundled/package/dist)
for (const binaryPath of getLocalSeccompPaths('apply-seccomp')) {
if (fs.existsSync(binaryPath)) {
return binaryPath
}
}
// 4. 回退: 检查全局npm安装 (用于原生构建)
for (const globalBase of getGlobalNpmPaths()) {
const binaryPath = join(globalBase, 'vendor', 'seccomp', arch, 'apply-seccomp')
if (fs.existsSync(binaryPath)) {
return binaryPath
}
}
return null
}
八、挂载点清理机制 (Linux特有)
8.1 问题背景
源码位置: linux-sandbox-utils.ts
// 文件: src/sandbox/linux-sandbox-utils.ts
/**
* 跟踪bwrap为非存在deny路径创建的挂载点
*
* 问题: bwrap执行 --ro-bind /dev/null /nonexistent/path 时
* 会在主机文件系统创建空文件作为挂载点
* 这些文件在bwrap退出后仍然存在
*/
const bwrapMountPoints: Set<string> = new Set()
/**
* 活动沙箱计数器
* 延迟清理策略: 确保没有运行中的沙箱使用这些挂载点
* 防止删除挂载点导致运行中沙箱的bind mount失效
*/
let activeSandboxCount = 0
8.2 延迟清理实现
/**
* 清理bwrap创建的挂载点文件
*
* 每次调用递减activeSandboxCount
* 只有计数器归零时才真正删除文件
*/
export function cleanupBwrapMountPoints(opts?: { force?: boolean }): void {
if (!opts?.force) {
if (activeSandboxCount > 0) {
activeSandboxCount--
}
// 延迟清理: 还有活动沙箱
if (activeSandboxCount > 0) {
logForDebugging(
`[Sandbox Linux] Deferring cleanup — ${activeSandboxCount} sandbox(es) active`
)
return
}
} else {
activeSandboxCount = 0 // 强制清理
}
// 清理所有挂载点
for (const mountPoint of bwrapMountPoints) {
try {
const stat = fs.statSync(mountPoint)
// 只删除bwrap创建的空文件
if (stat.isFile() && stat.size === 0) {
fs.unlinkSync(mountPoint)
logForDebugging(`Cleaned up bwrap mount point: ${mountPoint}`)
} else if (stat.isDirectory()) {
// 空目录挂载点
const entries = fs.readdirSync(mountPoint)
if (entries.length === 0) {
fs.rmdirSync(mountPoint)
}
}
} catch {
// 忽略清理错误
}
}
bwrapMountPoints.clear()
}
九、Git工作树支持
9.1 问题检测
源码位置: linux-sandbox-utils.ts
/**
* 检查路径的任何现有组件是否是文件(非目录)
*
* Git工作树场景:
* - .git是文件 (内容: gitdir: /path/.../.git/worktrees/xxx)
* - .git/hooks永远不可能存在
* - 在文件上挂载目录会导致bwrap失败
*/
function hasFileAncestor(targetPath: string): boolean {
const parts = targetPath.split(path.sep)
let currentPath = ''
for (const part of parts) {
if (!part) continue
const nextPath = currentPath + path.sep + part
try {
const stat = fs.statSync(nextPath)
if (stat.isFile() || stat.isSymbolicLink()) {
return true // 发现文件祖先,路径无法创建
}
} catch {
break // 路径不存在
}
currentPath = nextPath
}
return false
}
9.2 应用逻辑
// 在linuxGetMandatoryDenyPaths函数中
const dotGitPath = path.resolve(cwd, '.git')
let dotGitIsDirectory = false
try {
dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory()
} catch {
// .git不存在
}
if (dotGitIsDirectory) {
// 标准git仓库: 保护.git/hooks和.git/config
denyPaths.push(path.resolve(cwd, '.git/hooks'))
if (!allowGitConfig) {
denyPaths.push(path.resolve(cwd, '.git/config'))
}
} else {
// Git工作树或无git: 不保护.git路径
// 避免在文件上尝试挂载目录
}
十、容器环境适配
10.1 Docker环境检测
源码位置: linux-sandbox-utils.ts
// 文件: src/sandbox/linux-sandbox-utils.ts
// ========== PID命名空间隔离 ==========
bwrapArgs.push('--unshare-pid')
if (!enableWeakerNestedSandbox) {
// 安全模式: 挂载新的/proc
bwrapArgs.push('--proc', '/proc')
} else {
// 🐳 Docker容器弱沙箱模式
//
// 问题:
// - 容器通常没有CAP_SYS_ADMIN
// - 但EUID=0导致bwrap尝试直接clone()而非创建user namespace
// - 结果: EPERM错误
//
// 解决方案:
// - --unshare-user: 强制使用user namespace路径
// - --bind /proc /proc: apply-seccomp需要写/proc/self/setgroups
bwrapArgs.push('--unshare-user', '--bind', '/proc', '/proc')
}
十一、配置示例
11.1 严格模式 (零信任)
const strictConfig: SandboxRuntimeConfig = {
network: {
allowedDomains: [], // 无网络访问
deniedDomains: [],
allowLocalBinding: false,
allowAllUnixSockets: false,
},
filesystem: {
denyRead: ['/'], // 拒绝所有读取
allowRead: ['/usr', '/bin', process.cwd()], // 允许系统和CWD
allowWrite: [process.cwd()], // 只允许写入CWD
denyWrite: ['.git', 'node_modules'],
allowGitConfig: false,
},
}
11.2 开发模式
const devConfig: SandboxRuntimeConfig = {
network: {
allowedDomains: ['registry.npmjs.org', '*.github.com'],
allowLocalBinding: true, // 允许本地开发服务器
allowUnixSockets: ['/var/run/docker.sock'],
},
filesystem: {
denyRead: ['~/.ssh', '~/.aws'],
allowWrite: [process.cwd(), '/tmp', '~/.npm'],
denyWrite: ['.git/hooks'],
allowGitConfig: true,
},
allowPty: true, // 允许伪终端
}
十二、核心包 sandbox-runtime 总结
| 特性 | 实现方案 | 源码位置 |
|---|---|---|
| 跨平台一致性 | 统一配置接口,底层适配bubblewrap(Linux)和Seatbelt(macOS) | sandbox-manager.ts |
| 嵌套命名空间 | apply-seccomp在socat之后应用seccomp,解决socket创建冲突 | linux-sandbox-utils.ts#L614-L642 |
| 非存在路径保护 | 预创建/dev/null挂载点阻止未来攻击 | linux-sandbox-utils.ts#L767-L824 |
| 符号链接追踪 | isSymlinkOutsideBoundary防止symlink绕过 | sandbox-utils.ts#L89-L195 |
| 延迟挂载点清理 | activeSandboxCount计数器防止并发竞态 | linux-sandbox-utils.ts#L331-L372 |
| 智能路径展开 | Linux上expandGlobPattern自动展开glob | sandbox-utils.ts#L467-L521 |
| CRLF注入防护 | isValidHost严格验证主机名 | parent-proxy.ts#L449-L458 |
| Git工作树感知 | hasFileAncestor检测.git是文件还是目录 | linux-sandbox-utils.ts#L108-L129 |
| 主机名规范化 | canonicalizeHost统一格式防止绕过 | parent-proxy.ts#L471-L483 |
| NO_PROXY兼容 | parseNoProxy实现curl/golang语义 | parent-proxy.ts#L115-L180 |
这些特性在 Claude Code 中都得到了实际工程应用,具体表现如下:
1️⃣ 跨平台一致性 - 统一配置接口
源码位置: src/utils/sandbox/sandbox-adapter.ts
// 将 Claude Code 设置转换为 sandbox-runtime 统一配置格式
// 屏蔽了底层平台差异(Linux bubblewrap vs macOS sandbox-exec)
export function convertToSandboxRuntimeConfig(settings: SettingsJson): SandboxRuntimeConfig {
return {
network: {
allowedDomains, // 允许的网络域名
deniedDomains, // 拒绝的网络域名
allowUnixSockets, // Unix socket 白名单
},
filesystem: {
denyRead, // 拒绝读取的路径
allowRead, // 允许读取的路径
allowWrite, // 允许写入的路径
denyWrite, // 拒绝写入的路径
},
ripgrep: ripgrepConfig, // 沙箱内搜索工具配置
}
}
工程价值: 上层代码无需关心平台差异,一套配置同时支持 macOS (Seatbelt) 和 Linux (bubblewrap)
2️⃣ 嵌套命名空间 - apply-seccomp 分层隔离
依赖检查: src/components/sandbox/SandboxDependenciesTab.tsx
// Linux/WSL2 需要三个关键依赖
<Text>bubblewrap (bwrap): {bwrapMissing ? '未安装' : '已安装'}</Text>
<Text>seccomp filter: {seccompMissing ? '未安装' : '已安装'}</Text>
<Text>socat: {socatMissing ? '未安装' : '已安装'}</Text>
隔离架构:
bwrap (Stage 1) - 网络命名空间隔离
└─ socat 桥接进程 - 可创建 Unix socket
└─ apply-seccomp (Stage 2) - 系统调用过滤
└─ 用户代码 - 无法创建新 socket,但可用继承的 socket FD
精妙之处: 用户代码无法创建新 Unix socket(防止恶意通信),但可以使用 Stage 1 创建的 socket 与代理通信(保证正常网络功能)
3️⃣ 非存在路径保护 - /dev/null 挂载点
源码位置: src/utils/sandbox/sandbox-adapter.ts:257-280
// SECURITY: 防止 Git 沙箱逃逸攻击
// 攻击者可以植入 HEAD + objects/ + refs/ 让 Claude 的 git 误判为裸仓库
const bareGitRepoFiles = ['HEAD', 'objects', 'refs', 'hooks', 'config']
for (const gitFile of bareGitRepoFiles) {
const p = resolve(dir, gitFile)
try {
statSync(p)
denyWrite.push(p) // 文件存在:沙箱内只读绑定
} catch {
bareGitRepoScrubPaths.push(p) // 文件不存在:命令后清理
}
}
// 命令执行后立即清理植入的文件
function scrubBareGitRepoFiles(): void {
for (const p of bareGitRepoScrubPaths) {
try {
rmSync(p, { recursive: true }) // 在沙箱外 git 运行前清除
} catch { /* ENOENT 是预期情况 */ }
}
}
工程价值: 防御 CVE 级别的 Git 沙箱逃逸攻击,双重保护(沙箱内挂载 + 沙箱外清理)
4️⃣ 符号链接追踪 - 防止 symlink 绕过
源码位置: src/utils/permissions/filesystem.ts:272-365
/**
* 获取权限检查时需要验证的所有路径
* 包括原始路径和所有符号链接解析路径
*/
export function getPathsForPermissionCheck(inputPath: string): string[] {
const pathSet = new Set<string>()
pathSet.add(path) // 总是检查原始路径
// 追踪符号链接链,收集所有中间目标
let currentPath = path
const visited = new Set<string>()
const maxDepth = 40 // 防止循环链接
for (let depth = 0; depth < maxDepth; depth++) {
if (visited.has(currentPath)) break
visited.add(currentPath)
const stats = fsImpl.lstatSync(currentPath)
if (!stats.isSymbolicLink()) break
const target = fsImpl.readlinkSync(currentPath)
const absoluteTarget = nodePath.isAbsolute(target)
? target
: nodePath.resolve(nodePath.dirname(currentPath), target)
pathSet.add(absoluteTarget) // 添加中间目标到检查列表
currentPath = absoluteTarget
}
return Array.from(pathSet)
}
使用场景:
// 检查原始路径和所有 symlink 解析路径是否都在允许的工作目录内
const pathsToCheck = getPathsForPermissionCheck(filePath)
return pathsToCheck.every(p => pathInAllowedWorkingPath(p))
防止的攻击: ./evil.txt -> /etc/passwd 或 ./data -> /var/www/html
5️⃣ 延迟挂载点清理 - 并发安全保护
源码位置: src/utils/sandbox/sandbox-adapter.ts:730-792
let initializationPromise: Promise<void> | undefined
async function initialize(): Promise<void> {
// 防止并发初始化的竞态条件
if (initializationPromise) {
return initializationPromise // 返回现有的 promise
}
// 同步创建 promise(在任何 await 之前)
// 防止 wrapWithSandbox() 在 promise 赋值前被调用
initializationPromise = (async () => {
try {
// 一次性检测 git worktree(会话期间缓存)
if (worktreeMainRepoPath === undefined) {
worktreeMainRepoPath = await detectWorktreeMainRepoPath(getCwdState())
}
await BaseSandboxManager.initialize(runtimeConfig, wrappedCallback)
// 订阅设置变更以动态更新沙箱配置
settingsSubscriptionCleanup = settingsChangeDetector.subscribe(() => {
BaseSandboxManager.updateConfig(newConfig)
})
} catch (error) {
initializationPromise = undefined // 失败时允许重试
}
})()
return initializationPromise
}
工程价值: 防止多个命令并发初始化沙箱导致的状态混乱
6️⃣ 智能路径展开 - Linux glob 警告
源码位置: src/utils/sandbox/sandbox-adapter.ts:597-607
/**
* Linux/WSL 上 bubblewrap 不支持 glob 模式
* 返回警告让用户知道哪些规则可能无法正常工作
*/
function getLinuxGlobPatternWarnings(): string[] {
if (platform !== 'linux' && platform !== 'wsl') return []
if (!settings?.sandbox?.enabled) return []
const hasGlobs = (path: string): boolean => {
const stripped = path.replace(/\/\*\*$/, '')
return /[*?[\]]/.test(stripped) // 检测 glob 字符
}
// 检查所有权限规则中的 glob 模式
for (const ruleString of [...permissions.allow, ...permissions.deny]) {
if (hasGlobs(rule.ruleContent)) {
warnings.push(ruleString)
}
}
return warnings
}
工程适配: macOS 的 Seatbelt 原生支持 glob,Linux 的 bubblewrap 不支持,所以给出警告而不是静默失败
7️⃣ CRLF 注入防护 - 主机名验证
源码位置: src/utils/proxy.ts 和 src/utils/managedEnvConstants.ts
/**
* 获取 NO_PROXY 环境变量
* 优先小写(no_proxy > NO_PROXY)
*/
export function getNoProxy(env = process.env): string | undefined {
return env.no_proxy || env.NO_PROXY
}
/**
* 检查 URL 是否应该绕过代理(支持 curl/golang 语义)
* - 精确主机名匹配: "localhost"
* - 域名后缀匹配: ".example.com"
* - 通配符匹配: "*.example.com"
* - IP 地址: "127.0.0.1"
*/
export function shouldBypassProxy(urlString: string, noProxy = getNoProxy()): boolean {
// 实现 curl/golang 的 NO_PROXY 解析语义
// 防止恶意域名绕过代理检查
}
防护场景: 防止 evil.com\x00.allowed.com 这样的控制字符注入攻击
8️⃣ Git 工作树感知 - 智能路径检测
源码位置: src/utils/sandbox/sandbox-adapter.ts:422-445
/**
* 检测 cwd 是否是 git worktree 并解析主仓库路径
* 在 worktree 中,.git 是文件(不是目录),包含 "gitdir: ..."
*/
async function detectWorktreeMainRepoPath(cwd: string): Promise<string | null> {
const gitPath = join(cwd, '.git')
try {
const gitContent = await readFile(gitPath, { encoding: 'utf8' })
const gitdirMatch = gitContent.match(/^gitdir:\s*(.+)$/m)
if (!gitdirMatch?.[1]) return null
// 解析 gitdir 路径
const gitdir = resolve(cwd, gitdirMatch[1].trim())
// 格式: /path/to/main/repo/.git/worktrees/worktree-name
const marker = `${sep}.git${sep}worktrees${sep}`
const markerIndex = gitdir.lastIndexOf(marker)
if (markerIndex > 0) {
return gitdir.substring(0, markerIndex) // 返回主仓库路径
}
return null
} catch {
return null // .git 是目录(不是 worktree)
}
}
工程应用:
// 在 worktree 中,git 操作需要写入主仓库的 .git 目录
if (worktreeMainRepoPath && worktreeMainRepoPath !== cwd) {
allowWrite.push(worktreeMainRepoPath) // 允许写入主仓库
}
解决的问题: Worktree 模式下 .git 是指向主仓库的文件,沙箱需要允许写入主仓库的 .git 目录(index.lock 等)
9️⃣ 主机名规范化 - 防止绕过
虽然 Claude Code 中没有直接的 canonicalizeHost 函数,但在代理配置中实现了类似的防护:
// src/upstreamproxy/upstreamproxy.ts
const NO_PROXY_LIST = [
'localhost',
'127.0.0.1',
'::1',
'*.anthropic.com', // Bun, curl, Go (glob 匹配)
'.anthropic.com', // Python urllib/httpx (后缀匹配)
'anthropic.com', // apex 域名后备
]
// 三种形式因为 NO_PROXY 解析在不同运行时不同
// 防止通过不同格式绕过代理规则
🔟 NO_PROXY 兼容 - curl/golang 语义
源码位置: src/utils/proxy.ts:88-161
/**
* 检查 URL 是否应该绕过代理
* 支持 curl/golang 的 NO_PROXY 语义
*/
export function shouldBypassProxy(urlString: string, noProxy = getNoProxy()): boolean {
const { hostname } = new URL(urlString)
const noProxyList = (noProxy || '').split(',').map(s => s.trim())
for (const rule of noProxyList) {
// 精确匹配
if (rule === hostname) return true
// 域名后缀匹配 (.example.com)
if (rule.startsWith('.') && hostname.endsWith(rule)) return true
// 通配符匹配 (*.example.com)
if (rule.startsWith('*.')) {
const suffix = rule.slice(1)
if (hostname.endsWith(suffix)) return true
}
}
return false
}
工程价值: 兼容 curl、golang、python 等不同运行时的 NO_PROXY 解析差异,防止配置不一致导致的安全漏洞
📊 总结:
| 特性 | 实现位置 | 工程价值 |
|---|---|---|
| 跨平台一致性 | sandbox-adapter.ts |
统一接口屏蔽平台差异 |
| 嵌套命名空间 | SandboxDependenciesTab.tsx |
分层隔离,安全与功能兼顾 |
| 非存在路径保护 | sandbox-adapter.ts:257-280 |
防御 Git 沙箱逃逸 |
| 符号链接追踪 | filesystem.ts:272-365 |
防止 symlink 逃逸攻击 |
| 延迟清理 | sandbox-adapter.ts:730-792 |
并发安全,防止竞态条件 |
| 智能路径展开 | sandbox-adapter.ts:597-607 |
Linux 上给出 glob 警告 |
| CRLF 防护 | proxy.ts |
防止控制字符注入 |
| Git 工作树感知 | sandbox-adapter.ts:422-445 |
正确支持 worktree 模式 |
| 主机名规范化 | upstreamproxy.ts |
防止格式绕过 |
| NO_PROXY 兼容 | proxy.ts:88-161 |
跨运行时兼容性 |
接下来具体深入看看Claude Code 中集成的沙箱系统架构
Claude Code 中集成的沙箱系统架构
核心组件
┌───────────────────────────────────────────────────────────
Claude Code 应用层
├───────────────────────────────────────────────────────────
│ 沙箱适配器层 (sandbox-adapter.ts)
│ - 设置转换
│ - 权限规则解析
│ - 生命周期管理
├───────────────────────────────────────────────────────────
│ @anthropic-ai/sandbox-runtime
│ - 底层沙箱实现
│ - 平台特定隔离
├───────────────────────────────────────────────────────────
│ 操作系统层
│ - macOS: 系统沙箱
│ - Linux/WSL2: bubblewrap + seccomp
└───────────────────────────────────────────────────────────
核心组成文件
- 主要沙箱适配器:
- src/utils/sandbox/sandbox-adapter.ts - 核心沙箱管理器,封装了 @anthropic-ai/sandbox-runtime 包,提供 Claude CLI 特定的集成功能
- 沙箱 UI 组件:
- 沙箱命令和类型:
- src/commands/sandbox-toggle/sandbox-toggle.tsx -
/sandbox命令实现 - src/entrypoints/sandboxTypes.ts - 沙箱配置类型定义
- src/utils/sandbox/sandbox-ui-utils.ts - 沙箱违规 UI 工具
- src/commands/sandbox-toggle/sandbox-toggle.tsx -
- 沙箱决策逻辑:
- src/tools/BashTool/shouldUseSandbox.ts - 决定是否使用沙箱执行命令
核心沙箱管理器
sandbox-adapter.ts 是一个适配层(Adapter Layer),是把 Anthropic 官方的 @anthropic-ai/sandbox-runtime 包包装成 Claude CLI 专用的版本。简单说就是:给官方沙箱穿上 Claude CLI 的"外套",让它能识别 Claude 的配置系统、工具集成和特殊功能。
第一部分:导入依赖(第 1-30 行)
// 从官方沙箱包导入类型和基础类
import type { ...各种配置类型 } from '@anthropic-ai/sandbox-runtime'
import {
SandboxManager as BaseSandboxManager, // 官方基础沙箱管理器
SandboxRuntimeConfigSchema, // 配置校验 Schema
SandboxViolationStore, // 违规记录存储
} from '@anthropic-ai/sandbox-runtime'
基础沙箱引擎和类型定义,准备在此基础上加工。
import { rmSync, statSync } from 'fs' // 同步文件操作(删除、状态检查)
import { readFile } from 'fs/promises' // 异步读文件
import { memoize } from 'lodash-es' // 缓存函数结果(避免重复计算)
import { join, resolve, sep } from 'path' // 路径处理工具
文件操作、路径拼接、缓存优化的基础设施。
// Claude CLI 内部的各种工具函数
import { getAdditionalDirectoriesForClaudeMd, ... } from '../../bootstrap/state.js'
import { logForDebugging } from '../debug.js'
import { expandPath } from '../path.js'
import { getPlatform, type Platform } from '../platform.js'
// ... 各种 settings 相关的导入
其他模块的状态管理、调试日志、平台检测、配置系统等功能,准备和沙箱做整合。
第二部分:工具名称常量 & 权限规则解析(第 32-58 行)
import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js'
import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js'
// ... 其他工具名称
定义 Claude 里各种工具的名字——Bash 命令、文件编辑、文件读取、网页抓取等,后面要根据这些名字做权限判断。
// 本地副本避免循环依赖
function permissionRuleValueFromString(ruleString: string): PermissionRuleValue {
const matches = ruleString.match(/^([^(]+)\(([^)]+)$/)
if (!matches) {
return { toolName: ruleString } // 没有括号,整个字符串就是工具名
}
// 解析出 "工具名(规则内容)" 的格式
const toolName = matches[1]
const ruleContent = matches[2]
return { toolName, ruleContent }
}
解析权限规则的字符串格式。比如 WebFetch(domain:example.com) 会被拆成:
-
工具名:
WebFetch -
规则内容:
domain:example.com
function permissionRuleExtractPrefix(permissionRule: string): string | null {
const match = permissionRule.match(/^(.+):\*$/)
return match?.[1] ?? null // 提取 "npm run test:*" 中的 "npm run test" 部分
}
处理带通配符的规则,把 command:* 里的 command 部分提取出来。
第三部分:路径解析核心逻辑(第 59-118 行)
resolvePathPatternForSandbox - 权限规则路径解析
export function resolvePathPatternForSandbox(pattern: string, source: SettingSource): string {
// 处理 // 前缀 - 绝对路径(从文件系统根目录开始)
if (pattern.startsWith('//')) {
return pattern.slice(1) // "//.aws/**" → "/.aws/**"
}
// 处理 / 前缀 - 相对于配置文件所在目录
// 注意:~/path 和普通相对路径交给官方沙箱处理
if (pattern.startsWith('/') && !pattern.startsWith('//')) {
const root = getSettingsRootPathForSource(source)
return resolve(root, pattern.slice(1)) // "/foo" → "${settings目录}/foo"
}
// 其他情况原样返回(~/path, ./path, path)
return pattern
}
Claude 的权限规则有特殊的路径语法:
-
//path→ 真正的绝对路径(从系统根目录开始) -
/path→ 相对配置文件的位置(不是系统根目录) -
~/path→ 用户主目录 -
./path或path→ 相对当前工作目录
关键区别:权限规则里的 /path 不是系统根目录,而是配置文件的目录!这是 Claude 特有的约定。
resolveSandboxFilesystemPath - 沙箱文件系统路径解析
export function resolveSandboxFilesystemPath(pattern: string, source: SettingSource): string {
// 兼容旧写法://path → /path
if (pattern.startsWith('//')) return pattern.slice(1)
// 使用标准路径扩展(支持 ~ 展开、相对路径解析)
return expandPath(pattern, getSettingsRootPathForSource(source))
}
sandbox.filesystem.* 设置项(如 allowWrite)使用标准路径语义:
-
/path= 真正的绝对路径(不像权限规则那样是相对配置目录) -
~/path= 展开为用户主目录 -
相对路径 = 相对于配置目录
这是修复 #30067 的关键:之前用户写 /Users/foo/.cargo 被错误当成相对路径,现在修复了,按绝对路径处理。
第四部分:托管域名/路径检查(第 119-140 行)
export function shouldAllowManagedSandboxDomainsOnly(): boolean {
return (
getSettingsForSource('policySettings')?.sandbox?.network?.allowManagedDomainsOnly === true
)
}
function shouldAllowManagedReadPathsOnly(): boolean {
return (
getSettingsForSource('policySettings')?.sandbox?.filesystem?.allowManagedReadPathsOnly === true
)
}
检查是否只允许"托管"的域名或读取路径。policySettings 是企业策略配置,优先级最高,IT 管理员可以用这个锁定沙箱行为。
第五部分:配置转换器(第 141-350 行)——核心逻辑
convertToSandboxRuntimeConfig - 把 Claude 配置转成官方沙箱配置
export function convertToSandboxRuntimeConfig(settings: SettingsJson): SandboxRuntimeConfig {
const permissions = settings.permissions || {}
负责把 Claude 的各种零散配置翻译成官方沙箱能理解的统一格式。
网络域名处理(第 148-185 行)
const allowedDomains: string[] = []
const deniedDomains: string[] = []
// 如果启用"仅托管域名",只从 policySettings 读取
if (shouldAllowManagedSandboxDomainsOnly()) {
const policySettings = getSettingsForSource('policySettings')
for (const domain of policySettings?.sandbox?.network?.allowedDomains || []) {
allowedDomains.push(domain)
}
// 也从 policySettings 的 permissions.allow 中提取 WebFetch 规则
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))
}
}
} else {
// 普通模式:从用户设置读取
for (const domain of settings.sandbox?.network?.allowedDomains || []) {
allowedDomains.push(domain)
}
// 从 permissions.allow 提取 WebFetch 域名规则
for (const ruleString of permissions.allow || []) {
// ... 同上逻辑
}
}
-
企业锁定模式:只允许 IT 管理员在
policySettings里指定的域名,用户自己设的不管用 -
普通模式:用户自己配置的白名单域名 + 权限规则里的
WebFetch(domain:xxx)都有效
// 处理拒绝域名(deny 列表)
for (const ruleString of permissions.deny || []) {
const rule = permissionRuleValueFromString(ruleString)
if (rule.toolName === WEB_FETCH_TOOL_NAME && rule.ruleContent?.startsWith('domain:')) {
deniedDomains.push(rule.ruleContent.substring('domain:'.length))
}
}
黑名单逻辑——permissions.deny 里的 WebFetch(domain:evil.com) 会被提取出来,禁止访问这些域名。
文件系统路径处理(第 186-300 行)
// 基础可写路径:当前目录 + Claude 临时目录
const allowWrite: string[] = ['.', getClaudeTempDir()]
const denyWrite: string[] = []
const denyRead: string[] = []
const allowRead: string[] = []
默认情况下,沙箱里的命令可以读写当前工作目录和Claude 的临时文件夹(用来放 cwd 跟踪文件等)。
安全加固 - 禁止写入配置文件(第 196-210 行)
// 永远禁止写入 settings.json 文件,防止沙箱逃逸
const settingsPaths = SETTING_SOURCES.map(source =>
getSettingsFilePathForSource(source)
).filter((p): p is string => p !== undefined)
denyWrite.push(...settingsPaths) // 所有来源的配置文件路径
denyWrite.push(getManagedSettingsDropInDir()) // 托管配置目录
沙箱里的命令绝对不能改 Claude 的配置文件,否则可能逃逸沙箱控制。把所有 settings.json 的路径都加入黑名单。
// 如果当前目录和原始启动目录不同,也禁止写入当前目录的配置文件
const cwd = getCwdState()
const originalCwd = getOriginalCwd()
if (cwd !== originalCwd) {
denyWrite.push(resolve(cwd, '.claude', 'settings.json'))
denyWrite.push(resolve(cwd, '.claude', 'settings.local.json'))
}
用户可能用 cd 切换了目录,新目录下的 Claude 配置也要保护起来。
保护 Skills 目录(第 214-222 行)
// 禁止写入 .claude/skills 目录(和 commands/agents 同等权限)
denyWrite.push(resolve(originalCwd, '.claude', 'skills'))
if (cwd !== originalCwd) {
denyWrite.push(resolve(cwd, '.claude', 'skills'))
}
Skills(技能文件)和 Commands/Agents 一样敏感,都是自动加载的高权限代码,必须保护。
Git 裸仓库攻击防护(第 224-250 行)——高级安全逻辑
// 安全漏洞修复:Git 的 is_git_directory() 会把包含 HEAD+objects+refs 的目录
// 当成裸仓库。攻击者可以植入这些文件 + 带 core.fsmonitor 的配置来逃逸沙箱。
bareGitRepoScrubPaths.length = 0 // 清空待清理列表
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)
}
}
}
防御特定攻击向量——
-
攻击者在当前目录放
HEAD、objects/、refs/等文件 -
Git 会误以为这是裸仓库(bare repo)
-
配合恶意
core.fsmonitor配置,可能让 Claude 的非沙箱化 Git 操作逃逸
对策:
-
如果文件已存在 → 沙箱里只读挂载(
denyWrite) -
如果文件不存在 → 沙箱命令执行后立即删除(
scrubBareGitRepoFiles),防止非沙箱化 Git 看到
Git Worktree 支持(第 252-258 行)
// 如果在 git worktree 中,需要允许写入主仓库的 .git 目录(用于 index.lock 等)
if (worktreeMainRepoPath && worktreeMainRepoPath !== cwd) {
allowWrite.push(worktreeMainRepoPath)
}
Git Worktree 模式下,工作目录和 .git 目录是分开的,需要特别允许写入主仓库的 .git 目录,否则 Git 操作会失败。
额外目录处理(第 260-268 行)
// 处理 --add-dir CLI 参数或 /add-dir 命令添加的目录
const additionalDirs = new Set([
...(settings.permissions?.additionalDirectories || []),
...getAdditionalDirectoriesForClaudeMd(),
])
allowWrite.push(...additionalDirs)
用户通过命令行 --add-dir 或 /add-dir 命令额外添加的目录,也要加入白名单,让沙箱内的 Bash 命令能访问。
遍历所有配置来源解析权限规则(第 270-300 行)
for (const source of SETTING_SOURCES) {
const sourceSettings = getSettingsForSource(source)
// 从 permissions.allow/deny 提取文件操作规则
if (sourceSettings?.permissions) {
for (const ruleString of sourceSettings.permissions.allow || []) {
const rule = permissionRuleValueFromString(ruleString)
if (rule.toolName === FILE_EDIT_TOOL_NAME && rule.ruleContent) {
allowWrite.push(resolvePathPatternForSandbox(rule.ruleContent, source))
}
}
// ... deny 规则同理
}
// 从 sandbox.filesystem.* 提取路径(使用标准路径语义)
const fs = sourceSettings?.sandbox?.filesystem
if (fs) {
for (const p of fs.allowWrite || []) {
allowWrite.push(resolveSandboxFilesystemPath(p, source))
}
// ... denyWrite, denyRead, allowRead 同理
}
}
Claude 的配置是分层的(local、project、policy 等),要遍历每个来源:
-
从
permissions.allow/deny提取Edit和Read规则(用权限规则路径语法) -
从
sandbox.filesystem.*提取路径(用标准路径语法)
注意:如果启用了 allowManagedReadPathsOnly,只有 policySettings 的 allowRead 才有效。
Ripgrep 配置(第 301-308 行)
const { rgPath, rgArgs, argv0 } = ripgrepCommand()
const ripgrepConfig = settings.sandbox?.ripgrep ?? {
command: rgPath,
args: rgArgs,
argv0,
}
沙箱内搜索代码需要 ripgrep,优先用用户配置,否则用 Claude 自带的。
返回完整配置(第 310-350 行)
return {
network: { allowedDomains, deniedDomains, ...各种网络选项 },
filesystem: { denyRead, allowRead, allowWrite, denyWrite },
ignoreViolations: settings.sandbox?.ignoreViolations,
enableWeakerNestedSandbox: settings.sandbox?.enableWeakerNestedSandbox,
enableWeakerNetworkIsolation: settings.sandbox?.enableWeakerNetworkIsolation,
ripgrep: ripgrepConfig,
}
}
把所有收集到的配置打包成官方沙箱能理解的格式,包括网络、文件系统、违规处理、嵌套沙箱、网络隔离、搜索工具等。
第六部分:状态管理与初始化(第 351-600 行)
关键状态变量
let initializationPromise: Promise<void> | undefined // 初始化状态锁
let settingsSubscriptionCleanup: (() => void) | undefined // 配置变更订阅清理函数
let worktreeMainRepoPath: string | null | undefined // Git Worktree 主仓库路径缓存
const bareGitRepoScrubPaths: string[] = [] // 待清理的裸仓库文件路径
这些变量记住沙箱的各种状态——是否在初始化、如何清理监听、Git 仓库位置、需要清理的攻击文件等。
清理裸仓库文件(第 368-380 行)
function scrubBareGitRepoFiles(): void {
for (const p of bareGitRepoScrubPaths) {
try {
rmSync(p, { recursive: true }) // 同步删除
logForDebugging(`[Sandbox] scrubbed planted bare-repo file: ${p}`)
} catch {
// 文件不存在是正常的(大部分情况下都没被植入)
}
}
}
每次沙箱命令执行后,检查并删除可能被植入的裸仓库文件,防止后续非沙箱化操作被攻击。
检测 Git Worktree(第 382-408 行)
async function detectWorktreeMainRepoPath(cwd: string): Promise<string | null> {
const gitPath = join(cwd, '.git')
try {
const gitContent = await readFile(gitPath, { encoding: 'utf8' })
// .git 文件内容格式:gitdir: /path/to/main/repo/.git/worktrees/name
const gitdirMatch = gitContent.match(/^gitdir:\s*(.+)$/m)
if (!gitdirMatch?.[1]) return null
const gitdir = resolve(cwd, gitdirMatch[1].trim())
// 从路径中提取主仓库目录:/.git/worktrees/ 之前的部分
const marker = `${sep}.git${sep}worktrees${sep}`
const markerIndex = gitdir.lastIndexOf(marker)
if (markerIndex > 0) {
return gitdir.substring(0, markerIndex)
}
return null
} catch {
// .git 是目录(普通仓库)或无法读取,说明不是 worktree
return null
}
}
检测是否在 Git Worktree 中——Worktree 的 .git 是文件而不是目录,里面指向主仓库的位置。解析这个路径,让沙箱知道主仓库在哪里。
依赖检查与平台支持(第 410-440 行)
const checkDependencies = memoize((): SandboxDependencyCheck => {
const { rgPath, rgArgs } = ripgrepCommand()
return BaseSandboxManager.checkDependencies({ command: rgPath, args: rgArgs })
})
const isSupportedPlatform = memoize((): boolean => {
return BaseSandboxManager.isSupportedPlatform() // 支持 macOS、Linux、WSL2+
})
用 memoize 缓存结果,避免重复检查。检查系统是否支持沙箱(需要 bubblewrap、socat 等工具),以及平台是否兼容(WSL1 不支持)。
各种配置读取函数(第 442-500 行)
function getSandboxEnabledSetting(): boolean {
try {
const settings = getSettings_DEPRECATED()
return settings?.sandbox?.enabled ?? false // 默认关闭
} catch (error) {
return false // 出错时保守处理,默认关闭
}
}
function isAutoAllowBashIfSandboxedEnabled(): boolean {
const settings = getSettings_DEPRECATED()
return settings?.sandbox?.autoAllowBashIfSandboxed ?? true // 默认开启
}
// ... 其他类似的配置读取函数
沙箱是否启用、是否自动允许沙箱化的 Bash、是否允许非沙箱命令、是否强制要求沙箱等。都有安全默认值。
平台白名单检查(第 501-530 行)
function isPlatformInEnabledList(): boolean {
try {
const settings = getInitialSettings()
const enabledPlatforms = settings?.sandbox?.enabledPlatforms
if (enabledPlatforms === undefined) return true // 未设置=全部允许
if (enabledPlatforms.length === 0) return false // 空数组=全部禁止
const currentPlatform = getPlatform()
return enabledPlatforms.includes(currentPlatform) // 检查当前平台是否在白名单
} catch (error) {
return true // 出错时默认允许
}
}
NVIDIA 提出的需求:只想在 macOS 上启用沙箱(因为 Linux/WSL 支持较新),可以设置 enabledPlatforms: ["macos"] 来限制。
沙箱启用综合判断(第 531-545 行)
function isSandboxingEnabled(): boolean {
if (!isSupportedPlatform()) return false // 平台不支持
if (checkDependencies().errors.length > 0) return false // 依赖缺失
if (!isPlatformInEnabledList()) return false // 平台不在白名单
return getSandboxEnabledSetting() // 用户是否显式开启
}
沙箱能不能用,平台支持、依赖齐全、平台白名单、用户开启。缺一不可。
沙箱不可用原因诊断(第 546-580 行)——重要安全功能
function getSandboxUnavailableReason(): string | undefined {
// 只有用户显式开启沙箱时才报告问题(避免对未使用沙箱的用户造成噪音)
if (!getSandboxEnabledSetting()) return undefined
if (!isSupportedPlatform()) {
const platform = getPlatform()
if (platform === 'wsl') {
return 'sandbox.enabled is set but WSL1 is not supported (requires WSL2)'
}
return `sandbox.enabled is set but ${platform} is not supported`
}
if (!isPlatformInEnabledList()) {
return `sandbox.enabled is set but ${getPlatform()} is not in sandbox.enabledPlatforms`
}
const deps = checkDependencies()
if (deps.errors.length > 0) {
const hint = platform === 'macos'
? 'run /sandbox or /doctor for details'
: 'install missing tools (e.g. apt install bubblewrap socat)'
return `sandbox.enabled is set but dependencies are missing: ${deps.errors.join(', ')} · ${hint}`
}
return undefined // 一切正常
}
修复 #34044 的关键——之前用户开了沙箱但依赖缺失时,系统静默失败(返回 false),用户以为自己在受保护的环境中,实际上完全没有!现在会明确告诉用户:"你开了沙箱但它跑不起来,原因是..."
Linux 通配符警告(第 581-620 行)
function getLinuxGlobPatternWarnings(): string[] {
const platform = getPlatform()
if (platform !== 'linux' && platform !== 'wsl') return [] // 只有 Linux/WSL 需要警告
// 检查权限规则中的通配符(Linux 的 bubblewrap 不完全支持)
const hasGlobs = (path: string): boolean => {
const stripped = path.replace(/\/\*\*$/, '') // 去掉末尾的 /**
return /[*?[\]]/.test(stripped) // 检查是否有 * ? [ ] 等通配符
}
// 遍历所有 allow/deny 规则,收集带通配符的文件操作规则
for (const ruleString of [...(permissions.allow || []), ...(permissions.deny || [])]) {
const rule = permissionRuleValueFromString(ruleString)
if ((rule.toolName === FILE_EDIT_TOOL_NAME || rule.toolName === FILE_READ_TOOL_NAME)
&& rule.ruleContent && hasGlobs(rule.ruleContent)) {
warnings.push(ruleString)
}
}
}
平台差异警告——Linux/WSL 使用 bubblewrap 做沙箱,它对通配符(globs)的支持不如 macOS 完整。如果用户配置了 Edit(/foo/bar_*.txt) 这样的规则,在 Linux 上可能行为不一致,需要提醒用户。
策略锁定检查(第 621-640 行)
function areSandboxSettingsLockedByPolicy(): boolean {
// 检查 flagSettings 或 policySettings 是否设置了沙箱相关配置
const overridingSources = ['flagSettings', 'policySettings'] as const
for (const source of overridingSources) {
const settings = getSettingsForSource(source)
if (settings?.sandbox?.enabled !== undefined ||
settings?.sandbox?.autoAllowBashIfSandboxed !== undefined ||
settings?.sandbox?.allowUnsandboxedCommands !== undefined) {
return true // 被高优先级配置锁定了
}
}
return false
}
检查沙箱设置是否被企业策略或命令行标志锁定。如果被锁定,用户在本地 settings.json 的修改不会生效,需要告知用户。
设置沙箱配置(第 641-665 行)
async function setSandboxSettings(options: { enabled?, autoAllowBashIfSandboxed?, allowUnsandboxedCommands? }): Promise<void> {
const existingSettings = getSettingsForSource('localSettings')
updateSettingsForSource('localSettings', {
sandbox: {
...existingSettings?.sandbox,
...(options.enabled !== undefined && { enabled: options.enabled }),
// ... 其他选项
}
})
}
提供接口让用户(或命令)修改沙箱设置,保存在本地配置中。
第七部分:核心功能函数(第 666-780 行)
wrapWithSandbox - 包装命令(第 676-690 行)
async function wrapWithSandbox(
command: string,
binShell?: string,
customConfig?: Partial<SandboxRuntimeConfig>,
abortSignal?: AbortSignal,
): Promise<string> {
// 如果沙箱启用,确保初始化完成
if (isSandboxingEnabled()) {
if (initializationPromise) {
await initializationPromise // 等待初始化完成
} else {
throw new Error('Sandbox failed to initialize.')
}
}
return BaseSandboxManager.wrapWithSandbox(command, binShell, customConfig, abortSignal)
}
核心包装函数——把普通 Shell 命令包装成沙箱内运行的命令。如果沙箱还没初始化好,会等待;如果初始化失败,报错。
initialize - 初始化沙箱(第 692-760 行)
async function initialize(sandboxAskCallback?: SandboxAskCallback): Promise<void> {
if (initializationPromise) return initializationPromise // 已在初始化或完成
if (!isSandboxingEnabled()) return // 沙箱未启用,跳过
// 包装回调,强制执行 allowManagedDomainsOnly 策略
const wrappedCallback = sandboxAskCallback
? async (hostPattern) => {
if (shouldAllowManagedSandboxDomainsOnly()) {
logForDebugging(`[sandbox] Blocked network request to ${hostPattern.host} (allowManagedDomainsOnly)`)
return false // 企业模式下直接拒绝,不询问
}
return sandboxAskCallback(hostPattern) // 正常询问用户
}
: undefined
// 同步创建 Promise,防止竞态条件
initializationPromise = (async () => {
try {
// 预检测 Git Worktree(只检测一次,缓存结果)
if (worktreeMainRepoPath === undefined) {
worktreeMainRepoPath = await detectWorktreeMainRepoPath(getCwdState())
}
const settings = getSettings_DEPRECATED()
const runtimeConfig = convertToSandboxRuntimeConfig(settings)
// 初始化官方沙箱管理器(macOS 自动启用日志监控)
await BaseSandboxManager.initialize(runtimeConfig, wrappedCallback)
// 订阅配置变更,动态更新沙箱配置
settingsSubscriptionCleanup = settingsChangeDetector.subscribe(() => {
const settings = getSettings_DEPRECATED()
const newConfig = convertToSandboxRuntimeConfig(settings)
BaseSandboxManager.updateConfig(newConfig)
logForDebugging('Sandbox configuration updated from settings change')
})
} catch (error) {
initializationPromise = undefined // 重置,允许重试
logForDebugging(`Failed to initialize sandbox: ${errorMessage(error)}`)
}
})()
return initializationPromise
}
初始化中枢——
-
防止重复初始化
-
企业模式下拦截所有网络询问
-
预检测 Git Worktree 状态
-
转换配置并启动官方沙箱
-
监听配置变更,实时更新沙箱规则(无需重启)
其他辅助函数(第 762-780 行)
function refreshConfig(): void {
if (!isSandboxingEnabled()) return
const settings = getSettings_DEPRECATED()
BaseSandboxManager.updateConfig(convertToSandboxRuntimeConfig(settings))
}
async function reset(): Promise<void> {
// 清理订阅、重置状态、清空缓存、重置官方沙箱
settingsSubscriptionCleanup?.()
settingsSubscriptionCleanup = undefined
worktreeMainRepoPath = undefined
bareGitRepoScrubPaths.length = 0
checkDependencies.cache.clear?.()
isSupportedPlatform.cache.clear?.()
initializationPromise = undefined
return BaseSandboxManager.reset()
}
refreshConfig 用于配置变更后手动刷新;reset 用于彻底重置沙箱状态(如测试场景)。
第八部分:排除命令管理(第 782-830 行)
export function addToExcludedCommands(command: string, permissionUpdates?): string {
const existingSettings = getSettingsForSource('localSettings')
const existingExcludedCommands = existingSettings?.sandbox?.excludedCommands || []
// 从权限建议中提取命令模式(如 "npm run test" 从 "npm run test:*")
let commandPattern: string = command
if (permissionUpdates) {
const bashSuggestions = permissionUpdates.filter(...)
if (bashSuggestions.length > 0) {
const firstBashRule = bashSuggestions[0]!.rules.find(...)
if (firstBashRule?.ruleContent) {
const prefix = permissionRuleExtractPrefix(firstBashRule.ruleContent)
commandPattern = prefix || firstBashRule.ruleContent
}
}
}
// 添加到排除列表(如果不存在)
if (!existingExcludedCommands.includes(commandPattern)) {
updateSettingsForSource('localSettings', {
sandbox: {
...existingSettings?.sandbox,
excludedCommands: [...existingExcludedCommands, commandPattern],
},
})
}
return commandPattern
}
智能排除——用户说"以后这个命令不用沙箱了",系统会:
-
检查权限建议,如果有 Bash 规则(如
Bash(npm run test:*)) -
提取通配符前缀(
npm run test)作为排除模式 -
保存到
excludedCommands列表,下次运行这个命令时跳过沙箱
第九部分:导出接口(第 832-930 行)
export interface ISandboxManager {
initialize(...): Promise<void>
isSupportedPlatform(): boolean
isPlatformInEnabledList(): boolean
getSandboxUnavailableReason(): string | undefined
isSandboxingEnabled(): boolean
// ... 20+ 个其他方法
}
export const SandboxManager: ISandboxManager = {
// 自定义实现
initialize,
isSandboxingEnabled,
isSandboxEnabledInSettings: getSandboxEnabledSetting,
isPlatformInEnabledList,
getSandboxUnavailableReason,
// ... 其他自定义方法
// 透传给官方沙箱管理器
getFsReadConfig: BaseSandboxManager.getFsReadConfig,
getFsWriteConfig: BaseSandboxManager.getFsWriteConfig,
getNetworkRestrictionConfig: BaseSandboxManager.getNetworkRestrictionConfig,
// ... 其他透传方法
// 特殊处理:清理时同时清理裸仓库文件
cleanupAfterCommand: (): void => {
BaseSandboxManager.cleanupAfterCommand()
scrubBareGitRepoFiles() // 额外的安全清理
},
}
定义完整的沙箱管理接口,实现适配器模式——
-
自定义方法:Claude CLI 特有的逻辑(配置转换、平台检查、策略锁定等)
-
透传方法:直接调用官方沙箱的功能(文件系统限制、网络限制等)
-
增强方法:如
cleanupAfterCommand在官方清理后追加裸仓库文件清理
核心沙箱管理器总结:
| 维度 | 说明 |
|---|---|
| 安全加固 | 多层防护:配置文件保护、Git 裸仓库攻击防护、企业策略锁定、显式不可用报告 |
| 配置融合 | 把 Claude CLI 的分层配置(local/project/policy/flag)翻译成官方沙箱格式 |
| 平台适配 | 处理 macOS/Linux/WSL 的差异(通配符支持、依赖检查、平台白名单) |
| 企业特性 | policySettings 托管模式、平台白名单、只读路径限制、域名白名单 |
| Git 集成 | Worktree 检测、裸仓库攻击防护、主仓库写入权限处理 |
| 动态更新 | 配置变更实时同步到沙箱,无需重启 |
沙箱 UI 组件
这是一个基于 React (Ink) 构建的终端界面沙箱配置系统,包含 5 个核心组件
SandboxSettings (主入口)
├── SandboxModeTab - 沙箱模式选择
├── SandboxOverridesTab - 覆盖策略配置
├── SandboxConfigTab - 沙箱配置展示
├── SandboxDependenciesTab - 依赖检查
└── SandboxDoctorSection - 诊断摘要
概括如下,具体看源码不再赘述。
SandboxSettings 作为主入口提供三态模式管理(auto-allow 自动回退、regular 严格沙箱、disabled 完全禁用),并根据依赖状态动态调整 Tab 展示(有错误时仅显示 Dependencies,有警告时全量展示,正常时显示 Mode+Overrides+Config);SandboxModeTab 负责模式选择与 Unix Socket 警告提示;SandboxOverridesTab 处理覆盖策略配置,支持 open(允许非沙箱回退)和 closed(严格模式)两种状态,并处理未启用、策略锁定等边界情况;SandboxConfigTab 以只读方式展示当前沙箱的完整配置,包括排除命令、文件系统读写限制、网络限制、Unix Socket 白名单及 Linux Glob 模式兼容性警告;SandboxDependenciesTab 实现平台差异化依赖检查,macOS 需 seatbelt+ripgrep,Linux 需 bubblewrap+socat+ripgrep+seccomp(可选),并提供对应安装命令;SandboxDoctorSection 作为诊断摘要入口,仅在支持平台且启用时展示健康状态,汇总错误警告并引导用户执行 /sandbox 命令获取帮助。
沙箱命令和类型
命令入口文件(sandbox-toggle.tsx),实现了 /sandbox 命令的逻辑,包含平台检测、依赖检查、权限验证和子命令处理四个核心环节。
1. 依赖导入
import { relative } from 'path';
import React from 'react';
import { getCwdState } from '../../bootstrap/state.js';
import { SandboxSettings } from '../../components/sandbox/SandboxSettings.js';
import { color } from '../../ink.js'; // 终端颜色输出
import { getPlatform } from '../../utils/platform.js';
import { addToExcludedCommands, SandboxManager } from '../../utils/sandbox/sandbox-adapter.js';
import { getSettings_DEPRECATED, getSettingsFilePathForSource } from '../../utils/settings/settings.js';
import type { ThemeName } from '../../utils/theme.js';
使用了 Ink(React for CLI)框架,说明这是一个命令行交互式应用。
2. 主入口函数
export async function call(
onDone: (result?: string) => void, // 完成回调
_context: unknown, // 未使用的上下文
args?: string, // 命令参数
): Promise<React.ReactNode | null>
3. 四层防御性检查(关键逻辑)
| 检查层级 | 函数 | 说明 |
|---|---|---|
| 平台支持 | isSupportedPlatform() |
拒绝 WSL1,仅支持 macOS/Linux/WSL2 |
| 企业策略 | isPlatformInEnabledList() |
通过 enabledPlatforms 配置白名单控制 |
| 权限锁定 | areSandboxSettingsLockedByPolicy() |
高优先级配置锁定本地修改 |
| 依赖状态 | checkDependencies() |
检查沙箱依赖(如 Docker、nsjail 等) |
// WSL1 特殊提示
const errorMessage = platform === 'wsl'
? 'Error: Sandboxing requires WSL2. WSL1 is not supported.'
: 'Error: Sandboxing is currently only supported on macOS, Linux, and WSL2.';
4. 交互模式 vs 命令模式
const trimmedArgs = args?.trim() || '';
// 无参数 → 启动交互式 TUI 菜单
if (!trimmedArgs) {
return <SandboxSettings onComplete={onDone} depCheck={depCheck} />;
}
// 有参数 → 解析子命令
const parts = trimmedArgs.split(' ');
const subcommand = parts[0];
5. 子命令实现:exclude
目前唯一实现的子命令,用于将特定命令模式加入沙箱排除列表:
if (subcommand === 'exclude') {
// 提取命令模式(支持引号包裹)
const commandPattern = trimmedArgs.slice('exclude '.length).trim();
const cleanPattern = commandPattern.replace(/^["']|["']$/g, ''); // 去引号
// 写入本地配置
addToExcludedCommands(cleanPattern);
// 生成相对路径反馈(提升 UX)
const localSettingsPath = getSettingsFilePathForSource('localSettings');
const relativePath = localSettingsPath
? relative(getCwdState(), localSettingsPath)
: '.claude/settings.local.json';
}
使用示例:
/sandbox exclude "npm run test:*"
/sandbox exclude 'git push origin main'
6. 配置层级设计
从代码中可推断配置优先级(高 → 低):
企业策略配置 (locked by policy)
↓
enabledPlatforms 白名单
↓
本地设置 (.claude/settings.local.json) ← exclude 命令写入这里
↓
用户级设置
sandboxTypes.ts
src/entrypoints/sandboxTypes.ts 是一个 Claude Code Agent SDK 的沙箱配置类型定义文件,使用了 Zod v4 进行运行时类型校验。
SandboxSettings (主配置)
├── Network 配置 (网络访问控制)
├── Filesystem 配置 (文件系统权限)
└── 其他安全/行为开关
1. 网络配置 (SandboxNetworkConfig)
| 字段 | 用途 |
|---|---|
allowedDomains |
允许访问的域名白名单 |
allowManagedDomainsOnly |
强制仅使用托管设置中的域名(忽略用户/项目级配置) |
allowUnixSockets / allowAllUnixSockets |
Unix Socket 访问控制(macOS 专用) |
allowLocalBinding |
是否允许本地端口绑定 |
httpProxyPort / socksProxyPort |
代理端口配置 |
2. 文件系统配置 (SandboxFilesystemConfig)
| 字段 | 用途 |
|---|---|
allowWrite / denyWrite |
写入权限的黑白名单 |
allowRead / denyRead |
读取权限的黑白名单 |
allowManagedReadPathsOnly |
强制仅使用托管策略中的读取路径 |
注意:allowRead 在 denyRead 区域内具有更高优先级(可覆盖拒绝规则)。
3. 核心安全开关
| 开关 | 说明 |
|---|---|
enabled |
总开关 |
failIfUnavailable |
沙箱启动失败时是否直接退出(而非降级到无沙箱运行) |
autoAllowBashIfSandboxed |
沙箱启用时自动允许 bash 执行 |
allowUnsandboxedCommands |
是否允许通过 dangerouslyDisableSandbox 参数绕过沙箱 |
enableWeakerNestedSandbox |
允许较弱的嵌套沙箱 |
enableWeakerNetworkIsolation |
macOS 专用:允许访问 trustd 服务(降低安全性,用于 Go 工具链的 TLS 证书验证) |
4. 特殊配置
-
excludedCommands:排除特定命令的沙箱限制 -
ripgrep:自定义 ripgrep 命令路径和参数 -
ignoreViolations:按命令记录可忽略的违规类型(Record<string, string[]>)
shouldUseSandbox.ts
src/tools/BashTool/shouldUseSandbox.ts是一个关于 Bash 命令沙箱(Sandbox)执行策略 的 TypeScript 模块,主要功能是决定是否对用户的命令启用沙箱隔离。
这个模块决定了何时将 bash 命令放入沙箱执行,何时允许直接执行。它包含两个主要部分:
-
containsExcludedCommand- 检查命令是否被用户排除在沙箱之外 -
shouldUseSandbox- 综合判断是否启用沙箱
1. 动态命令禁用(仅针对 'ant' 用户类型)
if (process.env.USER_TYPE === 'ant') {
const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE(...)
// 检查禁用子字符串和禁用命令
}
-
通过 GrowthBook 获取动态配置
-
可以禁用特定子字符串或完整命令
-
仅对
USER_TYPE=ant的用户生效(可能是内部测试或特定用户群体)
2. 用户配置的排除命令(主要逻辑)
const settings = getSettings_DEPRECATED()
const userExcludedCommands = settings.sandbox?.excludedCommands ?? []
用户可以在设置中配置不想放入沙箱的命令列表。
3. 命令解析与匹配策略
关键设计:复合命令拆分
subcommands = splitCommand_DEPRECATED(command)
将 docker ps && curl evil.com 拆分成 ["docker ps", "curl evil.com"] 分别检查,防止通过复合命令绕过沙箱。
候选命令生成(迭代剥离)
const candidates = [trimmed]
// 迭代剥离环境变量前缀和安全包装器
while (startIdx < candidates.length) {
const envStripped = stripAllLeadingEnvVars(cmd, BINARY_HIJACK_VARS)
const wrapperStripped = stripSafeWrappers(cmd)
}
这个循环会生成所有可能的命令变体:
-
timeout 300 FOO=bar bazel run→bazel run -
剥离环境变量(如
FOO=bar) -
剥离安全包装器(如
timeout 30)
匹配规则类型
switch (rule.type) {
case 'prefix': // 前缀匹配,如 `bazel:*` 匹配所有 bazel 命令
case 'exact': // 精确匹配
case 'wildcard': // 通配符匹配
}
4. 沙箱启用决策(shouldUseSandbox)
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 // 启用沙箱
}
安全设计要点
| 设计 | 说明 |
|---|---|
| 复合命令拆分 | 防止 safe_cmd && evil_cmd 绕过 |
| 迭代剥离 | 处理 timeout 30 FOO=bar evil_cmd 多层包装 |
| 候选命令集合 | 生成所有可能的命令变体进行匹配 |
| 明确的非安全边界注释 | excludedCommands 是便利功能,真正的安全边界是权限提示系统 |
更多推荐



所有评论(0)