Agent开发沙箱设计 - 使工具更安全的被执行
这是 code-artisan 拆解系列第五篇。
前言
前四篇我们已经把一个 Coding Agent 的骨架搭得差不多了:ReAct 主循环、provider 抽象、4 个 builtin 工具、middleware 接口和 4 个有用的 middleware。但有一件事我们一直藏着没动:所有 builtin 工具的 invoke 里,IO 都是直接调 node:fs/promises 和 node:child_process 跑在当前 Node 进程里的。
本地实验没问题。一旦想把这套东西做成"用户上来就能用的产品"或者"暴露给外部 Agent 调用的服务",问题立刻就出来:
bash工具接受到任意字符串然后exec。LLM 写出cat /etc/passwd、rm -rf /tmp/*或者一段恶意脚本,主循环没有任何阻拦。read_file/write_file拿绝对路径。LLM 一句话就能改服务端的package.json,或者读出其他工作目录。- 一个 Node 进程跑 N 个并发会话,所有用户共享同一份文件系统命名空间。隔离全靠"祈祷 LLM 不犯错"。
我们要同时解决两件事:运行安全(LLM 写什么命令、什么路径都不该真把宿主机搞坏)和避免工具重复开发(路径检查、超时、换底这些事不能每个工具都重写一遍)。为此分两步走:
- 把 IO 操作集中到一个沙箱(Sandbox)里。路径检查、命令超时、输出截断这些事统一在沙箱里做一次;换底(本地、容器、microVM)也只换沙箱的具体实现,不动工具。
- 把沙箱抽成接口,让工具只依赖接口。工具拿到的是
Sandbox类型,不知道也不关心底下跑的是LocalSandbox还是E2BSandbox。
落地之后效果:
- builtin 工具只负责协议层(schema、参数解析、返回格式),不直接碰 fs / process
- 同一份 Agent 代码,本地跑 dev 用
LocalSandbox,部署到后端切E2BSandbox起 microVM,主循环和 builtin tools 零行改动
Part 1:抽出 Sandbox 接口 + LocalSandbox 落地
我们先把第三篇结尾的工具骨架复习一遍。两个有代表性的工具长这样(其他 read_file / str_replace / write_file 形态类似):
import { readFile } from "node:fs/promises";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const readFileTool = defineTool({
name: "read_file",
parameters: z.object({ path: z.string() }),
invoke: async ({ path }) => {
const content = await readFile(path, "utf8");
return content || "(empty)";
},
});
const bashTool = defineTool({
name: "bash",
parameters: z.object({ command: z.string() }),
invoke: async ({ command }, { signal, cwd }) => {
const { stdout, stderr } = await execAsync(command, { cwd, signal, timeout: 10_000 });
return (stdout + (stderr ? `\n${stderr}` : "")).trim() || "(no output)";
},
});
我们先从接口入手。
Sandbox 接口的形状
我们要的 Sandbox 是一个"执行环境"的最小抽象:传命令给它跑、传路径让它读 / 写。先把目前 builtin 工具实际用到的能力列一下,得到接口骨架:
interface Sandbox {
exec(command: string, options?: ExecOptions): Promise<ExecResult>;
readFile(path: string): Promise<string>;
writeFile(path: string, content: string, options?: WriteFileOptions): Promise<void>;
}
interface ExecOptions {
cwd?: string;
timeoutMs?: number;
}
interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
}
interface WriteFileOptions {
append?: boolean;
}
接口只覆盖三种能力(执行命令、读、写),刚好对应我们前几篇用到的所有 IO 形态。代码里 grep / glob / listDir 这些都可以基于 exec 自己实现,也可以让 sandbox 自己提供方法暴露给工具,code-artisan 两种都做了,但展开就是机械地加几个方法,篇幅原因这一篇只讲三件最关键的:exec / readFile / writeFile。
为什么 ExecOptions 里放 timeoutMs,而不是塞到 sandbox 构造参数里? 因为不同命令的合理超时差别非常大:grep 几秒钟就要回,npm install 给到 10 分钟都不嫌多。塞构造里就只能取一个折中值,谁来都不舒服。放在 per-call options 里,sandbox 自己有个默认值,工具按需覆盖。
LocalSandbox 实现
Sandbox 是接口,下一步给一个能跑的实现。最直接的就是把上一节那段 node:fs + child_process 的逻辑搬过来,包成 class:
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { dirname } from "node:path";
const execAsync = promisify(exec);
class LocalSandbox implements Sandbox {
constructor(
private readonly cwd: string = process.cwd(),
private readonly timeoutMs: number = 30_000,
) {}
async exec(command: string, options: ExecOptions = {}): Promise<ExecResult> {
try {
const { stdout, stderr } = await execAsync(command, {
cwd: options.cwd ?? this.cwd,
timeout: options.timeoutMs ?? this.timeoutMs,
});
return { stdout, stderr, exitCode: 0 };
} catch (err: any) {
// execAsync 在 exitCode != 0 时会抛错,但 stdout/stderr 仍带在 err 上
// 把它们捞出来再以正常结果返回,让上层(工具)按需展示
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? err.message ?? "",
exitCode: typeof err.code === "number" ? err.code : 1,
};
}
}
async readFile(path: string): Promise<string> {
return readFile(path, "utf8");
}
async writeFile(path: string, content: string, options: WriteFileOptions = {}): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, content, { encoding: "utf8", flag: options.append ? "a" : "w" });
}
}
注意 exec 里那段 try/catch:execAsync 看到非零 exitCode 就抛,但 stdout / stderr 是绑在 error 对象上传过来的。如果直接让错误冒上去,工具拿不到 LLM 想要的 npm test 报错文本。我们把这两段捞出来再以正常 ExecResult 返回,意思是"命令本身可能失败,但 sandbox 的 exec 调用永远成功"。这一招让上层工具只需要看 exitCode 做判断,不必再写一层 try/catch。
builtin 工具改用 Sandbox
主循环把 sandbox 通过 ToolContext 注入:
interface ToolContext {
sandbox: Sandbox;
signal?: AbortSignal;
}
工具变得相当短:
const readFileTool = defineTool({
name: "read_file",
parameters: z.object({ path: z.string() }),
invoke: async ({ path }, ctx) => {
const content = await ctx.sandbox.readFile(path);
return content || "(empty)";
},
});
const bashTool = defineTool({
name: "bash",
parameters: z.object({ command: z.string() }),
invoke: async ({ command }, ctx) => {
const { stdout, stderr, exitCode } = await ctx.sandbox.exec(command);
const output = stdout + (stderr ? `\n${stderr}` : "");
if (exitCode !== 0 && !output.trim()) return `(exit code ${exitCode})`;
return output.trim() || "(no output)";
},
});
工具本体从 7-8 行降到 2-3 行实质代码,而且完全不感知底下是哪种 sandbox。Agent 端的注入也只在创建时多塞一个参数:
const sandbox = new LocalSandbox(workspace);
const agent = new Agent({ apiKey, sandbox });
await agent.run("...", { signal });
agent.run 内部,每次调 tool.invoke(input, ctx) 时把 ctx.sandbox 一起传下去就完了。主循环没多一行非平凡逻辑。
接口落定了,但安全没有完全解决:LocalSandbox 还是裸跑在你的 Node 进程里,LLM 写什么命令就跑什么。下一节我们就把 LocalSandbox 自己先扎紧。
Part 2:给 LocalSandbox 上 3 道安全锁
抽出 Sandbox 接口让"换底"变成可能,但默认实现自己不安全的话,多数用户最先用上的恰好就是这个不安全版本。这一节我们把 LocalSandbox 自己扎紧三件事:路径不许出 workspace、命令不许跑超时、输出不许撑爆 LLM 上下文。
第 1 把锁:workspace 路径牢笼
LocalSandbox 拿到一个 cwd 当工作目录,但 readFile("/etc/passwd") 它照样会读,因为它根本没检查路径在不在 workspace 里。我们要求所有路径调用都必须落在 workspace 内,越界直接报错:
import { resolve, relative, isAbsolute } from "node:path";
class LocalSandbox implements Sandbox {
private readonly workspace: string;
constructor(workspace: string, private readonly timeoutMs: number = 30_000) {
this.workspace = resolve(workspace);
}
// 所有路径调用都先经过这个闸门:转绝对路径 + 比对 workspace 前缀
private 关进牢笼(p: string): string {
const abs = isAbsolute(p) ? resolve(p) : resolve(this.workspace, p);
const rel = relative(this.workspace, abs);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(`path escapes workspace: ${p}`);
}
return abs;
}
async readFile(path: string): Promise<string> {
return readFile(this.关进牢笼(path), "utf8");
}
async writeFile(path: string, content: string, options: WriteFileOptions = {}): Promise<void> {
const abs = this.关进牢笼(path);
await mkdir(dirname(abs), { recursive: true });
await writeFile(abs, content, { encoding: "utf8", flag: options.append ? "a" : "w" });
}
// ...
}
关进牢笼 的两步逻辑值得单独说一下:
- 先 resolve 成绝对路径。如果 LLM 传
"../../etc/passwd",resolve(workspace, "../../etc/passwd")会真把它算成绝对路径,符号链接没绕,..是认真起作用的。这一步等于"模拟 fs 实际会去访问哪个路径"。 - 再用
relative(workspace, abs)看相对位置。如果结果以..开头,说明 abs 不在 workspace 子树里,这正是我们想拦的越界。Windows 平台上 relative 还可能返回另一个盘符的绝对路径(比如 workspace 在 C: 而路径在 D:),所以isAbsolute(rel)也要拦一次。
留个口子:exec 我们故意不做类似的命令拦截。bash 的本质就是"用户允许 LLM 跑任意 shell 命令",去 sanitize 命令字符串既挡不住决心绕的(echo /etc/passwd | xargs cat),又会误伤大量正常命令。命令侧的边界靠后两道锁 + sandbox 自身的进程权限(容器、microVM)来兜,不靠字符串检查。
第 2 把锁:exec 超时
execAsync(..., { timeout }) 自带超时机制,但默认走构造里给的 30 秒,对 npm install 这种远远不够,对 grep 又太松。我们让 ExecOptions 可以覆盖,但要给出一个合理的最大上限,避免 LLM 一句 timeoutMs: 99999999 把整个 Node 进程挂在那儿:
private static readonly MAX_TIMEOUT_MS = 5 * 60 * 1000;
async exec(command: string, options: ExecOptions = {}): Promise<ExecResult> {
const requested = options.timeoutMs ?? this.timeoutMs;
const timeout = Math.min(requested, LocalSandbox.MAX_TIMEOUT_MS);
try {
const { stdout, stderr } = await execAsync(command, {
cwd: options.cwd ?? this.workspace,
timeout,
});
return { stdout, stderr, exitCode: 0 };
} catch (err: any) {
// killed=true 是 child_process 在超时后强杀进程留下的标记
if (err.killed) {
return { stdout: err.stdout ?? "", stderr: `command timeout after ${timeout}ms`, exitCode: 124 };
}
return {
stdout: err.stdout ?? "",
stderr: err.stderr ?? err.message ?? "",
exitCode: typeof err.code === "number" ? err.code : 1,
};
}
}
超时后返回 exitCode: 124(按 GNU coreutils 的约定,timeout 命令超时退出码就是 124),LLM 看到这个数字加 stderr 里的提示就能知道是"被掐了",不会以为是命令本身报了某种业务错。
第 3 把锁:输出体积上限
grep -r 在大仓库里跑一次能吐几十 MB;cat /var/log/big.log 同理。如果直接把这堆字符塞回 messages,分分钟把 LLM 上下文撑爆。exec 这一层先做一道粗截断:
private static readonly MAX_OUTPUT_BYTES = 200_000;
private truncate(s: string): string {
if (s.length <= LocalSandbox.MAX_OUTPUT_BYTES) return s;
const head = s.slice(0, LocalSandbox.MAX_OUTPUT_BYTES - 200);
return `${head}\n\n[... truncated, ${s.length - head.length} chars omitted ...]`;
}
// 在 exec 的两个 return 分支里都包一下:
// return { stdout: this.truncate(stdout), stderr: this.truncate(stderr), exitCode: 0 };
阈值定 200K chars 是个工程取舍:模型上下文一般够吃,但显著大于 99% 普通命令的输出。再大就一定是日志或 dump 类操作,由 LLM 自己用 head / tail 收口更合理。
值得提醒一下:LocalSandbox 即便上了这三道锁也不该被当成"安全沙箱"用。命令行随手一句 apt install 也能改系统状态、curl ... | sh 也能拉远端脚本下来跑。它最多算"误操作护栏",真隔离要靠下一节的 E2BSandbox。
Part 3:E2BSandbox · 把同一个 Agent 搬进 microVM
要给"用户上来就敢用"的 Coding Agent 一份真隔离,工具调用就不能跑在自己的 Node 进程里。一个直接的选择是 E2B:它每个 sandbox 是一台 microVM(Firecracker),冷启 1-2 秒,文件系统、进程、网络都独立,外面挂掉了也不影响主进程。
接入这件事如果 builtin 工具是直接调 node:fs 写的,要改 8-10 个文件。但我们上一节已经把 IO 抽到 Sandbox 接口后面了,所以这一节真的只需要新加一个 class:E2BSandbox implements Sandbox。
完整实现
import { Sandbox as E2BSDK } from "@e2b/code-interpreter";
class E2BSandbox implements Sandbox {
constructor(private readonly sdk: E2BSDK) {}
static async create(timeoutMs: number = 10 * 60 * 1000): Promise<E2BSandbox> {
// E2B 一个 sandbox 默认 5 分钟空闲就被回收,长会话场景把 timeout 拉到 10 分钟
const sdk = await E2BSDK.create({ timeoutMs });
return new E2BSandbox(sdk);
}
async exec(command: string, options: ExecOptions = {}): Promise<ExecResult> {
const result = await this.sdk.commands.run(command, {
cwd: options.cwd ?? "/home/user",
timeoutMs: options.timeoutMs ?? 30_000,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
};
}
async readFile(path: string): Promise<string> {
return this.sdk.files.read(path);
}
async writeFile(path: string, content: string, options: WriteFileOptions = {}): Promise<void> {
if (options.append) {
const existing = await this.sdk.files.read(path).catch(() => "");
await this.sdk.files.write(path, existing + content);
} else {
await this.sdk.files.write(path, content);
}
}
}
整个 class 不到 30 行,几乎是 Sandbox 接口逐方法对到 E2B SDK 的转译。两点单独说一下:
为什么不在 E2BSandbox 里做路径牢笼? 因为 microVM 自己就是隔离边界。每个会话起一台独立的 sandbox,里头跑什么命令、读哪个路径,只影响这台一次性的小 VM。E2B 的实现层等于把"隔离"从应用层(path 字符串检查)下沉到了 VM 层(VMM 隔离),上层 sandbox 类自然不用再操这份心。
writeFile 的 append 为什么要先 read 再写? E2B SDK files.write 是覆盖语义,没有 append 模式。我们手动模拟:先 read 旧内容(不存在就当空串),拼上新内容,再 write 回去。
切换的代价:零行业务代码
调用侧只改一行:
// 本地开发
const sandbox = new LocalSandbox("/tmp/workspace");
// 部署到后端
const sandbox = await E2BSandbox.create();
// 后面完全一样
const agent = new Agent({ apiKey, sandbox });
await agent.run(input, { signal });
Agent、主循环、所有 builtin 工具、Part 4 文章里的 4 个 middleware,一行都没动。这就是 Part 1 把接口抽出去的回报。
Part 4:Sandbox 装饰器 · 给已有 sandbox 套一层 audit / quota / dry-run
到 Part 3 为止,"换底"这件事已经做完了。这一节我们处理另一类需求:我已经有 LocalSandbox 或 E2BSandbox 在跑了,现在想给所有 IO 加一道横切的拦截。比如:
- 把每次
exec/readFile/writeFile都记到审计日志里 - 限制单次会话总写入字节数,超了就拒
- 让 sandbox 进入"演练模式":所有写操作不真落地,只 log
最贵的做法是把这套逻辑塞进每个 sandbox 实现里。最便宜的做法是给 sandbox 接口加一层装饰器(Decorator Pattern):装饰器自己也实现 Sandbox,构造时拿一个内部 sandbox,每个方法在转发前 / 转发后插入自己的逻辑。
例:AuditingSandbox
class AuditingSandbox implements Sandbox {
constructor(
private readonly inner: Sandbox,
private readonly onAudit: (entry: AuditEntry) => void,
) {}
async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
const start = Date.now();
const result = await this.inner.exec(command, options);
this.onAudit({
kind: "exec",
command,
exitCode: result.exitCode,
bytes: result.stdout.length + result.stderr.length,
durationMs: Date.now() - start,
});
return result;
}
async readFile(path: string): Promise<string> {
const content = await this.inner.readFile(path);
this.onAudit({ kind: "readFile", path, bytes: content.length });
return content;
}
async writeFile(path: string, content: string, options?: WriteFileOptions): Promise<void> {
await this.inner.writeFile(path, content, options);
this.onAudit({ kind: "writeFile", path, bytes: content.length, append: !!options?.append });
}
}
type AuditEntry =
| { kind: "exec"; command: string; exitCode: number; bytes: number; durationMs: number }
| { kind: "readFile"; path: string; bytes: number }
| { kind: "writeFile"; path: string; bytes: number; append: boolean };
装饰器拿到内部 sandbox 后,每个方法的形态都是"调一下内部、记一下事、原样返回"。它自己也实现 Sandbox 接口,所以可以像普通 sandbox 一样塞回 agent:
const inner = new LocalSandbox("/tmp/workspace");
const sandbox = new AuditingSandbox(inner, (entry) => {
console.log("[audit]", entry);
});
const agent = new Agent({ apiKey, sandbox });
Agent 完全不知道自己拿到的是一个装饰过的 sandbox。这就是装饰器模式相对继承 / 修改的关键优势:新能力是通过组合加进来的,老代码(agent、tool、其他 sandbox 实现)零感知。
装饰器 vs middleware:什么时候用哪个
第四篇我们已经用 middleware 解过类似的需求,比如可以写一个 auditMiddleware 在 afterToolUse 里把工具调用结果记下来。那为什么这一节要再引入一种"装饰器"机制?
两者的有效粒度不一样:
- Middleware 在 agent loop 这一层:拿到的是"哪个工具被调用了、入参是什么、最终返回了什么"。如果一个工具自己内部调了 3 次 sandbox.exec + 2 次 sandbox.readFile,middleware 完全看不到,只能看到这个工具最终的一个返回字符串。
- Sandbox 装饰器在 IO 这一层:拿到的是每一次 fs / process 调用。能看到工具内部真正动了几次 IO、每次的精确字节数、命令字符串。
要做"会话总写入配额"这种单位是字节、按 IO 次数累加的事,sandbox 装饰器更合适。要做"某种工具被调用前先拦下来确认"这种单位是工具次数、按 tool_use 算的事,middleware 更合适。两者并不互相替代,而是分别管住了同一个 agent 的两个不同截面。
写在最后
到这一篇结束,code-artisan 的执行层完全独立出去了:
- Part 1:抽出
Sandbox接口 +LocalSandbox落地 · builtin 工具瘦身到只剩协议 - Part 2:给
LocalSandbox上 3 道安全锁 · 路径牢笼 / 命令超时 / 输出截断 - Part 3:
E2BSandbox落地 · 主循环和工具零行改动,换底成本是新写一个 30 行的 class - Part 4:通过 Sandbox 装饰器 把横切关注点通过组合加进来,不动既有实现
回头看的话,这一篇和前面四篇的关系是这样的:02 / 03 把 agent 主循环和工具系统搭出来,04 给主循环加横切(middleware),05 给工具背后的 IO 加横切(sandbox + decorator)。两个截面、两套机制,但用的都是"接口 + 注入 + 装饰组合"这条线。
下一篇 06 我们换个方向:skills 系统。code-artisan 里有一份 ~/.agents/skills/ 目录,每个 skill 是一个带 frontmatter 的 markdown 文件(name / description / 主体),agent 在适合的时机会自动加载相关 skill 注入 system prompt。这个机制让 Agent 在不动核心代码的前提下学会新领域,比如"如何写 git commit"、"团队的代码风格"、"特定 API 的调用约定"。06 会从最小骨架开始,一直拆到 skill 触发判定怎么做才不爆 token。
完整代码在 GitHub:github.com/lhz960904/c…(这一篇拆的核心文件是 packages/agent/sandbox/ 和 packages/agent/tools/tool.ts)。如果觉得这个项目对你有帮助,欢迎点个 star ⭐。