Skip to content

code-sandbox 服务 — OS 级代码沙箱 ​

面向:要为 Aalis 写一个代码沙箱后端(provider),或在自己的插件里安全执行不可信代码(consumer)的第三方作者。

code-sandbox 把「在 OS 隔离下执行不可信代码」收口成一个独立服务。注册名是字符串 'code-sandbox',消费方经 codeSandbox.use 调用;契约包是 @aalis/api-code-sandbox。

它为什么单独成服务,而不塞进通用的 process?「在 OS 隔离里跑不可信代码」是 code_runner 独有的诉求。package-manager、scheduler 等同样会跑子进程的插件都不需要它,把它塞进共享的 process 契约会污染公共面。

未来可以有 -docker、-wasm、-e2b 等不同机制的实现,它们经同一个 code-sandbox 服务名,按优先级或偏好相互替换。

参考实现是 @aalis/plugin-code-sandbox-os(Linux 用 bubblewrap,macOS 用 sandbox-exec)。目前唯一的消费方是 @aalis/plugin-tool-code-runner。


1. 契约:CodeSandboxService ​

契约由 CodeSandboxService 定义:

ts
export interface CodeSandboxService {
  /** 本机是否有可用沙箱后端(无 → 调用方 fail-closed) */
  readonly available: boolean;
  /** 当前后端标识(诊断/展示用,如 'bwrap' / 'seatbelt' / 'none') */
  readonly backend: string;
  /**
   * 在沙箱内运行命令并等待结束。返回 ExecResult;
   * 与 ProcessService.execFile 一致——非零退出会 reject(错误对象挂 `.result`)。
   */
  run(req: SandboxRunRequest): Promise<ExecResult>;
}

请求体 SandboxRunRequest:

ts
export interface SandboxRunRequest {
  cmd: string;                                  // 解释器路径/名,如 python3 / node
  args: string[];                               // 命令参数(含脚本路径)
  cwd?: string;                                 // 工作目录(本地绝对路径)
  env?: Record<string, string | undefined>;    // env 白名单:沙箱内仅这些键可见,其余宿主 env 清零(防 secrets 泄漏)
  timeout?: number;                             // 超时(毫秒)
  signal?: AbortSignal;                         // 中止信号:中止时终止沙箱内进程,语义同 process 契约的 SpawnOptions.signal
  policy: SandboxPolicy;                        // 隔离策略
}

signal 的语义与 process 服务 §2.2 的 SpawnOptions.signal 相同:调用时已中止则不起进程,run() 以 signal.reason reject、不带 .result;运行中中止时 POSIX 下对进程组先 SIGTERM、宽限后 SIGKILL,Windows 下立即结束整棵进程树,run() 按非零退出 reject,错误对象上挂 .result。经 process 服务起进程的 provider 把它原样交给 execFile 即可。

隔离策略 SandboxPolicy:

ts
export interface SandboxPolicy {
  /** 可读绝对目录白名单(信息性;v1 后端读放开,预留给更严格的读限定实现) */
  fsRead: string[];
  /** 可写绝对目录白名单(如 workspace + 临时目录);此外一律只读/拒写 */
  fsWrite: string[];
  /** 子进程网络:'deny'=断网(推荐默认);'allow'=放开(粗粒度,无法按域名过滤) */
  network: 'deny' | 'allow';
}

返回值 ExecResult 复用 process 契约,结构是 { code, signal, stdout, stderr, truncated? }。非零退出会 reject,错误对象上挂 .result——这与 ProcessService.execFile 的约定一致,详见 §5 错误处理。

契约包的 package.json 没有 aalis.service 块,因为它是契约包(keywords: ["aalis","aalis-api"])——导出 interface、type 与描述符 codeSandbox(defineService<CodeSandboxService>('code-sandbox')),不在运行时注册服务。真正的 provides: [codeSandbox] 由实现包声明(见 §4)。消费方在 uses 里声明 codeSandbox: optional(codeSandbox),经 codeSandbox.use 调用;服务未就绪或实现未安装时 codeSandbox.present 为 false。


2. 谁提供 / 谁消费 ​

角色包关键 API
契约@aalis/api-code-sandbox导出 interface / type / codeSandbox 描述符
参考实现@aalis/plugin-code-sandbox-osprovide.use(codeSandbox, …)
唯一消费方@aalis/plugin-tool-code-runnercodeSandbox.present → codeSandbox.use.run(...)

参考实现有几个关键设计,写 provider 时值得参照:

  • 不直接 import node:child_process / node:fs。它把不可信代码包成沙箱启动器命令后,经现有的 process 服务来 spawn(uses required = ['process'])。OS 探测同样经 process 服务做功能性试跑。
  • 功能性探测(probeBackend):经 process 服务真跑一次最小沙箱命令(macOS sandbox-exec -p '(version 1) (allow default)' true、Linux bwrap --ro-bind / / --dev /dev --proc /proc --unshare-all true,与正式运行一样挂 /dev 与 /proc,超时 5000ms),退出码为 0 才算可用。这比「命令是否存在」更强——一次同时覆盖存在性,以及 Linux unprivileged userns 是否真能用。探测失败(未装 bwrap、userns 被禁、容器里挂不了 /proc 或超时)时,backend = 'none'、available = false。探测带本次激活的 lifecycle.use.signal:启动器挂住时停用或停机会中止探测进程,apply 随之抛出 abort 原因、不记成「没有沙箱后端」,停用不必等满 5000ms 的探测超时,也不会超过宽限仍没停下、拖住重新激活。
  • 命令改写(wrapForSandbox(),纯逻辑、便于单测):把 (cmd, args) 改写为「经沙箱启动器运行」,全程 shell-free,不拼 shell 字符串。

3. 写一个 provider ​

最小契约 ​

你必须实现 CodeSandboxService 的三个成员:available(getter)、backend(getter)、run()。

写 provider 时必须守住下面几条不变量:

  1. available === false 时 run() 不应被调用,但仍要防御性地抛错,而不是裸跑——参考实现在 backend === 'none' 时,run() 直接 throw。
  2. 强制 policy:fsWrite 之外只读或拒写、network === 'deny' 必须真正断网、env 之外的宿主环境变量必须清零。这是契约的安全语义,consumer 依赖它来防「写出工作区 / 联网外泄 / secrets 泄漏」。
  3. run() 的错误约定要对齐 ExecResult:非零退出 reject,错误对象挂 .result(见 §5)。
  4. 转交 req.signal:不转交时停止键与执行器的中止都停不掉沙箱里的脚本(第一方调用方不设超时)。

注册(provide) ​

参考实现在 apply 里先探测后端,再注册单例:

ts
import { codeSandbox } from '@aalis/api-code-sandbox';
import { processService } from '@aalis/api-process';
import { definePlugin, logger, provide } from '@aalis/core';

export default definePlugin({
  name: '@acme/plugin-example',
  provides: [codeSandbox],
  uses: { provide, logger, process: processService },
  async apply({ provide, logger, process }) {
    const log = logger.use.child('my-sandbox');
    // 探测与 run 都经 process.use(process 服务),别直接碰 child_process
    log.info(`backend probe via ${process.present ? 'process' : 'missing'}`);
    provide.use(codeSandbox, {
      available: true,
      backend: 'example',
      async run() {
        throw new Error('example sandbox: 未实现');
      },
    });
  },
});

同名多实现时,消费方选中哪一家按「本插件偏好 > 全局偏好 > priority > 注册顺序」——这是纯按名选择,没有能力维度的匹配(详见 服务模型)。

双源元数据要同步 ​

provides / uses 有两套独立来源,部署时都要写对(见 清单元数据):

  • 代码导出:provides: [codeSandbox]、uses: { process: processService }。
  • package.json 的 aalis.service:
jsonc
{
  "keywords": ["aalis", "aalis-plugin"],
  "aalis": {
    "service": {
      "required": ["process"],
      "provides": ["code-sandbox"]
    }
  }
}

WARNING

keywords 必须包含 aalis-plugin。npm 部署时,加载器靠这个关键词门识别可加载插件;缺了它,插件不会被识别为可加载插件。

最小可编译骨架 ​

ts
import { codeSandbox } from '@aalis/api-code-sandbox';
import type { CodeSandboxService, SandboxRunRequest } from '@aalis/api-code-sandbox';
import { type ExecResult, type ProcessService, processService } from '@aalis/api-process';
import { definePlugin, provide } from '@aalis/core';

class MyCodeSandboxService implements CodeSandboxService {
  proc!: ProcessService;
  _ok = true;
  get available() {
    return this._ok;
  }
  get backend() {
    return this._ok ? 'mybackend' : 'none';
  }

  async run(req: SandboxRunRequest): Promise<ExecResult> {
    if (!this._ok) throw new Error('code-sandbox: 无可用后端,run() 不应被调用');
    // 必须强制:fsWrite 外只读、network==='deny' 断网、env 外宿主变量清零。
    return this.proc.execFile(req.cmd, req.args, { cwd: req.cwd, timeout: req.timeout, signal: req.signal });
  }
}

export default definePlugin({
  name: '@example/plugin-code-sandbox-mybackend',
  provides: [codeSandbox],
  uses: { provide, process: processService },
  async apply({ provide, process }) {
    const proc = process.use;
    const impl = new MyCodeSandboxService();
    impl.proc = proc;
    impl._ok = true;
    provide.use(codeSandbox, impl);
  },
});

4. 标准消费方式 ​

code-sandbox 通常是可选依赖:consumer 把它声明在 uses optional,运行时取不到就 fail-closed。code_runner 就是这么做的:

ts
uses: { storage, proc: processService, codeSandbox: optional(codeSandbox) };

先看 present + fail-closed ​

业务句柄 codeSandbox.use 是活的,选择、缺席与固定同一家的规则见 选择层与业务层。它是可选依赖,code_runner 在每次工具调用前经 createRunnerConfig 取 codeSandbox.present ? codeSandbox.use : undefined,再在 runCode 里检查 available:

ts
// 节选
const policy = config.sandbox
  ? { fsRead: [config.cwd], fsWrite: [config.cwd, tmp.path], network: config.sandbox.network }
  : undefined;

// fail-closed:要求隔离但本机无可用后端 → 拒绝执行(不静默裸跑)
if (policy && !config.codeSandbox?.available) {
  return { exitCode: -1, stdout: '', stderr: '',
    error: `代码执行已禁用(沙箱不可用,backend=${config.codeSandbox?.backend ?? 'none'}):…` };
}

// signal 是执行器给这次调用的中止信号(工具调用上下文的 callCtx.signal),两条路径都交下去;
// 两个工具声明可转后台,等多久由执行器的让出时限管,不另设超时
const result = policy
  ? await config.codeSandbox!.run({ cmd: interpreter, args: [...extraArgs, scriptPath],
      cwd: config.cwd, env, signal, policy })
  : await proc.execFile(interpreter, [...extraArgs, scriptPath], { cwd: config.cwd, env, signal });

要点是,policy 应当用本次运行实际解析出的本地路径来构造:config.cwd 来自 storage.resolveLocalPath,tmp.path 来自 proc.makeTempDir。这样路径随运行时变化,将来 storage 按 session 命名空间化后,会自动变成 per-session 隔离。

WARNING

要求隔离时不提供回退。consumer 必须自己实现 fail-closed:available 为假就直接拒绝执行,不要回退到 proc.execFile 裸跑。code_runner 只有在 owner 显式把配置设成 sandbox.mode='none' 时,才走无 policy 的裸进程分支,并在每次启动时告警。

错误边界 ​

run() 与 ProcessService.execFile 同约定——非零退出 reject、错误对象挂 .result;被中止的也按非零退出 reject。code_runner 的处理如下(节选):

ts
} catch (err) {
  const e = err as Error & { result?: { code; signal; stdout; stderr } };
  if (e.result) {
    const output = { exitCode: e.result.code ?? -1, stdout: …, stderr: … };
    if (signal?.aborted) {
      // 被信号结束的才说被中止(中止宽限到点的 SIGKILL 也算);不是被信号结束的按实际退出回报
      if (e.result.signal !== null) return { ...output, aborted: true, message: '代码已中止' };
      return { ...output, note: '调用已中止' };
    }
    return output;
  }
  // 没有退出结果:进程根本没起来,或已中止时 spawn 同步抛出
  if (signal?.aborted) return { exitCode: -1, stdout: '', stderr: '', aborted: true, message: '代码已中止' };
  return { exitCode: -1, stdout: '', stderr: '', error: e.message };
}

5. 能力 / 风险 → 影响 ​

code-sandbox 是 Aalis 安全模型里对抗不可信代码的一道 OS 级隔离,写 provider 和 consumer 都要了解它的边界。威胁模型是:LLM 生成的脚本属于不可信代码,应把 LLM 视为可能被提示注入策反的内部角色(安全模型 §1)。code_runner 的两个工具 run_python / run_javascript 都标了 visibility: 'restricted',受 权限两轴 闸门约束。

参考实现 code-sandbox-os 强制以下三条:

  • 写限定:只放行 policy.fsWrite,其余只读(Seatbelt 用 (deny default) + (allow file-write* (subpath …)) 仅白名单;bwrap 用 --ro-bind / / + --bind 写白名单)。
  • 网络粗粒度开关:policy.network === 'deny' 时默认断网(Seatbelt (deny network*)、bwrap --unshare-all 含 net 命名空间隔离);只有 'allow' 才放开,且无法按域名过滤。注意:这与 owner 自己的网络出口 safeFetch(SSRF 防护)是两套机制;沙箱内 'allow' 的子进程网络不经 safeFetch 的内网防护,能联网就能打内网。
  • env 清零、仅留白名单:sandbox-exec … env -i <白名单> / bwrap --clearenv --setenv,防止宿主 secrets 泄漏给不可信代码。consumer 传 env 时要明确只放安全键(code_runner 的 safeEnv() 只保留 PATH、LANG 等)。sandbox.mode=none 时执行走 process 服务,它的 env 是替换语义,白名单同样生效,只是没有文件与网络隔离。

provider 实现必须落实这三条强制语义;consumer 也必须传一个收紧的 policy(fsWrite 最小化、默认 network: 'deny'、env 白名单),这些语义才有意义。


6. 边界与限制(v1 真实限制) ​

契约明写了 v1 语义,安全模型 §4 也单列了这道边界:

  • 不防「读取本机其它文件」:v1 后端读放开(解释器需要系统库),policy.fsRead 目前是信息性的,预留给将来更严格的读限定实现。要防读需要更强的 WASM / microVM 后端。因此不要把不该被读到的 secrets 放进沙箱进程能访问的路径,不要依赖沙箱阻止读取。
  • 不是强隔离:这是 OS 级减速带,不防内核漏洞 / 提权 / sandbox 逃逸,不等于 gVisor / 虚拟机级隔离。
  • storage 不是沙箱:resolveLocalPath(uri) 把 storage URI 解析成 OS 绝对路径交给子进程后,storage 那层的 root 校验对子进程毫无约束力——真正的隔离全靠这里的 OS 沙箱(见 安全模型 §5、存储 URI 文法)。
  • 平台覆盖:参考实现只覆盖 macOS(seatbelt)/ Linux(bwrap)。Windows 等其它平台 backend='none',code_runner auto 模式会 fail-closed。
  • env 不在 run() 外层重复传:参考实现把 env 白名单注入交给 wrapper(env -i / --clearenv --setenv),外层经 process 服务 spawn 时不再传 env——外层启动器进程继承宿主 env 无妨,因为 wrapper 已为内层不可信子进程清空。写 provider 时不要把宿主 env 透传到内层。
  • 中止与超时打的是外层启动器所在的进程组。Seatbelt 下 sandbox-exec → env → 解释器 逐级 exec,是同一个进程,组信号直接打到脚本;主动脱离进程组的后代(Python 的 os.setsid、Node 以 detached: true 起的子进程等守护化写法)不在组里,停不掉。bwrap 带 --new-session,内层进程不在我们的进程组里,组信号打到外层 bwrap;它同时带 --die-with-parent 与 --unshare-all(含 pid 命名空间),外层退出后内层随之结束,脱离进程组的后代也在同一个命名空间里。

7. 交叉链接 ​

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布