ThomasHartDev/airlock
GitHub: ThomasHartDev/airlock
为不可信或 Agent 生成的代码提供零凭证、资源受限且输出需通过后置条件验证的临时隔离执行环境。
Stars: 0 | Forks: 0
# airlock
为不可信或由 agent 编写的代码提供临时的、零凭证的、可自我验证的执行环境。
## 演示内容
airlock 是运行不可信代码的安全方式:无论是 LLM 生成的代码片段、插件,还是用户提交的函数。本仓库使用 TypeScript 从头构建了这一基础组件。其保证在于:除非执行过程保持在资源上限内,且其输出满足调用者提供的后置条件,否则调用者绝对不会读取到任何输出。不可信代码在被证明正确之前均被视为有罪,而你必须在接触该值之前通过类型系统证明这一点。
第一部分是实现契约以及强制执行该契约的进程内运行器。第二部分添加了 `run(code, opts)`,它在一个全新的 `node:vm` 上下文中执行不可信源代码,且不包含任何环境权限。第三部分添加了 `runInWorker(code, opts)`:在具有冻结的全局变量和空 env 的 `worker_threads` 隔离层上实现相同的契约。本部分强化了**资源限制**层:带有硬终止的挂钟时间期限、V8 堆上限以及输出大小上限,从而防止失控的任务在时间、内存或数兆字节的返回值上卡死宿主机。后续部分将添加基于 Docker 的执行层以及不断扩充的文档化逃逸尝试测试套件。
## 演示概念
- **验证门控结果。** 输出值只能通过可辨识联合类型的 `ok` 变体来访问,因此在调用处根本无法表示未经验证的运行。
- **后置条件契约。** 当调用者提供的断言对输出成立时,该运行才被信任,这是一种应用于不可信代码的契约式设计检查。
- **具有协作式取消的期限强制执行。** 内部计时器与任务竞速,并中止其运行所在的 `AbortSignal`,该信号与调用者拥有的任何信号组合在一起。
- **通过终止 worker 实现的硬抢占。** 在隔离层上,挂钟时间期限和调用者中止都会调用 `worker.terminate()`,从而回收 OS 线程,而不是放弃卡死的任务。
- **资源隔离与上限。** 三个独立的预算限制了每一次运行:挂钟时间、V8 老生代堆(`maxOldGenerationSizeMb`)以及测量的 UTF-8 输出大小(`maxOutputBytes`)。每一个都映射到一种明确的结果状态(`timeout`、`out-of-memory`、`output-too-large`)。
- **预算控制的输出计量。** 输出大小通过带有提前退出预算的方式进行遍历,因此循环或巨大的结构无法挂起宿主机计量器,并且过大的值永远不会作为可信结果到达后置条件。
- **全面的错误处理。** 抛出的错误、超时、断言失败、OOM(内存溢出)以及超大输出都是结果联合类型中的值,而不是异常,因此没有任何失败模式会作为 rejection 逃脱。
- **能力安全框架。** 任务仅接收其所需的中止能力;`run` 将此扩展到源代码,源代码只能看到零环境权限,以及仅在 `grant` 对象中传递的能力。
- **零凭证不变式。** 不可信源代码在 `node:vm` 上下文中运行,该上下文不携带任何宿主机权限:没有 `process`、`process.env`、`require`、`fetch`、计时器或 `Buffer`。该不变式是一个命名的拒绝列表,在上下文构建时进行探测,因此如果权限发生泄漏,运行将实施故障关闭。
- **Realm 隔离及其局限性。** 该上下文拥有自己的一套 ECMAScript 内置对象,因此 `globalThis` 上的宿主机机密是不可达的,且沙盒自身的 `Function` 会在 in-realm 内编译。已知的进程内漏洞,即 `this.constructor.constructor` 通过借用的全局原型到达宿主机 realm,会通过逃逸尝试测试来锚定,而不是被隐藏起来。
- **使用 `worker_threads` 的线程级隔离。** `runInWorker` 在专属的 V8 隔离层及其自身的线程中运行不可信源代码。进程内的构造函数遍历逃逸仅能到达 worker 的 realm,该 worker 在启动时带有空的 `process.env`,无法看到宿主机的环境。逃逸尝试测试遍历了相同的构造函数链,并确认放置在 `process.env` 中的宿主机机密保持不可达状态。
- **冻结 realm 强化。** 在任何不可信代码运行之前,worker 会冻结 `globalThis` 以及核心内置对象及其原型,因此即使发生对 worker realm 的逃逸,也无法破坏同一隔离层中后续运行所依赖的共享状态。
- **分层抢占。** 同步死循环会被 V8 的 `timeout` 终止;永不结束的异步任务会被期限竞速中止(并在 worker 层级被终止)。`run` 将两者组合起来,使得这两类失控任务都无法卡死调用者。
- **严格的 TypeScript。** `strict`、`noUncheckedIndexedAccess` 和 `exactOptionalPropertyTypes`,不使用 `any`。
## 基础组件契约
```
runVerified(task, { timeoutMs, assert, signal?, maxOutputBytes? }) -> RunResult
```
- 该值**仅**以 `{ status: "ok", value, durationMs }` 的形式返回,并且只有在任务于 `timeoutMs` 之前完成、设置了 `maxOutputBytes` 时负载保持在其限制之下,且 `assert(value)` 返回 true 的情况下才会返回。
- 其他所有结果都是明确的拒绝:`timeout`、`assertion-failed`(携带该值仅用于诊断,绝不作为可信结果)、`output-too-large`、`out-of-memory` 或 `error`。
- 任务会接收到一个 `AbortSignal`,该信号会在到达期限或调用者自身的信号触发时激发,因此表现良好的异步工作可以提前停止。在 worker 层级上,相同的中止路径也会终止该隔离层。
进程内层级无法抢占通过同步死循环阻塞事件循环的代码;这正是隔离层和容器层级的作用所在。该层级定义了那些层级所实现的契约。
## 用法
```
import { runVerified, isVerified } from "airlock";
const result = await runVerified(
async (signal) => {
const res = await fetch("https://example.com/data.json", { signal });
return (await res.json()) as { total: number };
},
{
timeoutMs: 2000,
maxOutputBytes: 64 * 1024,
assert: (data) => Number.isInteger(data.total) && data.total >= 0,
},
);
if (isVerified(result)) {
console.log("trusted output:", result.value.total);
} else {
console.warn("refused:", result.status);
}
```
要运行不可信的**源代码**而不是可信闭包,请使用 `run`。该代码在执行时没有任何环境权限,因此 `process`、`require`、`fetch` 和计时器在其内部均为 undefined。它所需的任何能力都会通过 `grant` 显式传递:
```
import { run, isVerified } from "airlock";
const result = await run(
"add(rows.length, 1)",
{
timeoutMs: 50,
maxOutputBytes: 1024,
assert: (n) => Number.isInteger(n) && n > 0,
grant: {
rows: [{ id: 1 }, { id: 2 }],
add: (a: number, b: number) => a + b,
},
},
);
if (isVerified(result)) {
console.log("verified:", result.value); // 3
}
// a synchronous infinite loop is preempted and reported as a timeout
await run("while (true) {}", { timeoutMs: 25, assert: () => true });
// -> { status: "timeout", timeoutMs: 25 }
```
为了实现更强的隔离,`runInWorker` 在 `worker_threads` 隔离层中运行相同的源代码:一个拥有空 `process.env`、冻结的全局变量、堆上限和输出大小上限的独立 V8 堆和线程。`grant` 和返回值通过结构化克隆进行传递,因此请传递数据而不是活动函数。期限到达与调用者中止都会调用 `worker.terminate()`。
```
import { runInWorker, isVerified } from "airlock";
const result = await runInWorker(
"rows.reduce((sum, r) => sum + r.n, 0)",
{
timeoutMs: 500,
assert: (total) => total === 6,
grant: { rows: [{ n: 1 }, { n: 2 }, { n: 3 }] },
maxOldGenerationSizeMb: 32,
maxOutputBytes: 4096,
},
);
if (isVerified(result)) console.log("verified:", result.value); // 6
// a runaway allocation is capped and reported instead of taking the host down
await runInWorker("const a = []; while (true) a.push(new Array(1e6));", {
timeoutMs: 10_000,
assert: () => true,
maxOldGenerationSizeMb: 16,
});
// -> { status: "out-of-memory", maxOldGenerationSizeMb: 16 }
// an oversized return is refused before the post-condition runs
await runInWorker("'x'.repeat(1_000_000)", {
timeoutMs: 1000,
assert: () => true,
maxOutputBytes: 1024,
});
// -> { status: "output-too-large", maxOutputBytes: 1024, actualBytes: ... }
```
## 开发
```
pnpm install
pnpm run typecheck
pnpm test
pnpm run build
```
## 已实现的功能
- 脚手架:pnpm + 严格模式的 TypeScript、tsup 构建、vitest、CI,以及 `runVerified` 基础组件契约(期限限制 + 对可辨识联合结果的后置条件门控)。
- `src/sandbox.ts`:`run(code, opts)` 在零凭证的 `node:vm` 上下文中执行不可信源代码(没有 `process`/`require`/`fetch`/计时器),仅授予调用者传递的内容,通过 V8 的 timeout 抢占同步死循环,并在环境权限泄漏时通过探测到的 `ZeroCredentialViolation` 实施故障关闭。包含文档化的逃逸尝试测试。
- `src/worker.ts`:`runInWorker(code, opts)` 在一个启动时带有空 `process.env` 和冻结的全局变量的 `worker_threads` 隔离层中运行不可信源代码,通过 `maxOldGenerationSizeMb` 限制堆(报告为 `out-of-memory`),并在期限到达时硬终止线程,从而确保同步死循环和永不结束的异步任务都能被抢占。逃逸尝试测试确认,在进程内能够到达宿主机 realm 的构造函数遍历,在这里仅能到达无凭证的 worker realm。
- `src/limits.ts`:所有层级共享的资源上限。挂钟时间超时会中止任务信号,并在 worker 层级上,在期限到达和调用者中止时调用 `worker.terminate()`。通过 V8 `resourceLimits` 实现堆上限。输出大小上限(`maxOutputBytes`)通过带有预算控制的遍历(循环安全、提前退出)测量 UTF-8 负载,并在后置条件运行之前以 `output-too-large` 拒绝。
标签:LLM代码执行, MITM代理, TypeScript, 代码执行, 安全插件, 沙箱环境, 自动化攻击, 资源限制, 隔离运行