Plimsoll 发布自托管沙箱,为 AI 智能体生成的代码提供四级隔离
Show HN: Plimsoll, a self-hosted sandbox for code your AI agent writes
Plimsoll 是一个自托管沙箱,用于运行 AI 智能体写出的不可信代码,每次结果都会标明实际获得的隔离等级,按由弱到强分为 process、container、kernel、vm 四级。
plimsoll 在沙箱内运行不受信任的代码,例如 AI 智能体编写的代码,并且每个结果都会说明该沙箱的强度。请求可以要求最低强度;如果沙箱较弱,则不会运行任何内容。
这个名字来自 Plimsoll 线,即画在船体外侧、任何人都能查看的载重限制线。plimsoll 对隔离层级做了同样的事:一次运行周围墙壁的强度,以四个级别之一表示,从最弱开始:process、container、kernel、vm。该层级是每个结果上的一个值,而不是数据表中的一句话。请求可以设置一个下限,即它愿意接受的最弱层级,而官方 Go 客户端在结果返回时会再次检查该层级。
同一套机制让 plimsoll 不仅能隔离 AI 智能体编写的代码,还能对其运行考试。内置于模拟镜像中的试验运行器会针对该程序无法读取的场景运行智能体的程序:每个场景都通过一个在程序启动前就被删除的文件送达运行器。每次运行都会返回所发送请求和所返回结果的 SHA-256 哈希,官方客户端会重新计算(如何计算)。守护进程之外的一个独立程序 plimsoll-attest 会对已检查的结果进行签名,拒绝缺少一环的调用链,并重放一次运行。考试本身(模拟系统、场景、及格分数)仍由你决定:plimsoll 提供边界和证据。
查看一次真实运行
一个 AI 智能体编写了一个控制器:一个在每个tick(此处为模拟的一个 10 毫秒步长)读取系统状态并决定如何推动它的程序。plimsoll 让它针对一个模拟的倒立摆运行,即一个在轨道上的小车,顶部铰接一根杆,控制器通过左右推动小车使杆保持直立。模拟器被编译为 WebAssembly,一种在宿主程序内运行的可移植字节码,并内置到沙箱镜像中。控制器是调用方通过普通项目 API 发送的唯一文件,它以 container 层级运行。
倒立摆是控制工程中的标准教学问题。运行器是镜像中内置的一个程序,它让控制器针对模拟器运行,记录运行的轨迹,即每个 tick 的每个状态,并将其哈希为一个指纹:一个 SHA-256 哈希,因此相等的指纹意味着相同的数字。将通过的控制器运行两次,两个指纹匹配。智能体的初稿有两个增益(其公式中的乘数)符号错误:它在 5.96 秒时使杆倒下,其指纹也不同。
打开实时运行报告 ↗,在浏览器中重放两次运行,阅读智能体编写的控制器,并查看该页面刻意不声称的内容。该示例的一次执行会进行三次沙箱运行:通过的控制器两次,初稿一次。用 make docker-images && go run ./examples/oracle 复现它们。
它做的两件事
它报告墙壁。每个结果都会说明该运行实际获得的隔离层级。每个请求都可以设置一个下限,而守护进程无法满足其下限的请求会在任何代码运行之前被拒绝:ErrInsufficientIsolation 意味着没有运行任何内容。
res, err := provider.Sandbox.RunJavaScript(ctx, sandbox.Request{ Code: "console.log(6 * 7)", MinimumIsolation: sandbox.IsolationVM, // ErrInsufficientIsolation means nothing ran }) // res.Isolation reports the boundary that actually ran.
每一次什么都没运行的拒绝都会被标记为一次拒绝,并附上原因(request、permission、protocol、unsupported、isolation、environment、capacity)。sandbox.NotDispatchedReason(err) 会读取该标记,无论 provider(运行代码的后端)是在你自己的进程中,还是在守护进程之后。没有该标记的错误可能是在代码启动之后才出现的,因此自动重试永远不安全。标记只在一个把客户端请求 ID 带回来的应答上才算数,所以一个把某个请求的应答提供给另一个请求的代理,无法让一个实际运行过的调用被读成“什么都没运行”(返回的内容,以及它的含义)。
该层级是守护进程收集到的证据:它的配置以及它自身的启动检查。它不是 attestation,即来自硬件、证明正在运行什么软件的密码学证明,这里没有任何 provider 提供这种证明。docs/isolation-tiers.md 列出了每个层级所依据的内容。
它让 agent 编写的代码能够调用你的 API,而无需持有你的凭据、你的 API 地址,也无需开放网络访问。一个 grant 授予一次运行调用某个 API 所列路由的权限。代码通过 plimsoll 放入沙箱的一个小型客户端进行调用,而 broker——plimsoll 位于沙箱之外、发起真实调用的部分——会检查调用方和确切的路由,然后自行附加凭据。凭据是按每次运行 minted 的:plimsoll 每次运行只请求一次,因此它可以是一个全新的短期令牌,并且它永远不会进入沙箱。绕过客户端手动向外调用的代码无法更进一步,因为执行规则的是 broker,而不是客户端。参见 docs/capability-grants.md。
从 agent 框架中使用它
这些 agent 框架可以通过它们自己的工具或代码执行器,把其模型的代码交给 plimsoll(Trigger.dev 就是其中之一,它是一个托管的 TypeScript 作业运行器)。每一行都列出了要安装什么、指南,以及一个页面,该页面回放从该框架的工具到沙箱再返回的已记录调用,包括拒绝。
| 框架 | 包或额外项 | 指南 | 查看一次运行 |
|---|---|---|---|
| Vercel AI SDK | @plimsollmark/client/ai-sdk |
Vercel AI SDK 指南(INTERNAL · documentation →) | Vercel AI SDK 页面(INTERNAL · plimsoll site →) |
| Google ADK | plimsoll-client[adk] |
Google ADK 指南(INTERNAL · documentation →) | Google ADK 页面(INTERNAL · plimsoll site →) |
| Agno | plimsoll-client[agno] |
Python 客户端指南,Agno 和 CrewAI 工具(INTERNAL · documentation →) | Agno 页面(INTERNAL · plimsoll site →) |
| CrewAI | plimsoll-client[crewai] |
Python 客户端指南,Agno 和 CrewAI 工具(INTERNAL · documentation →) | CrewAI 页面(INTERNAL · plimsoll site →) |
| Trigger.dev | @plimsollmark/client/trigger |
Trigger.dev 指南(INTERNAL · documentation →) | Trigger.dev 页面(INTERNAL · plimsoll site →) |
| Mastra | @plimsollmark/client/mastra |
TypeScript 客户端指南,Mastra(INTERNAL · documentation →) | Mastra 页面(INTERNAL · plimsoll site →) |
给 Trigger.dev agent 一个代码工具
TypeScript 插件为 Trigger.dev 上的聊天代理提供了一个
executeCode 工具。在带有 session 的守护进程上,一个沙箱会为多次调用保留,
每次调用是一个 cell,代码在解释器内运行,并为该对话保留:
变量和文件可以存活到下一次调用。每个工具结果都会说明它们
能否存活、本次调用是否全新开始、它运行在哪个隔离层级之后,
以及 run record 的 SHA-256,这是一份关于发送和返回内容的回执,
客户端在返回前已核对过。调用方设置最低层级,因此较弱的提供方会在运行代码前拒绝。
查看功能与部署指南了解确切的 保证和生产环境底线。 可部署的 Trigger.dev 起始项目 包含一个聊天代理和一个任务,该任务通过真实守护进程证明两个 cell 共享同一个解释器, 且不进行 AI 模型调用。
一分钟内运行点东西
该示例不需要守护进程、不需要 docker、不需要凭据,也不需要网络:它使用 wasm
提供方,该提供方在你自己的进程内,在 QuickJS(一个编译为 WebAssembly 的小型 JavaScript 引擎)上运行 JavaScript。克隆和获取 Go 模块确实会使用
网络。
git clone https://github.com/plimsollmark/plimsoll && cd plimsoll go run ./examples/minimal
provider wasm
isolation process
exit code 0
timed out false
truncated stdout=false stderr=false
duration 304ms
stdout {"Engineering":59000000,"Sales":20300000,"Operations":9800000}
isolation 这一行来自运行结果,而不是示例自身的文本。
process 意味着完全没有操作系统隔离墙:不要将其用于恶意
代码。 安装 gVisor 后,同一段代码会在 kernel 层级运行。
gVisor 是容器与你机器内核之间的一层,它处理
容器对操作系统本身的请求,因此代码永远不会直接与你的
内核通信。sudo ./docker/install-gvisor.sh 会安装一个 固定版本的 gVisor
发布版(一个确切版本,只能通过编辑此仓库来更改),并将其
运行时 runsc 注册到 docker。程序的其他部分没有任何变化:
make docker-images # builds the project image (plimsoll/sandbox) the docker provider checks for
SANDBOX_PROVIDER=docker SANDBOX_DOCKER_RUNTIME=runsc go run ./examples/minimal在未设置 SANDBOX_PROVIDER 的情况下,plimsoll 使用 Disabled 提供方,它不运行任何内容,因此
代码执行绝不会意外开启。
下一步: docs/getting-started.md 会在同一台机器上
走一遍相同的部分:启动守护进程、创建调用方凭据、编写你自己的客户端、观察
某个层级拒绝一次运行,然后将守护进程从 process 移到 kernel,而无需更改
客户端。它还涵盖了嵌入 Go 包而不是运行守护进程。
各部分的位置
flowchart LR
A["Agent or MCP gateway (MCP: how an AI app offers tools to a model)"] -->|"Run: one payload, a protocol number, minimum_isolation"| AU
subgraph D["plimsolld"]
AU["authenticate the caller"] --> FL["compare the floor with current provider evidence"]
FL --> DI["dispatch"]
BR["broker: holds the minted credential, matches the exact route, counts every call"]
end
DI --> W["wasm: QuickJS on wazero (process tier)"]
DI --> K["docker with runsc (kernel tier)"]
DI --> V["e2b Firecracker (VM tier)"]
W -. "host.get / host.post" .-> BR
K -. "per-run Unix socket" .-> BR
V -. "authenticated guard" .-> BR
BR ==> |"your credential, attached host-side"| API["Your API"]
加载中
沙箱内的代码,即 guest,从不持有凭据,也从不 直接访问网络。没有授权的运行完全没有网络。
隔离层级
plimsolld 是 plimsoll 服务器。每个服务器只运行一个提供方,由
SANDBOX_PROVIDER 选择:
| 提供方 | 边界 | 报告的层级 | 用途 |
|---|---|---|---|
wasm |
在 wazero(一个用 Go 编写的 WebAssembly 运行时)上的 QuickJS,位于 plimsolld 自身内部 |
process |
快速的本地开发。引擎中的 escape(一种让代码逃出其沙箱的漏洞)会落在你的守护进程内。 |
docker 配合 runc,docker 的默认运行时 |
一个共享你机器内核的容器 | container |
当内核漏洞不在你计划防御的攻击范围内时,用于自托管。使用项目镜像时,它会保持一个会话:一个容器用于多次调用,并带有一个 JavaScript 解释器(当项目镜像为 plimsoll/sandbox-python 时还有一个 Python 解释器),其变量在调用之间存活。 |
docker 配合 runsc |
gVisor,一旦启动检查确认 docker 已注册 runsc |
kernel |
在你自己的机器上运行恶意代码。也会保持会话。 |
e2b |
一个 microVM(为单次运行而创建、随后销毁的小型虚拟机),来自 E2B,这是一个托管服务,在 Firecracker(AWS 的开源虚拟机监视器)上运行它们 | vm |
恶意代码运行在 E2B 的机器上而非你的机器上;按运行计费。也支持会话,在 microVM 运行期间计费;挂起会暂停它,这会保留文件但不保留解释器的变量。 |
dockercloud |
一个来自 Docker Cloud Sandboxes 的 microVM,Docker 的托管沙箱服务 | vm |
恶意代码运行在 Docker 的机器上;按运行计费。它支持两种 API,由 SANDBOX_DOCKERCLOUD_API 选择,两者都在 2026-10-04 通过了实时测试套件:一种是 Docker 在发布前推出、现已不再文档化的 API,它是默认选项,因为只有它具备授权和镜像证据;另一种是 Docker 文档化的 REST API,作为备用保留,直到它覆盖这些功能(无授权,在 PLIMSOLL_HARDENED=1 下被拒绝,运行时间最多 270 秒)。账户的网络策略必须为 deny-all(除非规则允许,否则无连接),每次运行都会检查这一点。当设置 SANDBOX_DOCKERCLOUD_GUARD_URL 时,授权通过与 E2B 相同的 guard(plimsoll 服务器上的一个地址,microVM 唯一可以连接的地方)工作。与 E2B 不同,客户机持有自己运行的用于 guard 的短期凭证,并且唯一的网络规则是通过 Docker 调用在其已发布 API 之外设置的。 |
openshell |
一个来自 OpenShell 的沙箱,NVIDIA 的代理沙箱运行时,由其网关服务器在 docker 上创建 | container |
已经运行 OpenShell 网关的代理平台。每次运行都会获得自己的沙箱,无网络;plimsoll 会读回其设置,拒绝在任何差异下运行,并在之后将其删除。已针对 2026-09-28 的 v0.1.2 网关进行测试。授权通过沙箱内部的中继到达代理,plimsoll 从外部连接到该中继,因此沙箱不需要网络规则。也支持会话。 |
| 未设置 | 不运行任何内容 | 不适用 | 默认值。 |
每个层级都依赖于守护进程的配置、提供商所报告的内容以及启动时的一次真实测试运行;这些都不是证明。docs/isolation-tiers.md 列出了每个层级的证据,并展示了请求如何设置其下限。会话用每次调用的全新沙箱换取速度和保留状态:较早调用运行的代码可以改变后续调用所见的内容,而具有 API 访问权限的调用需要允许会话的授权(docs/sessions.md)。一个会话就是一个信任域,plimsoll 将其绑定到经过身份验证的调用者,而不是该调用者的客户:一个通过单一凭证运行许多客户代码的服务必须将每个客户保留在各自的会话中(谁可以共享会话)。docker 提供商还可以应用随附的 seccomp 配置文件,这是一个列表,列出了其容器可以进行的唯一 系统调用(对内核的请求,例如打开文件)(docs/seccomp.md)。
更多真实运行
| 页面 | 它展示了什么 |
|---|---|
| 仿真回放页面,全部八个模拟器 ↗ | 为控制器运行而内置于仿真镜像中的八个模拟器:淋浴、buck converter(一种通过开关切换来降低电压的电源)、船舶航向、黑洞轨道、相对论火箭、卫星时钟、双缝以及小车摆杆平衡(该镜像还包含模块运行测试所使用的模型)。每个页面都通过沙箱运行一个失败和一个通过的手写控制器,并回放两条轨迹及其指纹。 |
| 一个用 C 编写的控制器,在沙箱中编译 ↗ | 同一个 cart-pole 模拟器,配有一个摆起控制器(它把杆从悬挂状态摆起,然后使其平衡),该控制器用 C 编写,并由运行自身的第一步编译为 WebAssembly,因此模拟器和控制器都是 WebAssembly。运行报告将其与 JavaScript 版本逐 tick 进行比较。两种语言的 cos 函数在大约百分之一的输入上最后一位不一致,因此在四个场景中的三个里,有一到两个力值不同,相差几个可表示的 double(最多 14)。指纹能捕捉到这一差异,而运动本身逐位完全相同。源码和注意事项见 其 README;用 make docker-images && go run ./examples/wasm-controller 复现。 |
| 用 C 编写的降压转换器控制器 ↗ | 电源的控制律(其控制器在每个 tick 应用的公式)用 C 编写,即转换器固件所用的语言。它在沙箱中编译为 WebAssembly,并针对一个模拟转换器运行,在负载突然变化时,其输出必须保持在 5 V。它不调用任何库函数,因此在所有四个场景中,其轨迹与 JavaScript 版本逐字节相同;页面逐 tick 绘制了其中一个场景的图表。源码和注意事项见 其 README;用 make docker-images && go run ./examples/wasm-buck 复现。 |
| 同一次运行,不同的沙箱 ↗ | 本页顶部的 cart-pole 运行,在本地 runc 和 gVisor、一个 OpenShell 网关、E2B 和 Docker Cloud Sandboxes 上:三个隔离层级、两个 Node 版本,以及一个指纹,与第一份报告发布的是同一个。用 go run ./examples/providers 复现已配置的行;云端行会产生费用。 |
| 一个沙箱,五次调用 ↗ | 在 OpenShell 沙箱上的一个会话:一个失败的测试、一个补丁、测试通过而无需再次发送文件,以及一个残留进程在下一次调用时已消失。每次调用的运行记录(守护进程关于发送了什么、返回了什么以及在哪里运行的声明)都经过签名并链接到前一条。验证器接受该签名集合,并在丢弃一次调用或更改一个字节时拒绝它。用 go run ./examples/sessions 和一个网关复现。 |
| 效率顾问的报告 ↗ | 顾问读取一次运行所做的 API 调用,并指出浪费的模式。一次实测运行:同一个问题向 API 提问,先是 13 次调用,然后 1 次,以及顾问的发现,即指出能在一次调用中回答它的路径。 |
| 十二节互动课程 ↗ | 一次运行如何执行、提供者、API 代理,以及连接 AI 代理。静态页面:无网络调用、无分析、无第三方脚本。 |
状态,直说
- Pre-1.0,单一作者,尚无外部用户。破坏性变更在没有弃用路径的情况下落地,这是有意为之。
- 从未进行过第三方安全审计,作者自己的自审账本也未发布。SECURITY.md 说明了存在什么、它值多少,以及你可以自己检查什么。
- CI 在公开环境中运行门禁,每项检查都名副其实。 audit workflow 包含三个 job。
audit在每次推送到main以及每个 pull request 时运行普通的make audit:build、vet、race tests、lint、buf lint、生成代码漂移检查,以及govulncheck。在audit之后,clients运行make clients-suite:在clients/typescript和examples/trigger-chat中运行npm ci,然后以 required 模式运行 Python 和 TypeScript Go 测试包。缺少运行时、依赖或被跳过的 Go 测试都会使该 job 失败。audit-docker也会跟随audit,并在镜像已准备好的情况下以 required 模式运行make docker-suite:缺少守护进程、缺少镜像或被跳过的测试都会使该 job 失败。因此,绿色的audit-docker意味着 docker 套件在 runc 下使用随附的 seccomp 配置文件运行,并证明了只读根目录、大小合适的noexec可写挂载,以及 broker 的拒绝行为。gvisor workflow 在 runsc(内核层级)下运行同一套件,使用固定版本的安装器。race detector 在普通 audit job 和两个 docker-suite job 中运行;client job 不运行它。没有任何检查会触发 E2B 或 Docker Cloud Sandboxes:这些套件驱动的是实时付费服务,因此被刻意从不接入 runner。参见 CONTRIBUTING.md。
了解其工作原理
如果你更愿意阅读而不是克隆代码,交互式课程是最快的入门方式。先看 Plain English 了解 API 背后的理念,或者看 Quick start 来实际运行一些东西。这些文档使用的每个术语都在 glossary 中有一句话定义。课程位于 docs/trainers/,可离线使用:在浏览器中打开克隆仓库里的任意文件即可。GitHub 会将 .html 文件显示为源代码而不是渲染它们,这就是上面的链接指向已发布副本的原因。
文档
以下每一项都端到端地回答一个问题。
| 文档 | 回答 |
|---|---|
| docs/getting-started.md | 我如何构建它、嵌入它、以带身份验证的服务方式启动它,并观察一个 floor 拒绝一次运行? |
| docs/example-programs.md | 这些可运行示例证明了什么,我应该先读哪一个? |
| docs/isolation-tiers.md | 每个层级依赖什么,我如何按请求要求某个层级? |
| docs/capability-grants.md | agent 代码如何在不持有我的凭据的情况下调用我的 API? |
| docs/run-results.md | 会返回什么,以及何时失败是错误而不是结果? |
| docs/run-records.md | 每次运行的记录会声明什么,我如何用另一种语言重新计算它,以及我如何在守护进程之外对记录进行签名、验证和重放? |
| docs/sessions.md | 我如何为多次调用保留同一个沙箱,调用之间会保留什么,包括解释器的变量? |
| clients/python | 我如何从 Python 调用 plimsolld? |
| clients/typescript | 我如何从 TypeScript 调用它,并为 Trigger.dev 或 Mastra agent 提供一个能保持其状态的代码工具? |
| docs/trigger-dev.md | 复用同一个解释器、隔离 floor 和经过检查的运行记录,这些调用如何一起出现在一个 Trigger.dev 工具中,我如何部署一个 task? |
| docs/ai-sdk.md | 我如何为 Vercel AI SDK agent 提供一个 executeCode 工具,是每次调用都新建,还是为一个用户的对话保留? |
| docs/google-adk.md | 我如何让 plimsoll 成为 Google ADK agent 的代码执行器,并支持输入文件、输出产物以及低于 floor 时的拒绝? |
| docs/placement.md | 我运行着多个守护进程:如何为每个请求挑选一个,以及拒绝在什么情况下可以安全地重试到别处? |
| docs/inner-loop-workflow.md | 如何在本地快速迭代,而不会把一个脆弱的沙箱发布到生产环境? |
| docs/efficiency-advisor.md | 顾问能看到什么,为什么它记录的内容永远不会包含代码发送或接收的数据,以及我如何选择它输出什么? |
| docs/hardened-mode.md | 如何让守护进程在每一项生产防护措施都设置好之前拒绝启动? |
| docs/dependencies.md | 沙箱要成立,哪些依赖必须被信任,以及检查它们的工具是如何被固定的? |
| docs/limitations.md | 它刻意不做什么? |
| docs/dockercloud.md | Docker Cloud Sandboxes 提供方需要操作员提供什么,以及每次运行会检查什么? |
| docs/openshell.md | OpenShell 提供方需要操作员提供什么,以及每次运行会检查什么? |
| docs/releasing.md | 为什么模块路径是公开的,为什么发布从 v0.2.0 开始,以及为什么没有校验和豁免? |
| docs/callers.md | 如何创建、轮换和撤销调用方凭据? |
| docs/seccomp.md 和 docs/gvisor.md | seccomp 过滤器和 gVisor 强制执行什么? |
| docs/guest-dependencies.md | npm 包如何进入一个没有网络的沙箱? |
| docs/architecture/credential-minting.md | 每次运行的凭据来自哪里,又停留在哪里? |
| docs/seams.md | 刻意的扩展点在哪里? |
| 术语表 ↗ | 这个词是什么意思?每个术语一句话,简明扼要。 |
| AGENTS.md | 架构参考:提供方、任何变更都不得破坏的规则,以及每一个环境变量。 |
License
来源:Hacker News · github.com

