Anthropic 这两天把 Claude Code 的 Bash 沙箱单独拎出来开源了,仓库叫 anthropics/sandbox-runtime,命令行工具名 srt,版本 0.0.81,Apache-2.0,目前约 6,695 star。README 的开场是一句话:在操作系统层面给任意进程加上文件和网络限制,不需要容器。
听起来是个很克制的工具。我把它 clone 下来通读了一遍,这句话本身没错,但它把一半的故事留在了后面。等你翻完 src/sandbox/,会发现这个仓库最重的部分不在「怎么把进程关起来」,而在「怎么让它能干活,同时拿不到钥匙」。
它默认什么都不给
先说清楚它的行为。srt 是 secure-by-default 的路子:进程起来时几乎没有权限,你一点点把需要的洞打开。
写是 allow-only。默认全禁,你必须显式列出可写路径,空列表就是完全不能写。读反过来,是 deny-then-allow,默认全放,你可以先禁掉一大片(比如整个 /Users),再把里面具体的路径放回来。这里有个反直觉的地方:读的放行优先(allowRead 盖过 denyRead),写的禁止优先(denyWrite 盖过 allowWrite),两条路的优先级方向是反的。网络同样是 allow-only,默认没有出网路由,一个个列域名。
官方在 README 和文档里反复强调一件事:文件和网络两个隔离必须同时在。只有文件隔离,被 prompt injection 的进程照样能把 ~/.ssh 读出来发到外网;只有网络隔离,进程可以先改系统文件、再借那个文件拿到网络。这条「双隔离」是全文的地基,后面所有机制都在为它服务。
同一道边界,三个平台三套做法
这是我觉得最有信息量的一段。同一个目标,三个平台落地的形态完全不同,我整理成了一张对照表:
| 维度 | macOS | Linux | Windows(alpha) |
|---|---|---|---|
| 文件系统 | sandbox-exec 动态生成 Seatbelt profile |
bubblewrap 的 --ro-bind / --bind / --tmpfs |
专用 srt-sandbox 本地账户、写受限 token、工作树显式 ACE |
| 网络出口 | Seatbelt 只允许连一个 localhost 端口 | --unshare-net 拔掉整个网络命名空间 |
按账户 SID 下机器级 WFP 过滤器,只放环路到代理端口段 |
| 本地 IPC | 交给 Seatbelt | seccomp-bpf 拦 socket(AF_UNIX) 与 AF_VSOCK |
job object 加账户隔离 |
| 违规监控 | 读系统 sandbox violation 日志库 | seccomp USER_NOTIF 观察写意图 | 每个会话真跑连接做 WFP 自检 |
| 后端语言 | TypeScript | TypeScript 加 C(seccomp) | 独立的 Rust crate |
Linux 这条路最干净。bwrap 的参数是 --new-session --die-with-parent,再按需要挂上 --unshare-net、--unshare-pid、--unshare-user,配合 --ro-bind 铺只读目录。源码里有一条注释写得很直白:如果没上 --unshare-pid,进程有机会逃出沙箱。所以 PID 命名空间是默认就 unshare 的,/proc 也重新挂一份。
macOS 那条路的麻烦在 Seatbelt profile 本身:sandbox-exec 拒绝超过 1025 字节的字符串字面量,编译几千个路径过滤器又要花时间。作者的解法是做一层 carve filter,把「大区域禁止 + 小范围放行」的老套路压成尽量少的正则。这类工程细节不上手写一遍是不会知道的。
Windows 走的是第三条路,也是工程量最大的一条:起一个专用的 srt-sandbox 本地账户,进程跑在这个身份下,再加一道机器级的 Windows Filtering Platform 出口封锁,按这个账户的 SID 过滤,只放行环路到代理端口段。它连「这个账户到底连不连得出去」都要每个会话实测一遍:用沙箱账户的身份真发几次连接,有一次通了就拒绝启动。
便宜的那一半,和贵的那一半
把三个平台摊开看,有个结论挺清楚:文件系统隔离是最商品化的一环。bwrap、Seatbelt、WFP 都是系统现成的能力,包一层壳谁都能做。这个仓库真正把工程量堆上去的,是沙箱外面的出口代理。
三个平台都把进程的原始出口掐死,只留一条通往宿主代理的路。Linux 是整个删掉网络命名空间,代理监听 unix socket、再 bind 进沙箱;macOS 的 Seatbelt 只准连一个本地端口;Windows 用 WFP 按身份封,代理就监听在那个端口段里。HTTP/HTTPS 走 HTTP 代理,其它 TCP 走 SOCKS5,两条都由代理按域名白名单放行或拒绝。
代理这一层在这个仓库里是实打实的重量级:http-proxy.ts 1,439 行,tls-terminate-proxy.ts 1,514 行,外加 socks-proxy.ts、mux-proxy.ts、parent-proxy.ts。但这些还只是运输层。真正有意思的是它往上长出来的凭据子系统。
改了 HTTP_PROXY 也没用,墙在操作系统里
有个问题得单独拎出来:代理是「指路牌」,本身不是「墙」。任何工具都能 unset HTTP_PROXY,或者干脆自己开个 socket 直连 IP。那 srt 拿什么保证流量一定走代理?
答案是它不靠环境变量。三个平台各用一种操作系统级别的硬手段,把「除了代理没有别的路可走」变成一条事实。
Linux 用的是网络命名空间。bwrap 的 --unshare-net 把沙箱塞进一个连网络接口都没有的命名空间:进程里没有任何路由,直连外网 IP 会在 syscall 层直接失败。它能碰到的唯一端点,是同一命名空间里由 socat 桥监听的两个本地端口(HTTP 3128、SOCKS 1080),桥再通过宿主侧的 unix socket 接到真代理。所以 agent 改 HTTP_PROXY 只是让它连错地方,unset 则让它哪儿都去不了,因为那个命名空间里除了这两条桥什么都没有。
macOS 用的是 Seatbelt。开启网络限制后,profile 里只下发一条出站规则:(allow network-outbound (remote ip "localhost:<代理端口>"))。connect() 到别的任何远端地址,内核直接拒。这里有个我读到时觉得讲究的细节:Seatbelt 的 network-outbound 是按源地址评估的,而未绑定的 socket 在 connect() 那一刻源地址是 INADDR_ANY,localhost 又会匹配任意地址,于是任何写成 (local ip ...) 的出站规则等于放行一切,他们因此坚持用 (remote ip "localhost:*")。这种坑不真跑一遍踩不出来。
Windows 用的是 WFP。机器级的 ALE_AUTH_CONNECT 过滤器按 srt-sandbox 账户的 SID 封出口,只放行到代理端口段的环路连接。README 有一句把这个模型说得最清楚:环境变量只是把工具指向代理,边界是过滤器,一个无视或者 unset 它们的进程照样被挡住。
那这条路拦不住什么,也得一起说。Linux 的命名空间是「全有或全无」,它保证的只是「你必须经过代理」,域名白名单在这一层并不存在,全凭代理自己实现,这句话就写在源码注释里。macOS 一旦打开 allowLocalBinding(为本地开发服务器而设),profile 会放行 (remote ip "localhost:*"),沙箱进程就能绕开代理直连本机端口。Windows 的那个归因缝前面提过,进程换个 token 就绕过了。还有一条更容易被忽略:请求出不出得去是一回事,代理替它做了什么又是另一回事。代码里专门把 TRACE 和 TRACK 直接拒掉(405),原因就是这类请求会回显请求内容,等于把代理注入的凭据原样吐回给沙箱。
凭据不进沙箱,只在出口换回来
srt 的配置里有一个 credentials 段,管环境变量和文件两类东西。每一类有两种模式。
mode: "deny" 最简单,值直接不给,沙箱里读不到。
mode: "mask" 是这个仓库的核心设计。它给沙箱里的进程一个假值,真值留在宿主的代理进程里。假值长这样:fake_value_<uuid4>,并且会按真值的长度做随机填充对齐,这样 HTTP body 在替换时 Content-Length 不用变,代理可以直接原样转发头。替换发生在出口,而且只发往这一条配置里 injectHosts 列出的主机,默认就是你的 allowedDomains。
文件的 mask 只在 Linux 生效:把一个装着哨兵内容的假文件只读覆盖在真文件上,沙箱读到的是假内容。macOS 和 Windows 退化成直接 deny。值本身存在进程内存里的一张注册表,不落盘、不打日志。
env 变量这层还有一个更细的开关叫 extract。不填它时是整值替换,一个哨兵顶掉整串。填了它则是结构化替换,用正则只圈出凭据那一段。拿一条 DATABASE_URL 连接串举例,整值替换会让解析它的工具拿到一段语法都不成立的字符串,圈段替换只把密码换掉,其余部分原样保留,工具照常 parse。配套的 onExtractNoMatch 决定正则什么都没匹配到时的行为,可以选直接把这个变量扣掉、退化成 deny,避免把一个没脱敏成功的真值放进沙箱。
这里有个必须一提的约束:masking 依赖 network.tlsTerminate。也就是说,要开这个功能,srt 得在进程内做 TLS 终结(也就是大伙常说的 MITM):它给沙箱塞一个包含自己 CA 的信任 bundle,于是代理能解开 HTTPS,看到明文,做替换,再用真凭据向真实上游握手。设计上的取舍是明确的,真值只经由一条它自己验证过的 TLS 连接出去,否则宁可不开。证书固定的客户端、走 mTLS 的内部服务,都可以用 excludeDomains 排除,让它们走不透明隧道,只是这样凭据注入也就对它们失效了。
再往上还有一层,是给 AWS 用的。你把 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY 打成 mask 之后,客户端拿假 secret 算出来的 SigV4 签名是错的,光换 header 里的 access key id 修不好。所以代理做的事是重签:剥掉客户端的 Authorization,保留它的 credential scope、签名头集合和 x-amz-date,用真 secret 按实际上行的请求重新算一遍签名。这套协议代码是对着官方 aws-sig-v4-test-suite 的向量验过的。签名形态分几类,header 签名的可以重签,分块上传和预签名 URL 的重签成本太高,走 deny 或放行策略。
读到这我大概理解了作者的取舍:拦住一个 Agent,难点从来不在把它关起来,而在让它正常干活的同时,别让密钥跟着它一起掉进沙箱。文件隔离解决的是「它改了什么」,这个代理层解决的是「它拿走了什么」。
我数了一遍这个仓库
为了不凭感觉说话,我把源码量了下:
| 指标 | 实测 |
|---|---|
| 仓库 | anthropics/sandbox-runtime,TypeScript,Apache-2.0 |
src/*.ts |
31,381 行,49 个文件 |
| 测试 | 131 个 .test.ts |
| Windows 后端 | 独立 Rust crate,27 个 .rs,29,280 行 |
| 凭据相关模块 | credential-*.ts 六个,另有 aws-sigv4.ts、mitm-ca.ts、mitm-leaf.ts、tls-terminate-proxy.ts |
| 仓库文件总数 | 378(不含 .git) |
两个数字值得停一下。一个是 Windows 那 29,280 行 Rust,比三个平台的 TypeScript 加起来还多,说明「在 Windows 上做同样的隔离」这件事被严重低估了。另一个是凭据相关的模块数量:六个 credential-* 加上 MITM 与 SigV4 一整条链路。从模块划分看,凭据在这里是一套有独立设计的子系统,而不是随手加的补丁。
它到底怎么用
读完架构,落地形态有三种,值得分开看。
最直接的是命令行。srt "curl anthropic.com" 把这条命令裹进沙箱,配一个 ~/.srt-settings.json 描述读写和域名规则。没有配置文件时用一套内置默认值;但配置文件存在却读不了、或者校验不过,它会直接报错退出,而不是退回默认值。这个细节我认同:默认值和配置文件是两套不同的策略,静默降级会把用户写在文件里的 deny 规则一并丢掉。
第二种是把它套在 MCP server 外面。.mcp.json 里把 command 从 npx 换成 srt,参数不动,那个 server 就整个跑进了边界里。官方拿 filesystem server 举例:套上之后让它写一个被 deny 的目录,会直接拿到 EPERM。MCP server 是现在 agent 生态里权限最大的一批组件,能一行配置包起来,是它比较容易落地的入口。
第三种是 srt proxy,把这个 HTTP 代理单独跑起来,不带子进程。宿主把负载放在别处,另一台虚拟机也好、另一个容器也好,只把它的流量指到这个代理上,再另起一个 decider 进程做放行决策。代理本身不持有策略,decider 说放行才出去。这个形态是奔着「代理和沙箱解耦」去的,也解释了为什么它对 fd 传参、lifeline、握手超时这些细节抠得那么细。
它自己承认拦不住什么
这篇不是软文,值得把它 README 里的 Known limitations 拎出来。
Windows 的 WFP 封锁有个结构性的缝:过滤引擎按「创建 socket 那一刻线程上的 token」归因,所以一个进程在创建 socket 之前把另一个 token 放到线程上,就不会被认成 srt-sandbox,也就绕过了封锁;系统服务代它转发的流量同样不匹配。这是 alpha 阶段的已知缺口。
macOS 有两个开关会削弱模型。allowLocalBinding 让沙箱进程可以不经过代理直连环路端口,官方给的理由是本地开发服务器要用。enableWeakerNetworkIsolation 为了 Go 程序(gh、gcloud、terraform 这些)能验证证书,会打开 com.apple.trustd.agent,文档里明确写了这是个潜在的数据外泄面。
Linux 的 seccomp 过滤器范围很窄,只拦 socket(AF_UNIX, ...)、socket(AF_VSOCK, ...),再加上 io_uring_setup/io_uring_enter/io_uring_register(后三个是因为 Linux 5.19 起 IORING_OP_SOCKET 能绕过 socket 规则)。它不拦父进程继承来的、或者通过 SCM_RIGHTS 传进来的 unix socket 描述符,32 位 x86 上的 socketcall() 也没堵。
把这些列出来反而更有说服力。一个沙箱工具如果只讲自己拦住了什么,通常意味着它没认真想绕过路径。
被拦下之后,它怎么把账算清
一个沙箱光是拦住还不够,出了边界尝试得让人知道是谁干的。srt 把三类事件收在一起:macOS 的 seatbelt 日志行、Linux 的 seccomp 事件、代理层的 deny。每条都挂在一个归因键下,annotateStderrWithSandboxFailures 和 getViolationsForCommand 用同一个键去查。
默认的键就是被包裹的那条命令字符串。这里有个坑官方专门提示了:键只比较前 100 个字符,所以长命令如果前缀相同会互相串味,同一条命令重跑还会继承上一轮的事件。正确做法是传一个 commandId(比如某个 tool-use id),把同一段脚本的多次调用分开。
还有一个「问主人」的机制。当某个目标既没被 deny 列表也没被 allow 列表决定时,可以注册一个 SandboxAskCallback 回调,交给宿主自己判断,只有返回 true 才放行;没有注册回调就直接拒绝。这个回调和 Claude Code 里那个「这条命令要不要批」的交互是同一件事的底层,只是把决策权交回给了调用方。
它替代不了微虚拟机,这不是新旧关系
读到这很容易得出一个结论:操作系统原语又快又不用容器,那微虚拟机是不是可以不要了。不是。这两个是不同威胁模型下的不同边界,不是轻量版和重量版的替代关系。
先看 srt 用的是哪一类边界。Seatbelt 管的是系统调用,bwrap 管的是命名空间,WFP 管的是网络过滤,它们全都跑在同一个内核里。这条路成立的前提是 host 可信、内核和这些原语本身都没被拿下。前面那段 Known limitations 里列的绕过,本质上都是同一内核之内的绕过,一旦绕过去,攻击者和宿主共用同一个内核,中间没有第二道墙。
微虚拟机换的是另一个层级:整个 guest 内核都在边界里面。攻击者即使把 guest 内核打穿,还要再越一次 hypervisor。它付出的代价是启动慢、要管镜像、要处理挂载,换回来的是「哪一层被攻破才算数」这个问题的答案彻底不同。
所以这里没有谁更先进的问题,只取决于你防的是谁。
你自己就是宿主、主机可信,只是不想让一个被 prompt injection 的 agent 乱来,那 OS 原语这一档又快又够用,不用镜像、不用等启动,还能把审批弹窗压下来(Anthropic 自己的说法是内部使用中减少了 84% 的权限提示)。
主机本身就不可信,比如多租户跑别人的 agent,或者客户要的是一句可被审计的「内核级隔离」,那这一档不成立,必须上微虚拟机。
还有一点能说明 srt 自己也没打算替代谁。它只管 shell 这一层:Claude Code 的文件工具、WebFetch、本地 MCP server、hook 全部跑在沙箱外面。它还专门留了 srt proxy 这个能单独跑、不带子进程的形态,明说负载可以放在「a VM, a container, another sandbox」里,这一层控制面坐在它外面。它是往你已有的边界上再叠一层,不是把边界换掉。
写在最后
官方叙事是「操作系统级沙箱,不要容器」。这句话对,但它的重心把注意力引到了隔离层,而隔离层恰好是这行里最不稀缺的东西。bwrap、Seatbelt、WFP 都是系统给的能力,任何一支有经验的团队两周就能包出一个能用的壳。这个仓库真正花代价的是出口那半边:把凭据从沙箱里彻底拿掉,在出口按主机和请求范围重放,给 AWS 重新签名。
顺着这个结构往下想,沙箱这个品类正在分成两层:下面一层是隔离,上面一层是凭据和授权。要注意的是,下面这一层被逐级商品化,不等于微虚拟机没用了。OS 原语这一档已经免费,容器这一档也差不多,微虚拟机这一档正在被各家云厂托管成一个 API。档位仍然需要,选哪一档取决于前面说的威胁模型;被消解掉的是「拿这档隔离当差异化」这件事。
对做 Agent 基础设施的人,这份源码给出的信号很直接。如果你的差异化只是「我有隔离」,那它已经被一个 6,695 star 的开源项目覆盖了,而且这一档还在继续往下掉价。能收费的地方在出口:凭据的注入范围怎么定、重放策略怎么审、证据怎么留。而这一层目前还没有事实标准,谁先把接口做出来,谁就占住位置。
相关阅读:
- 《NVIDIA OpenShell 拆解》 — 另一条把 Agent 安全做进内核拦截的路线
- 《Perplexity SPACE 拆解》 — 沙箱「一次做全」的另一种风格
- 《OpenCode 凭据泄露》 — 凭据没管好会是什么后果