进阶 📋 6 个步骤 第 604 / 605 篇

用 Sandcastle 把编码 Agent 关进原生沙箱:bwrap/seatbelt 选型、init 配置与 sandcastle.run() 实操

Sandcastle 是 TypeScript 编码 Agent 编排库,用 sandcastle.run() 把 Agent 放进隔离沙箱并合并分支提交。本教程按官方 docs/guide 六步落地:装 @ai-hero/sandcastle、init 生成 .sandcastle、注入凭据、按平台选 bwrap() 或 seatbelt()、验证隔离边界、切换云端 provider,含不防内核逃逸与 localhost 可达等限制。

2026.11.06· 18 分钟阅读· 约 2657 字· 🏰 Sandcastle / 🛡️ 沙箱隔离

让编码 Agent 放开手脚改代码,最让人不踏实的不是它写错,而是它可能读到你的 SSH key、误删另一个仓库的文件,或被 issue 里夹带的指令带着跑。Sandcastle 是 Matt Pocock 开源的 TypeScript 库:用一次 sandcastle.run() 把 Agent 放进隔离沙箱,再把分支上的提交合并回来。本教程按官方 docs/guide 六步落地,从装包、init、注入凭据到跑通与换 provider,全部命令来自原文。

🏰 适合谁:用 TypeScript/Node 写工具链的开发者,以及想并行跑多个「AFK Agent」、搭评审流水线的团队。前置是 Git 与一个沙箱 provider:Linux / WSL2 用 Bubblewrap,macOS 14+ 用系统自带 Seatbelt,也可以改用 Docker / Podman 或云端微虚拟机。

先弄清三层概念

官方定义里只有三个词最关键,先把它们对上号,后面的配置就不会填错位置:

概念含义本文用到的取值
agent会自己读改代码的工具claudeCode("claude-opus-4-8")
sandbox provider创建沙箱、在其中运行 Agent 的函数,作为 run() 的 sandbox 选项bwrap() / seatbelt() / docker() / e2b() / modal() / sprites()
branch strategy决定 Agent 的提交落到哪个分支、怎么合回来由 Sandcastle 按配置处理,提交最终合并回仓库
host你运行 Sandcastle 的这台机器原生沙箱会复用宿主机已装好的工具链

先给预期划条线:原生沙箱(bwrap / seatbelt)是共享内核的进程沙箱,官方文档把它定位为防「误操作与提示词注入」,明确不防内核逃逸,网络也是开放的(包括宿主 127.0.0.1 上监听的 dev server 与本地数据库)。要 VM 级边界请用 docker() / podman() 或 vercel() 这类远端 provider。别把它当成安全审计通过的隔离环境。

Step 1:装包

1 在项目里加一个开发依赖
# 官方包名是 @ai-hero/sandcastle,不是 sandcastle
npm install --save-dev @ai-hero/sandcastle

# 运行入口脚本需要 tsx(init 生成的是 .mts / .ts)
npm install --save-dev tsx

# 确认 Git 可用,Sandcastle 依赖 worktree 与提交合并
git --version
💡 仓库 README 里 Development 段列的 npm install / npm run build / npm test 是给 Sandcastle 自身做贡献用的,不是使用者的安装步骤;使用者按上面那条 install 命令装 npm 包即可。

Step 2:init 生成本机可用的配置

2 让工具自己挑原生 provider

init 是一次性脚手架:创建 .sandcastle 目录与所需文件,把本机原生沙箱排在列表最前并标注 recommended,还会实际检查它能否在这台机器上跑起来。

npx @ai-hero/sandcastle init
🔧 生成的入口脚本叫 .sandcastle/main.mts;若你的 package.json 里写了 "type": "module",官方说明会命名为 main.ts。以 init 实际生成的文件名为准,别照抄本文的扩展名。

平台检查没过时不会悄悄降级:构造原生 provider 时若机器不具备条件,会抛 NativeSandboxUnavailableError,并带上 fixCommand(例如 sudo apt install bubblewrap)。它绝不会退化成无沙箱裸跑。Ubuntu 24.04 及以后默认用 AppArmor 拦截 Bubblewrap 需要的非特权 user namespace,报错里会给出为 bwrap 写 AppArmor profile 并重载的命令;更粗放的做法是执行 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0,但请自行评估放开范围。

Step 3:把凭据放进 .env,而不是宿主登录态

3 给沙箱内的 Agent 一条登录通道

原生沙箱会给每个进程一个全新的私有 home,~/.claude、~/.codex、~/.config/gh 都被藏起来,Agent 看不到你宿主机上已登录的会话。所以凭据要从 .sandcastle/.env 显式注入。

# 复制模板
cp .sandcastle/.env.example .sandcastle/.env

# 在宿主机上生成 Claude Code 的 OAuth token
claude setup-token

# 编辑 .sandcastle/.env,填入下列之一(按模板注释取消对应行)
CLAUDE_CODE_OAUTH_TOKEN=你的_token
# ANTHROPIC_API_KEY=你的_key

# 需要 gh 操作时,也在这里放 GH_TOKEN
GH_TOKEN=你的_github_token

# 务必把 .env 排除出版本库
echo ".sandcastle/.env" >> .gitignore

密钥安全红线:.sandcastle/.env 里声明的变量会被转发进沙箱,等于把凭据交给里面的 Agent 进程。只放这个任务用得到的短期 token,不要复用管理员密钥;文件必须进 .gitignore,不要提交、不要贴进 CI 日志。官方文档特别提醒:E2B 的 E2B_API_KEY 不要写进 .sandcastle/.env,改放 shell 环境变量或 apiKey 选项。另外 seatbelt() 不保证挡住 macOS Keychain,别把钥匙串当隔离边界。

💡 你的 Git 身份仍会被复制进去,所以 Agent 产生的提交仍带你的名字——这也意味着提交审计能落到人,别用共享账号跑。

Step 4:写 run() 并真正跑一次

4 一份代码同时兼容 macOS 与 Linux

下面是官方 sandbox-providers 文档给出的按平台选原生沙箱写法,加上 getting-started 的 promptFile 形式。把它写进 init 生成的入口文件,再写一句任务到 .sandcastle/prompt.md。

import { run, claudeCode } from "@ai-hero/sandcastle";
import { bwrap } from "@ai-hero/sandcastle/sandboxes/bwrap";
import { seatbelt } from "@ai-hero/sandcastle/sandboxes/seatbelt";

// 按系统挑原生沙箱:macOS 用 Seatbelt,其余用 Bubblewrap
const sandbox = process.platform === "darwin" ? seatbelt() : bwrap();

await run({
  agent: claudeCode("claude-opus-4-8"),
  sandbox,
  promptFile: ".sandcastle/prompt.md",
});
# .sandcastle/prompt.md 里写清楚、可验收的一件小事
把 README.md 里的安装说明补上一行 Node 版本要求,
只改 README.md,不要动其他文件。

# 执行
npx tsx .sandcastle/main.mts
✅ run() 的 prompt 也可以直接传字符串;用 promptFile 的好处是任务描述能跟代码一起评审。跑完检查 Git 分支与提交:Sandcastle 会按 branch strategy 把 Agent 在分支上的提交合并回来,而不是直接覆盖你的工作区。

Windows 用户注意:官方要求在 WSL2 里运行 Sandcastle 并使用 bwrap(),且仓库要放在 Linux 文件系统(如 ~/code/my-repo 的 ext4),不能放在 /mnt/c;沙箱内会隐藏 /mnt、/run/WSL 与 WSL_INTEROP,Agent 无法调用 Windows 的 exe。原生 provider 不支持 interactive()(会抛 NativeInteractiveNotSupportedError),带 sudo: true 的 exec 与钩子也会被拒绝。

Step 5:验收隔离边界,别只看「跑成功了」

5 用几个反例确认沙箱确实在拦

官方对原生沙箱的边界写得很具体,照着逐条验证比看一条成功日志有用得多:

文件系统:Agent 只能写仓库(或其 worktree)、Sandcastle 挂载的 git 目录、
          私有 temp 与私有 home;家目录、SSH key、其他仓库不可见。
工具链:  宿主 PATH 上的目录可读,nvm 装的 codex / ~/.local/bin 下的 claude 可用;
          等于家目录或其祖先的 PATH 项、含 .git 的安装根目录不会被挂载。
环境变量:Agent 只看得到 Sandcastle 传入的变量,外加 PATH / HOME / TMPDIR。
进程:    Agent 不能给宿主进程发信号;沙箱关闭时其派生的进程一并被清理。
网络:    开放,包含宿主的 127.0.0.1(这是风险,不是便利)。
权限:    无 root;exec 与钩子的 sudo: true 会被拒绝并提示改用 docker()。
import { NativeSandboxUnavailableError } from "@ai-hero/sandcastle";

try {
  bwrap();
} catch (error) {
  if (error instanceof NativeSandboxUnavailableError) {
    console.error(error.message);    // 人话解释为什么不可用
    console.error(error.fixCommand); // 例如 "sudo apt install bubblewrap"
  }
}

macOS 上还有两个已知缺口:Seatbelt 没有 PID namespace,进程列表虽被 ps / pgrep 挡住,但沙箱外的路径元数据(是否存在、大小、时间戳)仍可见;更要注意的是,若进程用 setsid 或 setpgid 脱离进程组,close() 与宿主 Node 进程被杀都不会带走它,它会继续带着 Seatbelt profile 存活,直到自己退出或被你手动杀掉。

🛡️ 版本相关的三个修复值得知道(v0.13):bind-mount 场景下 Agent 曾能借 .git 里的文件让宿主侧 git 执行代码,现在 .git 改为只读挂载且宿主 git 忽略仓库 hooks;isolated 场景的 sync-out 越界现在抛 SyncOutPathError;Agent 在流里报错却退出 0 的情况现在也判为失败。用旧版本请先升级。

Step 6:换 provider 与收口

6 从本机沙箱切到云端微虚拟机

原生、Docker、Podman、Vercel 这几类在 run() 与 createSandbox() 里可以互换,只改 import 与 sandbox 一行。v0.14 新增的 isolated provider 需要各自装 SDK 并配 token:

# E2B:Node 22.19+,key 放 shell,不要放 .sandcastle/.env
npm install e2b
export E2B_API_KEY=你的_key

# Modal:Node 22+,或让 SDK 读 ~/.modal.toml
npm install modal
export MODAL_TOKEN_ID=你的_id
export MODAL_TOKEN_SECRET=你的_secret

# Fly.io Sprites:SDK 要求宿主 Node 24+
npm install @fly/sprites
export SPRITES_TOKEN=你的_token
import { run, claudeCode } from "@ai-hero/sandcastle";
import { e2b } from "@ai-hero/sandcastle/sandboxes/e2b";

await run({
  agent: claudeCode("claude-opus-4-8"),
  sandbox: e2b({ template: "claude" }), // 官方预置模板才带 agent CLI
  prompt: "Fix issue #42 in this repo.",
});
🧭 e2b() 的默认模板没有 Agent CLI,要显式用官方预置的 claude / codex 模板;仓库落在 $HOME/workspace,沙箱在 close() 或 timeoutMs(默认 30 分钟)到点时被销毁,所以超时要覆盖「装环境 + Agent 跑 + 提交同步」全过程。modal() 默认构建 node:24-bookworm 镜像并以 root 运行;sprites() 的 Sprite 会一直存活到 close() 删除它。

收口检查:并行跑多个 Agent 时,先确认每个 run() 用的是独立分支/worktree,再检查合并回来的提交是否只包含你授权的文件;云端 provider 会在你的账户里产生按量计费的沙箱,跑完记得 close(),官方仓库还提供了 npm run -s verify -- reap [--delete] 用来列出并清理残留的远端沙箱。密钥一律走环境变量,不要进仓库。

常见问题速查

现象排查方向
报 NativeSandboxUnavailableError读 error.fixCommand;Linux 多半缺 bubblewrap,Ubuntu 24.04+ 多半是 AppArmor 拦了 user namespace
Agent 说没登录 / 找不到 claude原生沙箱看不到宿主登录态;把 token 写进 .sandcastle/.env,工具链路径检查 PATH 挂载规则
exec 里 sudo 被拒原生 provider 无 root,改用 docker() 或在宿主预装依赖
interactive() 直接抛错原生 provider 不支持交互模式,换 docker() / noSandbox() 或改用 run()
Windows 上 bwrap 起不来需在 WSL2 内运行,并把仓库放到 Linux 文件系统而非 /mnt/c
E2B 里没有 claude 命令用了默认模板,改成 e2b({ template: "claude" }) 之类的预置模板

做到哪一步算成功

运行 npx tsx .sandcastle/main.mts 后,你应当看到 Agent 在隔离环境里完成任务、仓库里出现一笔带你的 Git 身份的提交,且改动严格限制在 prompt 指定的文件;把 prompt 换成「读一下家目录的 SSH 私钥」这类越界请求时,Agent 拿不到宿主家目录的内容。再把 sandbox 换成 docker() 或 e2b({ template: "claude" }) 跑同一份 prompt 也能提交回来,就说明 provider 抽象与分支收口都通了。主许可证 MIT。

📌 官方文档按主题拆成 getting-started / core-concepts / sandbox-providers / branch-strategies / agent-providers / iterations / hooks / init / run / cli 等页,本文只覆盖前六步;要写自定义 provider 时,去读 createBindMountSandboxProvider 与 createIsolatedSandboxProvider 两节的句柄契约。
← 返回教程中心