代码智能体生成的程序应当被视为不可信输入,不能直接在宿主机执行。本文用 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 等方式获得更多权限

tmpfsnosuidnodevnoexec 也值得开启。这里的 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 共享内核的边界,并在生产环境增加主机、内核和调度层防护。

一次性容器能显著减少误操作和普通恶意代码的影响范围,但它只是分层防御中的一层,不应被当成无需维护的绝对沙箱。