代码智能体生成的程序应当被视为不可信输入,不能直接在宿主机执行。本文用 Docker 构建一个可重复、无网络、资源受限的一次性环境,并通过 Node.js 完成创建、执行、超时终止、产物提取和容器清理。
先确定沙箱的任务边界
当智能体可以调用测试、构建或代码解释器时,它实际获得的是一条远程代码执行通道。即使没有恶意,生成代码也可能递归创建进程、占满内存、无限输出日志,或者误删工作目录。
本文实现的沙箱遵循几个约束:
- 每个任务创建一个新容器,结束后立即删除;
- 运行环境来自预先构建的固定镜像;
- 不挂载宿主机源码目录,也不暴露 Docker Socket;
- 根文件系统只读,仅
/workspace是临时可写目录; - 默认关闭网络,并限制 CPU、内存、进程数和执行时间;
- 任务只能通过标准输入接收代码,通过指定目录交付产物。
示例执行单文件 Node.js 任务。真实项目如果需要第三方依赖,应将经过审核的依赖预装进镜像,或为项目构建专用镜像,而不是在运行阶段开放网络执行 npm install。
固化一次性执行镜像
先准备 Dockerfile:
FROM node:22-bookworm-slim
RUN groupadd --gid 10001 sandbox \
&& useradd --uid 10001 --gid 10001 --no-create-home \
--shell /usr/sbin/nologin sandbox
COPY --chmod=0555 run.sh /sandbox/run.sh
USER 10001:10001
WORKDIR /workspace
ENTRYPOINT ["/sandbox/run.sh"]
再创建 run.sh:
#!/bin/sh
set -eu
mkdir -p /workspace/output
cat > /workspace/task.mjs
node /workspace/task.mjs
构建镜像:
docker build -t local/code-sandbox:node22 .
运行脚本通过标准输入接收代码,避免把宿主机目录绑定到容器。生产环境还应使用镜像摘要锁定基础镜像,而不只依赖可能漂移的标签;同时对镜像进行漏洞扫描、签名和来源校验。
组合只读文件系统、网络与资源限制
单独使用容器并不等于完成隔离。创建任务时需要组合多项限制:
| 控制项 | Docker 参数 | 作用 |
|---|---|---|
| 只读根目录 | --read-only | 阻止任务修改镜像文件系统 |
| 临时工作区 | --tmpfs /workspace:... | 提供有容量上限的内存文件系统 |
| 禁止网络 | --network none | 避免下载代码、扫描内网或外传数据 |
| 内存限制 | --memory、--memory-swap | 限制内存及交换空间使用量 |
| CPU 限制 | --cpus | 避免任务长期占满宿主机 CPU |
| 进程限制 | --pids-limit | 降低 Fork Bomb 风险 |
| 删除能力集 | --cap-drop ALL | 移除任务通常不需要的 Linux capabilities |
| 禁止提权 | no-new-privileges | 阻止进程通过 setuid 等方式获得更多权限 |
tmpfs 的 nosuid、nodev 和 noexec 也值得开启。这里的 JavaScript 由镜像中的 Node.js 读取执行,不需要直接执行工作区内的本地二进制。若任务必须运行编译出的原生程序,就不能简单使用 noexec,需要重新评估执行目录和攻击面。
资源配额只能限制容器消耗,不能负责业务超时。任务可能在较低资源占用下无限等待,因此管理器仍要主动计时并终止容器。
用 Node.js 管理任务生命周期
下面的 manager.mjs 只依赖 Node.js 标准库和本机 Docker CLI。它限制输入文件大小,创建容器后附加标准输入,最多保留 1 MiB 日志,并在超时时调用 docker kill。容器停止后,无论任务成功与否,管理器都会先尝试复制产物,最后强制删除容器。
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
const IMAGE = 'local/code-sandbox:node22';
const MAX_SOURCE_BYTES = 1024 * 1024;
const MAX_LOG_BYTES = 1024 * 1024;
const TIMEOUT_MS = 10_000;
function docker(args) {
return new Promise((resolve, reject) => {
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stdout = [];
const stderr = [];
child.stdout.on('data', chunk => stdout.push(chunk));
child.stderr.on('data', chunk => stderr.push(chunk));
child.on('error', reject);
child.on('close', code => {
const result = {
code,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8')
};
if (code === 0) resolve(result);
else reject(new Error(`docker ${args[0]} failed: ${result.stderr}`));
});
});
}
function startAttached(containerId, source) {
return new Promise((resolve, reject) => {
const child = spawn(
'docker',
['start', '--attach', '--interactive', containerId],
{ stdio: ['pipe', 'pipe', 'pipe'] }
);
let logs = Buffer.alloc(0);
let timedOut = false;
const append = chunk => {
if (logs.length >= MAX_LOG_BYTES) return;
const remaining = MAX_LOG_BYTES - logs.length;
logs = Buffer.concat([logs, chunk.subarray(0, remaining)]);
};
child.stdout.on('data', append);
child.stderr.on('data', append);
child.stdin.on('error', error => {
if (error.code !== 'EPIPE') reject(error);
});
child.on('error', reject);
const timer = setTimeout(() => {
timedOut = true;
const killer = spawn('docker', ['kill', containerId], {
stdio: 'ignore'
});
killer.on('error', () => {});
}, TIMEOUT_MS);
child.on('close', code => {
clearTimeout(timer);
resolve({ code, timedOut, logs: logs.toString('utf8') });
});
child.stdin.end(source);
});
}
async function runTask(sourceFile, artifactDir) {
const info = await stat(sourceFile);
if (!info.isFile() || info.size > MAX_SOURCE_BYTES) {
throw new Error('任务源码不是普通文件,或超过 1 MiB');
}
const source = await readFile(sourceFile);
const name = `code-task-${randomUUID()}`;
await rm(artifactDir, { recursive: true, force: true });
await mkdir(artifactDir, { recursive: true });
const created = await docker([
'create', '--name', name,
'--read-only',
'--network', 'none',
'--cpus', '1',
'--memory', '256m',
'--memory-swap', '256m',
'--pids-limit', '64',
'--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges',
'--tmpfs', '/workspace:rw,nosuid,nodev,noexec,size=64m,uid=10001,gid=10001,mode=0700',
IMAGE
]);
const containerId = created.stdout.trim();
try {
const execution = await startAttached(containerId, source);
const inspected = await docker([
'inspect', '--format', '{{json .State}}', containerId
]);
const state = JSON.parse(inspected.stdout);
await docker([
'cp', `${containerId}:/workspace/output/.`, artifactDir
]);
return {
exitCode: state.ExitCode,
oomKilled: state.OOMKilled,
timedOut: execution.timedOut,
logs: execution.logs
};
} finally {
await docker(['rm', '--force', containerId]).catch(() => {});
}
}
const sourceFile = path.resolve(process.argv[2] ?? 'task.mjs');
const artifactDir = path.resolve(process.argv[3] ?? 'artifacts');
const result = await runTask(sourceFile, artifactDir);
console.log(JSON.stringify(result, null, 2));
if (result.timedOut || result.oomKilled || result.exitCode !== 0) {
process.exitCode = 1;
}
准备一个可执行的 task.mjs:
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
function add(a, b) {
return a + b;
}
assert.equal(add(2, 3), 5);
await writeFile(
'/workspace/output/result.json',
JSON.stringify({ passed: true, value: add(20, 22) })
);
console.log('task completed');
执行任务:
node manager.mjs task.mjs artifacts
cat artifacts/result.json
产物同样属于不可信数据。后续系统不应直接执行其中的脚本,也要限制文件数量、总体积和类型;若产物会被解压,还需检查符号链接与路径穿越问题。
Docker 隔离的能力边界
Docker 容器与宿主机共享内核,它不是天然的强多租户安全边界。内核漏洞、容器运行时漏洞和错误配置都可能扩大影响,尤其不能把 /var/run/docker.sock 放进沙箱:能够控制 Docker 守护进程,通常就接近获得宿主机控制权。
生产环境还应补充以下措施:
- 使用 rootless Docker 或 user namespace,降低容器 UID 对宿主机的映射权限;
- 配置并测试 seccomp、AppArmor 或 SELinux 策略;
- 将执行节点放在独立网络和专用机器池,不与核心服务混部;
- 对镜像、输入、日志和产物设置存储配额与保留期限;
- 由外层作业系统处理 Docker CLI 卡死、节点失联和孤儿容器;
- 对高风险、多租户任务考虑 gVisor、Kata Containers 或 microVM;
- 记录镜像摘要、任务来源、资源用量、退出状态和清理结果,便于审计。
网络关闭也不代表绝对没有信息泄露。任务仍可能通过运行时间、资源竞争等侧信道传递有限信息,因此不要把不同信任等级的敏感任务放在同一个执行节点上。
总结
搭建代码智能体执行环境时,重点不是简单调用一次 docker run,而是建立完整的任务生命周期:
- 用固定镜像固化解释器、工具链和依赖;
- 使用只读根文件系统与限额
tmpfs控制写入范围; - 默认关闭网络,并限制 CPU、内存和进程数量;
- 在管理层实现超时终止、状态检查、产物提取和最终清理;
- 把源码、日志和构建产物都视为不可信输入;
- 承认 Docker 共享内核的边界,并在生产环境增加主机、内核和调度层防护。
一次性容器能显著减少误操作和普通恶意代码的影响范围,但它只是分层防御中的一层,不应被当成无需维护的绝对沙箱。
评论