主题
Claude Code Sandbox 深度调研
本文聚焦 Claude Code 的「沙箱(Sandbox)」机制,从设计哲学、整体架构、平台落地、关键源码到执行流程,逐层拆解 Anthropic 的实现思路。
调研依据:
- Anthropic 官方博客《Making Claude Code more secure and autonomous with sandboxing》
- 官方文档
docs.anthropic.com/en/docs/claude-code/sandboxing- 开源项目
anthropic-experimental/sandbox-runtime(npm 包@anthropic-ai/sandbox-runtime,CLI 名为srt)- Claude Code 反编译源码(
claude-code-sourcemap)中的sandbox-runtime与sandbox-adapter.ts
一、为什么需要沙箱:从「权限疲劳」到「OS 级边界」
1.1 Claude Code 早期的安全模型痛点
在没有沙箱前,Claude Code 走的是「Permission Prompt(权限提示)」路线:每个 Bash 命令、每次写文件、每次访问网络都可能要弹一次确认框。这套机制的问题官方在博客里说得很直白:
- Approval fatigue(审批疲劳):用户重复点 "approve",最终对每个提示视而不见。
- 生产力下降:被频繁打断,节奏被打乱。
- 代理自主性受限:必须等待用户审批,无法长时间自主运行。
- 信任前提脆弱:Prompt Injection 攻击可以诱导模型输出绕过 prompt 层防御的命令;纯应用层校验抵不过聪明的 exploit。
1.2 沙箱的设计哲学(Design Principle)
Anthropic 给出的核心思路是 Defined Boundaries(先画好边界)+ OS-Level Enforcement(操作系统级强制)。两条关键设计原则:
- Secure by default(默认安全):进程拿到的能力是最小的,需要的"权限洞"必须显式打开。
- Dual Isolation Model(双重隔离):文件系统隔离 + 网络隔离 必须成对出现。
- 只锁文件不锁网:恶意代码可以把
~/.ssh/id_rsaPOST 出去。 - 只锁网不锁文:恶意代码可以改
~/.bashrc在你下次登录时 reverse shell。
- 只锁文件不锁网:恶意代码可以把
Anthropic 在内部数据上声称:开启 Sandbox 后权限提示量减少 84%,且在 Prompt Injection 测试中能扛住典型逃逸尝试。
1.3 在 Claude Code 中的定位
- 沙箱只作用于 Bash 工具及其子进程树(Bash → npm → curl → ...),不覆盖
Read/Edit/Write这些走应用层 Permission 的工具。 - 与 Permission System 互补而非替代:
- Permission 控制 "Claude 能不能调这个 tool"(应用层)
- Sandbox 控制 "Bash 跑出去后能不能访问这个文件/域名"(OS 层)
- 沙箱被打包成独立的 npm 包
@anthropic-ai/sandbox-runtime,对外开源(Apache 2.0),CLI 名为srt。Claude Code 内部就是 vendor 这个包。
二、整体架构:分层与职责
┌─────────────────────────────────────────────────────────────────┐
│ Claude Code 主进程(Node.js) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ BashTool → shouldUseSandbox(input) → SandboxManager │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Shell.exec() → SandboxManager.wrapWithSandbox(cmd) │ │
│ │ │ │ │
│ │ ┌───────────────┴───────────┐ │ │
│ │ │ sandbox-adapter.ts │ │ │
│ │ │ (Claude CLI 的 settings │ │ │
│ │ │ → SandboxRuntimeConfig) │ │ │
│ │ └───────────────┬───────────┘ │ │
│ │ ▼ │ │
│ │ HTTP Proxy (127.0.0.1:port) ◄────── SandboxManager │ │
│ │ SOCKS5 Proxy (127.0.0.1:port) ◄────── (sandbox-runtime) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ spawn 子进程 │
└────────────────────────────┼────────────────────────────────────┘
▼
┌─────────────────────────────┐
│ 外层包装命令: │
│ • macOS: sandbox-exec -p │
│ '<profile>' bash │
│ • Linux: bwrap [args] bash │
└────────────┬────────────────┘
▼
┌─────────────────────────────┐
│ 受限子进程(用户的 bash │
│ 命令 + 全部子进程树) │
│ │
│ 网络 → Proxy(域名过滤) │
│ 文件 → Seatbelt/bwrap 拦截 │
└─────────────────────────────┘2.1 关键模块(sandbox-runtime 源码结构)
sandbox-runtime/
├── index.ts # 对外导出 SandboxManager 等
├── cli.ts # srt 命令入口
├── sandbox/
│ ├── sandbox-manager.ts # 总调度器:init / wrapWithSandbox / reset
│ ├── sandbox-config.ts # Zod Schema:网络/文件/seccomp/...
│ ├── sandbox-utils.ts # 通用工具:路径归一化、glob、危险文件清单
│ ├── sandbox-violation-store.ts # 违规事件存储
│ ├── http-proxy.ts # HTTP/HTTPS CONNECT 代理(域名过滤)
│ ├── socks-proxy.ts # SOCKS5 代理(任意 TCP 流量)
│ ├── linux-sandbox-utils.ts # bwrap 包装 + 网络桥接 + seccomp
│ ├── macos-sandbox-utils.ts # 动态生成 Seatbelt profile
│ └── generate-seccomp-filter.js # 加载预编译 BPF 过滤器
└── vendor/seccomp/
├── x64/{unix-block.bpf, apply-seccomp}
└── arm64/{unix-block.bpf, apply-seccomp}2.2 Claude Code 的适配层(sandbox-adapter.ts)
SandboxManager(基础库)只负责"给我一段命令、给我配置,我返回一段被包裹的命令字符串"。Claude Code 在外面套了一层 sandbox-adapter.ts,负责:
- Settings ↔ SandboxRuntimeConfig 转换:把 Claude 自己的
permissions.allow/deny(Edit(...)、Read(...)、WebFetch(domain:...))和sandbox.filesystem.*、sandbox.network.*翻译成 sandbox-runtime 的格式。 - 路径前缀语义:Claude Code 引入了三种路径写法:
//path— 文件系统绝对路径(去掉一个/)/path— 相对设置文件目录的路径(permission rule 约定,与一般理解相反)~/path、./path、path— 透传给 sandbox-runtime
- Worktree 检测:在 git worktree 里
.git是文件而不是目录,必须把主仓库路径加到allowWrite,否则git index.lock会报错。 - Bare-repo 防御(CVE-style):阻止恶意命令在 cwd 下植入
HEAD、objects/、refs/把 cwd 伪装成裸仓库(一旦伪装成功,宿主未沙盒的git就会读core.fsmonitor触发任意命令执行)。 - 设置变更订阅:
settingsChangeDetector.subscribe→ 实时updateConfig,让运行中的代理也能感知新规则。
三、配置模型:双向不对称的精妙
SandboxRuntimeConfigSchema(Zod)的核心字段,读写两侧的「allow / deny 优先级」是反着的:
| 维度 | 默认 | 模式 | 优先级 |
|---|---|---|---|
| read(读) | 允许全部 | deny-then-allow | allowRead 覆盖 denyRead |
| write(写) | 拒绝全部 | allow-only | denyWrite 覆盖 allowWrite |
| network | 拒绝全部 | allow-only | deniedDomains 覆盖 allowedDomains |
这种不对称的语义是精心设计的:
- 读:你想"封一个区域,但留一个洞"——例如禁
/Users但开 cwd。 - 写:你想"开一个区域,但留一个保护带"——例如开
.,但禁.env、.git/hooks/。
3.1 强制 Deny 路径(Mandatory Deny Paths,源码 DANGEROUS_FILES)
无论用户怎么配置 allowWrite,下列路径永远被拒写——这是「沙箱逃逸防线」:
ts
// sandbox-utils.ts
export const DANGEROUS_FILES = [
'.gitconfig', '.gitmodules',
'.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile',
'.ripgreprc',
'.mcp.json',
];
export const DANGEROUS_DIRECTORIES = ['.git', '.vscode', '.idea'];
// + 始终阻止 .git/hooks(任意提交触发执行)
// + 始终阻止 .git/config(除非显式 allowGitConfig: true)
// + 始终阻止 .claude/commands、.claude/agents、.claude/skills
// + 始终阻止 settings.json / settings.local.json(防止改沙箱自身)阻止 mv/rename 绕过的设计也很细:在 macOS profile 里同时下发 (deny file-write-unlink ...) 来挡掉 "把目标删掉再造一个同名文件" 的攻击。
3.2 Default Write Paths(生存所需)
某些路径是「让命令能正常跑完」的最低保障,所以 sandbox-runtime 默认就打开:
ts
function getDefaultWritePaths() {
return [
'/dev/stdout', '/dev/stderr', '/dev/null', '/dev/tty',
'/dev/dtracehelper', '/dev/autofs_nowait',
'/tmp/claude', '/private/tmp/claude',
`${homedir}/.npm/_logs`,
`${homedir}/.claude/debug`,
];
}四、macOS 实现:Seatbelt + sandbox-exec
4.1 Seatbelt 是什么
Seatbelt 是 Apple TrustedBSD MAC 框架(与 iOS 的 App Sandbox、App Store 沙箱、Chrome Renderer 沙箱同源)。命令行接口是 sandbox-exec(1) + 一种**类 Lisp 的 SBPL(Sandbox Profile Language)**配置:
scheme
(version 1)
(deny default (with message "<logTag>"))
(allow process-exec)
(allow file-read*)
(deny file-read* (subpath "/Users/me/.ssh") (with message "<logTag>"))
(allow network-bind (local ip "*:*"))
...Apple 早就用
sandbox-exec隔离自家系统服务,但它官方未文档化,签名时还会警告 "deprecated"——好在它一直没真被废弃。
4.2 Profile 动态生成(macos-sandbox-utils.ts)
每次 wrapWithSandbox 都会为这条命令现场生成一个 SBPL profile,关键骨架:
scheme
(version 1)
(deny default (with message "<logTag>")) ; 默认全拒
; ── 进程相关
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
; ── Mach IPC(仅放行白名单服务)
(allow mach-lookup
(global-name "com.apple.audio.systemsoundserver")
(global-name "com.apple.lsd.mapdb")
...)
; ── sysctl-read(精挑细选了 50+ 个,避免泄漏过多硬件信息)
(allow sysctl-read
(sysctl-name "hw.ncpu")
(sysctl-name-prefix "kern.proc.pid.")
...)
; ── 网络
(allow network-bind (local ip "localhost:<httpProxyPort>"))
(allow network-outbound (remote ip "localhost:<httpProxyPort>"))
(allow network-bind (local ip "localhost:<socksProxyPort>"))
; 其他网络操作 → 落入 (deny default) 被拒
; ── 文件读:先 allow 全部,再 deny 区域,再 allow back
(allow file-read*)
(deny file-read* (subpath "/Users/me/.ssh") (with message "<logTag>"))
(allow file-read* (subpath "<cwd>") (with message "<logTag>"))
; ── 文件写:默认拒,逐条 allow,再 deny 危险文件
(allow file-write* (subpath "<cwd>") (with message "<logTag>"))
(deny file-write* (regex "<glob-converted-regex>") (with message "<logTag>"))
(deny file-write-unlink (literal "/Users/me/.bashrc") (with message "<logTag>"))Glob 支持:macOS 端原生支持 glob——sandbox-utils.ts 里 globToRegex() 把 **/*.ts 类的 gitignore 风格写法编译为 SBPL 的 (regex ...)。Linux 不行(见下文)。
4.3 命令包装最终形态
bash
env SANDBOX_RUNTIME=1 TMPDIR=/tmp/claude \
HTTP_PROXY=http://localhost:53201 \
HTTPS_PROXY=http://localhost:53201 \
ALL_PROXY=socks5h://localhost:53202 \
GIT_SSH_COMMAND="ssh -o ProxyCommand='nc -X 5 -x localhost:53202 %h %p'" \
NO_PROXY=localhost,127.0.0.1,::1,*.local,... \
sandbox-exec -p '<上面那大段 profile>' /usr/bin/zsh -c '<用户的命令>'注意:
- 用
env VAR=val ... sandbox-exec ...而不是VAR=val sandbox-exec,因为后者用 shell quote 会很难对付,前者每个 KV 都是独立 argv,靠shell-quote包安全转义。 binShell默认走用户的$SHELL(zsh/bash),让 alias / shell snapshot 在沙箱内仍生效。
4.4 违规事件流式监听(macOS 独有)
macOS 提供了一个非常香的能力:内核 sandbox 拒绝事件会实时打到 system log。startMacOSSandboxLogMonitor() 就 spawn 了一个:
bash
log stream --predicate '(eventMessage ENDSWITH "<sessionSuffix>")' --style compact每条 deny 都会被解析、关联到引发它的命令(每条命令的 profile 里都嵌了一个 CMD64_<base64-encoded-cmd>_END_<sessionSuffix> 作为 logTag),然后回填到 stderr:
<original stderr>
<sandbox_violations>
deny file-write* /Users/me/.bashrc ...
deny network-outbound to evil.com:443 ...
</sandbox_violations>这个增强后的 stderr 会反喂给模型,让 Claude 知道哪条规则把它拦了,然后理性地选择放弃或申请 escape hatch。
五、Linux 实现:bubblewrap + 网络命名空间 + seccomp
Linux 比 macOS 复杂得多。Seatbelt 是「描述式策略」,bwrap 是「命令式构造命名空间」,所以要把同样的语义在 bwrap 里搭出来要费一番力气。
5.1 bubblewrap 是什么
bwrap 是 Flatpak 团队做的轻量沙箱,原理是 unprivileged user namespace + mount/PID/net namespace + bind mount。它不依赖 root,不需要内核 LSM,是目前 Linux 上做"非容器但又强隔离"的事实标准。
5.2 Linux 沙箱的三层:bwrap / 代理桥接 / seccomp
┌────────── 宿主机 host ──────────┐
│ │
│ Node 主进程 │
│ ├── HTTP Proxy (127.0.0.1:Px)│
│ └── SOCKS5 Proxy (127.0.0.1:Py)│
│ │
│ socat UNIX-LISTEN:/tmp/claude- │
│ http-XXX.sock,fork ──────┼──→ TCP localhost:Px
│ socat UNIX-LISTEN:/tmp/claude- │
│ socks-XXX.sock,fork ─────┼──→ TCP localhost:Py
└────────────────┬────────────────┘
│ (这两个 .sock bind-mount 到沙箱里)
▼
┌────────── bwrap 沙箱 ──────────┐
│ --unshare-net │ ← 完全切掉网络命名空间
│ --unshare-pid --proc /proc │ ← 切掉 PID 命名空间,挂全新 /proc
│ --ro-bind / / │ ← 整个根文件系统只读
│ --bind <cwd> <cwd> │ ← 仅 cwd 可写
│ --ro-bind /dev/null .bashrc │ ← 危险文件挂 /dev/null 拒写
│ --tmpfs <denyRead 目录> │ ← 拒读区域用 tmpfs 遮蔽
│ --bind /tmp/claude-http.sock ...│
│ │
│ 内部 bash -c " │
│ socat TCP-LISTEN:3128,fork ─┼──→ UNIX-CONNECT:/tmp/claude-http.sock
│ socat TCP-LISTEN:1080,fork ─┼──→ UNIX-CONNECT:/tmp/claude-socks.sock
│ apply-seccomp <bpf-file> sh -c '<user cmd>'
│ " │ ← seccomp BPF 拦 socket(AF_UNIX,...)
└────────────────────────────────┘为什么这么绕?因为 Linux 的 --unshare-net 是「全有或全无」——一旦切了网络命名空间就没有任何网卡,你必须自己造一条「沙箱内 → host 代理」的通道。Anthropic 选的是 Unix Domain Socket bind-mount:把宿主上 socat 监听的 .sock 挂进沙箱,然后再在沙箱内用一个 socat 把 localhost:3128 翻译到这个 .sock。这样沙箱里的 curl 看到的就是普通的 HTTP_PROXY。
5.3 文件系统隔离(generateFilesystemArgs)
精彩的几个细节(直接来自源码注释):
- 写白名单:
--ro-bind / /让根目录只读;然后--bind <p> <p>把允许写的路径再改回来。 - 不存在路径的拒绝处理(防
mkdir + write绕过):- 找出 deny 路径中第一个不存在的组件
- 在该位置
--ro-bind /dev/null <component>—— 后续任何mkdir都会失败 - bwrap 退出后会留下 0 字节的 ghost 文件,
cleanupBwrapMountPoints()兜底删掉
- Symlink 替换攻击防御:
findSymlinkInPath()检查 deny 路径中每个组件是否为 symlink- 如果是,挂
/dev/null占位,攻击者就没法删了 symlink 再重建一个真目录
- 拒读用 tmpfs:
--tmpfs /Users把 /Users "盖" 成空目录,再--ro-bind把allowRead里的子目录贴回来。 - /etc/ssh/ssh_config.d 强制隐藏:OrbStack 等环境下文件权限会让 SSH 报 "Bad owner",Anthropic 默认遮蔽。
- Glob 限制:bwrap 完全不支持 glob(参数必须是真路径)。
sandbox-runtime在 Linux 端要么静默丢弃 glob、要么expandGlobPattern()用fs.readdirSync+ 自己实现的globToRegex一次性展开成具体路径。这是 Linux 端能力受限的主要短板(UI 上会有 ⚠️ 警告:"Glob patterns not fully supported on Linux")。
5.4 强制 Deny 用 ripgrep 扫描(linuxGetMandatoryDenyPaths)
macOS 可以靠 glob 一发命中所有嵌套 .bashrc,Linux 不行——只能实时扫描。所以每条命令前都会跑:
bash
rg --files --hidden --max-depth 3 \
--iglob .bashrc --iglob .zshrc ... \
--iglob '**/.git/hooks/**' \
-g '!**/node_modules/**'把扫到的路径全部加进 deny 列表,然后逐一 --ro-bind /dev/null <path>。这就是 Linux 端依赖 ripgrep 的原因。mandatoryDenySearchDepth 默认 3,可以调到 1~10——值越大越安全但越慢。
5.5 网络代理桥接(initializeLinuxNetworkBridge)
宿主侧两条 socat:
bash
socat UNIX-LISTEN:/tmp/claude-http-<rand>.sock,fork,reuseaddr \
TCP:localhost:<httpProxyPort>,keepalive
socat UNIX-LISTEN:/tmp/claude-socks-<rand>.sock,fork,reuseaddr \
TCP:localhost:<socksProxyPort>,keepalivebwrap 启动时 --bind 把这两个 sock 挂进沙箱;沙箱里的 init 脚本再起两条 socat 把内部 127.0.0.1:3128 / 1080 转过去。环境变量统一注入:
bash
HTTP_PROXY=http://localhost:3128
HTTPS_PROXY=http://localhost:3128
ALL_PROXY=socks5h://localhost:1080
GIT_SSH_COMMAND="ssh -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=3128'"
NO_PROXY=localhost,127.0.0.1,::1,...域名过滤发生在哪? 在宿主机的 HTTP/SOCKS 代理里。bwrap 本身不懂域名,它只保证「除了这两个 .sock,没有任何出网通道」。
5.6 Unix Socket 阻断 + seccomp BPF
bwrap 的网络隔离是「网络命名空间级别」的——但 AF_UNIX socket 不走网络命名空间,进了同 mnt namespace 的 sock 文件就能通信。如果不堵这一层,恶意命令只要能打到 /var/run/docker.sock、/tmp/claude-http.sock 就能逃逸。
Anthropic 的解法是 预编译 seccomp BPF + 自定义 apply-seccomp 启动器:
- 预编译 BPF:
vendor/seccomp/{x64,arm64}/unix-block.bpf—— 拦socket(AF_UNIX, ...)和io_uring_setup/enter/register(5.19+ 有IORING_OP_SOCKET旁路风险)。 - apply-seccomp 二进制(C 源码同包,预编译):
unshare(CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNS)嵌套一层命名空间- 在新命名空间内做 PID 1(init/reaper),
PR_SET_DUMPABLE=0防 ptrace prctl(PR_SET_NO_NEW_PRIVS)+prctl(PR_SET_SECCOMP, ..., bpf-file)execve(user_command)
- 两阶段加载的精妙之处:
- 先让 socat 在沙箱里启动(它需要
socket(AF_UNIX,...)才能桥接) - 然后 apply-seccomp 把 BPF 装上,再
exec用户命令——这时候用户命令既不能新建 AF_UNIX socket,也看不到 socat 进程(被嵌套 PID namespace 隔开)
- 先让 socat 在沙箱里启动(它需要
- 架构限制:x86_64 / arm64 有预编译;i386 上
socket()走socketcall(SYS_SOCKET, ...)多路复用,BPF 拿不到子调用号——所以官方明确不支持 32 位 x86(避免假装安全)。
5.7 为什么不用 Landlock / SELinux / AppArmor
- Landlock:内核 5.13+ 才有,且语义偏文件——不能做网络隔离。
- SELinux/AppArmor:需要 root 写 policy,企业 IT 友好但不适合 CLI 工具默认开启。
- bwrap:unprivileged user namespace(5.13 之前就稳定)+ mount namespace 已经够强,且零配置。
唯一例外:Ubuntu 24.04+ 默认开启了 kernel.apparmor_restrict_unprivileged_userns=1,会剥夺 user namespace 的 capability——这种情况下 bwrap 和 seccomp 嵌套都会失败,需要用户:
bash
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0六、网络代理:HTTP/HTTPS + SOCKS5 双通道
6.1 为什么要两个代理?
- HTTP 代理:处理
HTTP_PROXY/HTTPS_PROXY协议——CONNECT隧道走 HTTPS,普通 GET/POST 走透明代理。绝大多数 CLI 工具(curl/wget/git/npm/pip/cargo/...)默认认这两个变量。 - SOCKS5 代理:处理「不认 HTTP_PROXY 的 TCP 协议」——SSH / 数据库连接 / 二进制 RPC。
socks5h://让 DNS 也走代理,避免 DNS 泄露。
源码里的 generateProxyEnvVars() 一口气下了 20+ 个环境变量,对各种工具都做了适配:
ts
HTTP_PROXY / HTTPS_PROXY / http_proxy / https_proxy / NO_PROXY / no_proxy
ALL_PROXY / all_proxy / FTP_PROXY / ftp_proxy / RSYNC_PROXY
DOCKER_HTTP_PROXY / DOCKER_HTTPS_PROXY
CLOUDSDK_PROXY_TYPE / CLOUDSDK_PROXY_ADDRESS / CLOUDSDK_PROXY_PORT // gcloud
GRPC_PROXY / grpc_proxy
GIT_SSH_COMMAND // git over ssh 通过 SOCKS5
SANDBOX_RUNTIME=1 // 给沙箱内进程一个识别标志
TMPDIR=/tmp/claude // 让所有临时文件落到允许写的目录6.2 代理层的请求过滤逻辑(http-proxy.ts)
ts
server.on('connect', async (req, socket) => {
const [hostname, portStr] = req.url.split(':');
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 域名 → 走 Unix socket 上的 mitmproxy
// 否则直连
...
});filter() 实现:
- 先匹配
deniedDomains(黑名单优先)→ 拒 - 再匹配
allowedDomains(支持*.example.com通配,但*.com这种过宽的 Schema 校验时就被拒了)→ 准 - 都不命中 → 调用
sandboxAskCallback({ host, port })弹 UI 让用户选 Yes / Yes-and-remember / No
6.3 BYO Proxy / MITM 模式
network.mitmProxy.{ socketPath, domains } 让用户接入自己的 mitmproxy(通过 Unix socket 与 sandbox-runtime 通信)。这条通道允许:
- 解密并审计 HTTPS 流量
- 实施 path 级别的允许("github.com 但只准访问
/api/v3/repos/own/...") - 配合自签 CA + macOS 上的
enableWeakerNetworkIsolation让 Go 程序认证书
6.4 MITM / 内部 NAT / 企业 SSO 适配
httpProxyPort/socksProxyPort:跳过自带代理,让流量走企业 forward proxy。enableWeakerNetworkIsolation(macOS):放开com.apple.trustd.agent,让 Go 工具(gh、gcloud、terraform、kubectl)能调用系统 Security 框架做 TLS 验证。安全代价:trustd 自身可能被滥用做数据外传,仅在确实需要时打开。
七、执行流程:一次 Bash("npm install") 的完整旅行
模型决策
│
▼
BashTool.tsx
│ 1) shouldUseSandbox(input)
│ ├─ SandboxManager.isSandboxingEnabled()?
│ ├─ input.dangerouslyDisableSandbox && allowUnsandboxed?
│ └─ containsExcludedCommand(input.command)? (docker / npm test:* / ...)
▼
Shell.exec(...)
│ 2) const wrapped = await SandboxManager.wrapWithSandbox(cmd, binShell, undefined, abortSignal)
▼
sandbox-adapter.ts → BaseSandboxManager (sandbox-runtime)
│
│ 3) 等待 initializationPromise(HTTP/SOCKS 代理 + Linux 桥接早就起好了)
│
│ 4) 平台分流:
│ ├─ macOS:动态生成 Seatbelt profile → 拼出 'env ... sandbox-exec -p ... zsh -c ...'
│ └─ Linux:构造 bwrap 参数 + 内层 socat 启动脚本 + apply-seccomp 包装
│
▼
spawn(/bin/sh, ['-c', wrapped], { cwd, env, stdio })
│
▼
┌─────────── 受限子进程 ───────────┐
│ npm install 的子进程树 │
│ ├─ 网络请求 → HTTP_PROXY │ → host proxy 检查域名 → registry.npmjs.org ✓
│ ├─ git clone via ssh → SOCKS5 │ → 黑名单/白名单
│ ├─ 写 ./node_modules/ │ ✓ 落到 cwd 内
│ ├─ 写 ~/.bashrc (恶意脚本) │ ✗ EPERM
│ └─ curl evil.com │ ✗ 403 by proxy
└─────────────────────────────────┘
│
▼
Shell.ts.then(result => {
SandboxManager.cleanupAfterCommand() // 清理 bwrap 留下的 ghost 文件 + scrubBareGitRepoFiles
// ↓ 把违规事件回填到 stderr
result.stdout = SandboxManager.annotateStderrWithSandboxFailures(cmd, result.stdout)
})
│
▼
模型看到的输出:
<最后几行 stdout>
<sandbox_violations>
deny file-write* /Users/me/.bashrc
deny network-outbound to evil.com:443
</sandbox_violations>八、Escape Hatch(逃生口):dangerouslyDisableSandbox
不是所有命令都能在沙箱里跑:
- docker:本身就是命名空间魔法,跟 bwrap 互斥
- watchman:要监控沙箱外的目录
- 某些 IDE 集成:要写
~/.vscode/
Anthropic 给了三档解法:
excludedCommands(用户白名单):在settings.json配["docker:*"],匹配上就完全跳过沙箱。注意官方原话:"这不是安全边界,只是用户体验"。- 模型主动申请
dangerouslyDisableSandbox: true:BashTool 的 input schema 暴露这个字段,模型遇到沙箱失败可以重试时打开它——但会重新走 Permission Prompt 让用户审批。 allowUnsandboxedCommands: false:硬关 escape hatch。dangerouslyDisableSandbox字段会被无视,命令必须沙盒化或在excludedCommands里。
源码里很有趣的细节:BashTool 的 inputSchema 是 lazySchema() 动态生成的,并且 _simulatedSedEdit 这个内部字段被刻意从 model-facing schema 里删掉,防止模型 "用 sed 包装写文件" 偷渡过沙盒检查(注释原文:"Exposing it in the schema would let the model bypass permission checks and the sandbox by pairing an innocuous command with an arbitrary file write.")。
九、与 Permission System 的关系
┌──────────────── Tool Layer ───────────────┐
│ Read / Edit / Write / WebFetch / MCP / ... │ ← Permission Rules
│ Bash │ - allow/deny/ask
└──────────────────┬─────────────────────────┘
│ Bash 工具单独再包一层
▼
┌──────────────── Sandbox Layer ────────────┐
│ OS-level enforcement (Seatbelt / bwrap) │ ← Sandbox Config
│ 作用范围:bash 进程及其全部子进程 │ - filesystem
│ │ - network (proxy)
└────────────────────────────────────────────┘重要陷阱(来自第三方分析 claudecodecamp):
sandbox.filesystem.denyRead只挡 Bash 走cat、vim等工具读文件。- Claude 自己的
Read工具走 Permission Layer——必须同时在permissions.deny加Read(...)才能完整拒读。 - 同理:
WebFetch(domain:...)deny 与sandbox.network.deniedDomains是 Read 工具与 Bash 工具两条管线的对应配置。
官方的 Settings 合并语义是「跨多 scope 数组合并而不是覆盖」——管理员通过 policy 设置的 allowWrite: ["/opt/company-tools"] 会和用户 settings 的 allowWrite: ["~/.kube"] 合并出最终允许列表。
十、安全边界清单(What sandbox does / doesn't)
✅ 沙箱能挡住
- 在 cwd 外写文件(包括所有子进程衍生的写)
- 访问未列入白名单的域名(HTTP/HTTPS/任意 TCP)
- 修改
.bashrc、.zshrc、.gitconfig、.git/hooks等触发执行的敏感文件 - 在 Linux 上创建任意 AF_UNIX socket 做 IPC
- 域名拼写绕过(Seatbelt/bwrap 的 deny 是 OS 层硬阻断)
⚠️ 沙箱不能保护的
Read/Edit/Write工具走应用层:只有 Bash 子进程被沙盒。要拒读 SSH key,必须两层都配。- 域名 fronting / 共享 CDN:允许
github.com也意味着 attacker 能往任意 GitHub 仓库 push 数据。 allowUnixSockets: ["/var/run/docker.sock"]:Docker socket 等同于 root,等于完全绕过沙箱。allowWrite包含$PATH目录:可以放后门可执行文件。enableWeakerNestedSandbox(Linux):在非特权 Docker 里跑沙箱必须打开,但安全性显著降低。- MCP server 不天然在沙箱内:要靠
srt npx -y server-foo这种方式手动包装。 - Computer Use(让 Claude 控屏幕)完全不在 sandbox 内:那是另一条赛道。
十一、与同类方案对比
| 维度 | Claude Code Sandbox | Codex CLI Sandbox | Devcontainer | Docker | gVisor / Firecracker |
|---|---|---|---|---|---|
| 隔离强度 | 中-强(OS Primitive) | 中(macOS sandbox-exec / Linux Landlock) | 强(完整 VM/容器) | 强 | 极强 |
| 启动延迟 | ~10ms | ~10ms | 秒级 | 秒级 | 秒~分钟 |
| 配置粒度 | 域名/路径 | 路径为主 | 全镜像 | 全镜像 | 全镜像 |
| 跨平台 | macOS / Linux / WSL2 | macOS / Linux | 跨 OS | 跨 OS | Linux |
| 用法心智 | 修改 settings.json | CLI flag 或 config | 整套环境配置 | Dockerfile | 复杂 |
| 网络过滤 | 域名级(HTTP+SOCKS) | 通常关闭 | 自定义 | 自定义 | 自定义 |
| 场景 | AI 代码代理(轻量、按命令) | AI 代理(轻量) | 长开发会话 | 服务部署 | 多租户 / 不可信代码 |
Anthropic 选 Sandbox 而不是默认 "永远跑在 Docker 里",是出于 延迟(每条 bash 都包一次容器太慢)+ 兼容性(Mac 用户用得上)+ 透明度(沙箱内能看到宿主真实文件结构) 的综合考虑。
十二、关键启示与设计模式
如果要在自己的 Agent 上做同样的事情,从 Claude Code 这套实现里能抽出几个值得借鉴的模式:
- 双向不对称的 allow/deny 优先级:读「允许全部、按需拒绝、再放回来」;写「拒绝全部、按需放行、再封死要害」。是 90% 的现实需求最简单的表达。
- Mandatory deny 永远生效:不管用户怎么配,
.bashrc/.git/hooks/ 沙箱自身的 settings 必须封死。这是「用户不能配置出一个不安全的状态」的兜底。 - OS 原语优先:能用 Seatbelt 的不要在 Node 层做检查;能用 namespace 的不要拦 syscall——尽量把策略下沉到内核,让 ASLR/MAC 做事。
- Network proxy 放在沙箱外:只允许出站走代理,比写 firewall 规则简单得多——还能复用熟悉的 mitmproxy 生态。
- Violation log 反喂模型:当沙箱拒了一个动作,要让 Agent 看见原因("deny file-write* /Users/me/.bashrc"),它才能聪明地选择 escape hatch、改方案或放弃,而不是盲目重试。
- Escape hatch 但要走人工审批:把"逃生口"暴露给模型很重要(否则它会被 false negative 卡死),但每次逃生必须经过用户。
- 拥抱「漏一定会有」的现实:明确写死 docker/watchman 不兼容;通过 BYO proxy 让企业用户能自己加 MITM;把局限性写到文档第一段——这种诚实是企业落地的关键。
- Settings 合并而不是覆盖:让 Admin 和 User 各自加规则,最终是并集——既方便企业策略,又不剥夺用户自由。
十三、参考资料
- 官方博客:Making Claude Code more secure and autonomous with sandboxing
- 官方文档:Claude Code › Sandboxing
- 开源仓库:anthropic-experimental/sandbox-runtime(Apache 2.0,~3.9k stars)
- 第三方实测分析:Claude Code Sandboxing: How /sandbox Works
- macOS Seatbelt 协议:Apple Sandbox Guide v1.0(逆向版)
- Linux 工具链:bubblewrap / socat / seccomp BPF