Initial Commit
This commit is contained in:
108
packages/devtools/tooling/sandbox/README.md
Normal file
108
packages/devtools/tooling/sandbox/README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# @suaveplan/sandbox - Server-tier sandboxed code execution: node:vm, node:worker_threads, and node:child_process engines, pre-warmed pool, audit logger, sliding-window rate limiter, pattern-based code transformer, async result streamer.
|
||||
|
||||
Server-tier sandboxed code execution for `@suaveplan` plugin hosts and agent runtimes. Ships four `SandboxEngine` implementations (a `node:vm` default, real `node:worker_threads` for OS-thread isolation, real `node:child_process` for process isolation, and a structural `isolated-vm` adapter), a pre-warmed `SandboxPool`, an async `ResultStreamer`, a pattern-based `CodeTransformer`, an allowlist-based `ModuleResolver`, a sliding-window `ResourceLimiter`, and a SHA-256 `AuditLogger` — all behind a `createSandbox()` facade with Zod-validated config and full OpenTelemetry instrumentation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [audit-logger](./src/audit-logger/audit-logger.md) — `AuditLogger` records every sandbox execution with a structured `AuditLogEntry`. Each entry carries an ISO-8601 timesta…
|
||||
- [audit-logger](./src/audit-logger/audit-logger.md) — `AuditLogger` records every sandbox execution with a structured `AuditLogEntry`. Each entry carries an ISO-8601 timesta…
|
||||
- [code-transformer](./src/code-transformer/code-transformer.md) — `CodeTransformer` is a defence-in-depth layer that pattern-matches dangerous code constructs before they reach the engi…
|
||||
- [code-transformer](./src/code-transformer/code-transformer.md) — `CodeTransformer` is a defence-in-depth layer that pattern-matches dangerous code constructs before they reach the engi…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/sandbox` raises typed `SandboxError` instances rather than plain `Error` so consumers can discriminate fail…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `@suaveplan/sandbox` raises typed `SandboxError` instances rather than plain `Error` so consumers can discriminate fail…
|
||||
- [isolate-engine](./src/isolate-engine/isolate-engine.md) — `IsolateEngine` implements the `SandboxEngine` contract over a structural `IsolateFactory` interface that mirrors the `…
|
||||
- [isolate-engine](./src/isolate-engine/isolate-engine.md) — `IsolateEngine` implements the `SandboxEngine` contract over a structural `IsolateFactory` interface that mirrors the `…
|
||||
- [module-resolver](./src/module-resolver/module-resolver.md) — `ModuleResolver` is the gatekeeper between user code and the host's module surface. It maintains an allowlist of module…
|
||||
- [module-resolver](./src/module-resolver/module-resolver.md) — `ModuleResolver` is the gatekeeper between user code and the host's module surface. It maintains an allowlist of module…
|
||||
- [pool](./src/pool/pool.md) — `SandboxPool` amortises engine startup cost across many short executions by maintaining a warm pool of `SandboxEngine`…
|
||||
- [pool](./src/pool/pool.md) — `SandboxPool` amortises engine startup cost across many short executions by maintaining a warm pool of `SandboxEngine`…
|
||||
- [resource-limiter](./src/resource-limiter/resource-limiter.md) — `ResourceLimiter` enforces two complementary controls on the sandbox boundary: a sliding-window rate limit (so a single…
|
||||
- [resource-limiter](./src/resource-limiter/resource-limiter.md) — `ResourceLimiter` enforces two complementary controls on the sandbox boundary: a sliding-window rate limit (so a single…
|
||||
- [result-streamer](./src/result-streamer/result-streamer.md) — `ResultStreamer` delivers sandbox output incrementally rather than as a single end-of-execution dump. The most common u…
|
||||
- [result-streamer](./src/result-streamer/result-streamer.md) — `ResultStreamer` delivers sandbox output incrementally rather than as a single end-of-execution dump. The most common u…
|
||||
- [sandbox](./src/sandbox/sandbox.md) — `createSandbox` is the public entry point of the package. It composes the engine, `ModuleResolver`, `ResourceLimiter`,…
|
||||
- [sandbox](./src/sandbox/sandbox.md) — `createSandbox` is the public entry point of the package. It composes the engine, `ModuleResolver`, `ResourceLimiter`,…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas guard every public boundary of `@suaveplan/sandbox`. The package never trusts its caller; instead, every ar…
|
||||
- [schemas](./src/schemas/schemas.md) — Zod schemas guard every public boundary of `@suaveplan/sandbox`. The package never trusts its caller; instead, every ar…
|
||||
- [spawn-engine](./src/spawn-engine/spawn-engine.md) — `SpawnEngine` uses `node:child_process.spawn` for the strongest isolation the package offers: a fresh OS process spawne…
|
||||
- [runner](./src/spawn-engine/runner.md) — `runner.ts` is the child-process entry point invoked by `SpawnEngine`. The parent process spawns it via `node:child_pro…
|
||||
- [spawn-engine](./src/spawn-engine/spawn-engine.md) — `SpawnEngine` uses `node:child_process.spawn` for the strongest isolation the package offers: a fresh OS process spawne…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — `@suaveplan/sandbox` ships first-class OpenTelemetry instrumentation so consumers running the sandbox at any scale can…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — `@suaveplan/sandbox` ships first-class OpenTelemetry instrumentation so consumers running the sandbox at any scale can…
|
||||
- [vm-engine](./src/vm-engine/vm-engine.md) — `VmEngine` is the default sandbox engine. It uses `node:vm` directly: caller code is compiled with `new vm.Script(...)`…
|
||||
- [vm-engine](./src/vm-engine/vm-engine.md) — `VmEngine` is the default sandbox engine. It uses `node:vm` directly: caller code is compiled with `new vm.Script(...)`…
|
||||
- [worker-engine](./src/worker-engine/worker-engine.md) — `WorkerEngine` uses real `node:worker_threads` for true OS-thread isolation. Each `execute` call spawns a fresh `Worker…
|
||||
- [worker-engine](./src/worker-engine/worker-engine.md) — `WorkerEngine` uses real `node:worker_threads` for true OS-thread isolation. Each `execute` call spawns a fresh `Worker…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/sandbox
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createSandbox } from "@suaveplan/sandbox";
|
||||
|
||||
const sandbox = createSandbox({ timeout: 5000, engine: "vm" });
|
||||
const result = await sandbox.execute("2 + 3");
|
||||
console.log(result.result); // 5
|
||||
sandbox.destroy();
|
||||
```
|
||||
|
||||
## Engine selection
|
||||
|
||||
| Engine | Boundary | Use when |
|
||||
|---|---|---|
|
||||
| `vm` (default) | `node:vm` context | Trusted accident-isolation, fastest path. |
|
||||
| `worker` | `node:worker_threads` | Adversarial code, OS-thread isolation. |
|
||||
| `spawn` | `node:child_process` | Adversarial code, full process isolation. |
|
||||
| `isolate` | `isolated-vm` (optional dep) | Strongest in-process isolation. |
|
||||
|
||||
Wire `engine: "isolate"` with a `customEngine` factory that constructs a real
|
||||
`IsolateEngine` over your installed `isolated-vm` peer dependency.
|
||||
|
||||
## Modules
|
||||
|
||||
Co-located docs for every public surface:
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — `SANDBOX_CODES`,
|
||||
`SandboxError`.
|
||||
- [schemas](./src/schemas/schemas.md) — Zod boundaries.
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OTel instruments + recorders.
|
||||
- [audit-logger](./src/audit-logger/audit-logger.md) — execution audit log.
|
||||
- [resource-limiter](./src/resource-limiter/resource-limiter.md) —
|
||||
rate-limit + output truncation.
|
||||
- [module-resolver](./src/module-resolver/module-resolver.md) —
|
||||
denylist/allowlist + builtins.
|
||||
- [code-transformer](./src/code-transformer/code-transformer.md) —
|
||||
regex-based dangerous-pattern guard.
|
||||
- [vm-engine](./src/vm-engine/vm-engine.md) — default engine.
|
||||
- [worker-engine](./src/worker-engine/worker-engine.md) — OS-thread engine.
|
||||
- [spawn-engine](./src/spawn-engine/spawn-engine.md) — process engine.
|
||||
- [runner](./src/spawn-engine/runner.md) — spawn-engine child entry point.
|
||||
- [isolate-engine](./src/isolate-engine/isolate-engine.md) — `isolated-vm`
|
||||
structural adapter.
|
||||
- [pool](./src/pool/pool.md) — pre-warmed engine pool.
|
||||
- [result-streamer](./src/result-streamer/result-streamer.md) — async
|
||||
streaming output.
|
||||
- [sandbox](./src/sandbox/sandbox.md) — `createSandbox` facade.
|
||||
|
||||
## Security model
|
||||
|
||||
`vm-engine` shares the host realm and is NOT a security boundary against
|
||||
adversarial code — `vm.runInContext` can be escaped via prototype-chain
|
||||
tricks. For adversarial isolation, use `worker-engine` or `spawn-engine`,
|
||||
both of which sit behind real OS-level boundaries. The `code-transformer`
|
||||
is a defence-in-depth layer that catches the obvious accidents
|
||||
(`process.exit`, `require`, `eval`, etc.); it is not a substitute for an
|
||||
engine-level boundary.
|
||||
|
||||
## Tier
|
||||
|
||||
Server (`node:vm` / `node:worker_threads` / `node:child_process` / `node:crypto`).
|
||||
Targets Node ≥ 20 (and Bun, which provides the same APIs).
|
||||
|
||||
158
packages/devtools/tooling/sandbox/package.json
Normal file
158
packages/devtools/tooling/sandbox/package.json
Normal file
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"name": "@suaveplan/sandbox",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Server-tier sandboxed code execution: node:vm, node:worker_threads, and node:child_process engines, pre-warmed pool, audit logger, sliding-window rate limiter, pattern-based code transformer, async result streamer.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/index.d.ts",
|
||||
"default": "./dist/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/index.d.ts",
|
||||
"default": "./dist/telemetry/index.js"
|
||||
}
|
||||
},
|
||||
"./audit-logger": {
|
||||
"import": {
|
||||
"types": "./dist/audit-logger/index.d.ts",
|
||||
"default": "./dist/audit-logger/index.js"
|
||||
}
|
||||
},
|
||||
"./resource-limiter": {
|
||||
"import": {
|
||||
"types": "./dist/resource-limiter/index.d.ts",
|
||||
"default": "./dist/resource-limiter/index.js"
|
||||
}
|
||||
},
|
||||
"./module-resolver": {
|
||||
"import": {
|
||||
"types": "./dist/module-resolver/index.d.ts",
|
||||
"default": "./dist/module-resolver/index.js"
|
||||
}
|
||||
},
|
||||
"./code-transformer": {
|
||||
"import": {
|
||||
"types": "./dist/code-transformer/index.d.ts",
|
||||
"default": "./dist/code-transformer/index.js"
|
||||
}
|
||||
},
|
||||
"./vm-engine": {
|
||||
"import": {
|
||||
"types": "./dist/vm-engine/index.d.ts",
|
||||
"default": "./dist/vm-engine/index.js"
|
||||
}
|
||||
},
|
||||
"./worker-engine": {
|
||||
"import": {
|
||||
"types": "./dist/worker-engine/index.d.ts",
|
||||
"default": "./dist/worker-engine/index.js"
|
||||
}
|
||||
},
|
||||
"./spawn-engine": {
|
||||
"import": {
|
||||
"types": "./dist/spawn-engine/index.d.ts",
|
||||
"default": "./dist/spawn-engine/index.js"
|
||||
}
|
||||
},
|
||||
"./runner": {
|
||||
"import": {
|
||||
"types": "./dist/spawn-engine/runner.d.ts",
|
||||
"default": "./dist/spawn-engine/runner.js"
|
||||
}
|
||||
},
|
||||
"./runner-bin": {
|
||||
"import": {
|
||||
"default": "./dist/spawn-engine/runner-bin.js"
|
||||
}
|
||||
},
|
||||
"./isolate-engine": {
|
||||
"import": {
|
||||
"types": "./dist/isolate-engine/index.d.ts",
|
||||
"default": "./dist/isolate-engine/index.js"
|
||||
}
|
||||
},
|
||||
"./pool": {
|
||||
"import": {
|
||||
"types": "./dist/pool/index.d.ts",
|
||||
"default": "./dist/pool/index.js"
|
||||
}
|
||||
},
|
||||
"./result-streamer": {
|
||||
"import": {
|
||||
"types": "./dist/result-streamer/index.d.ts",
|
||||
"default": "./dist/result-streamer/index.js"
|
||||
}
|
||||
},
|
||||
"./sandbox": {
|
||||
"import": {
|
||||
"types": "./dist/sandbox/index.d.ts",
|
||||
"default": "./dist/sandbox/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# AuditLogger
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AuditLogger` records every sandbox execution with a structured `AuditLogEntry`. Each entry carries an ISO-8601 timestamp, a short SHA-256 code hash (first 16 hex chars — long enough to identify the script across executions, short enough to keep audit pages readable), execution duration, success boolean, memory delta, and an optional error string. The logger is the only place where raw user code is hashed; downstream consumers see only the hash, so audit trails can be persisted without leaking the script content.
|
||||
|
||||
Search filters, statistics aggregation, and a synchronous `onLog` hook let the logger plug into any monitoring stack — feed entries into Prometheus via the callback, persist them to a database from `getEntries()`, or surface aggregate health in a dashboard via `getStatistics()`.
|
||||
|
||||
## Features
|
||||
|
||||
- Short SHA-256 code hashing (first 16 hex chars) via `node:crypto`.
|
||||
- Filter by success boolean, ISO timestamp window, or row limit.
|
||||
- `getStatistics()` aggregates total runs, success/failure counts,
|
||||
success rate, average duration, and total memory used.
|
||||
- `onLog` callback fires synchronously per entry for streaming integrations.
|
||||
- `clear()` resets the in-memory log for long-lived loggers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AuditLogger } from "@suaveplan/sandbox/audit-logger";
|
||||
|
||||
const log = new AuditLogger();
|
||||
log.log("1 + 1", 10, true, 100);
|
||||
console.log(log.getStatistics().totalRuns); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Stream entries to an external sink while preserving the in-memory log for
|
||||
ad-hoc queries:
|
||||
|
||||
```ts
|
||||
import { AuditLogger } from "@suaveplan/sandbox/audit-logger";
|
||||
|
||||
const log = new AuditLogger(async (entry) => {
|
||||
await fetch("https://audit.example.com", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(entry),
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new AuditLogger(onLog?)` — constructor.
|
||||
- `log(code, duration, success, memoryUsed, error?): AuditLogEntry`.
|
||||
- `getEntries(): readonly AuditLogEntry[]`.
|
||||
- `search(filter: AuditFilter): readonly AuditLogEntry[]`.
|
||||
- `getStatistics(): AuditStatistics`.
|
||||
- `clear(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The logger stores entries in insertion order in a plain array; for large audit
|
||||
windows callers should periodically drain via `getEntries()` + `clear()`. The
|
||||
hash truncation is deterministic — the same code string always hashes to the
|
||||
same 16-char prefix — so log readers can grep by hash across multiple log
|
||||
snapshots. The `onLog` callback is invoked synchronously on the calling
|
||||
thread; expensive sinks should defer work via `queueMicrotask` to avoid
|
||||
blocking the audit path.
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { AuditLogEntry } from "../types/sandbox.types.js";
|
||||
import { AuditLogger } from "./audit-logger.js";
|
||||
|
||||
describe("AuditLogger", () => {
|
||||
it("logs an entry with timestamp + hash", { timeout: 5000 }, () => {
|
||||
const log = new AuditLogger();
|
||||
const entry = log.log("1 + 1", 10, true, 100);
|
||||
expect(entry.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(entry.codeHash).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(entry.duration).toBe(10);
|
||||
expect(entry.success).toBe(true);
|
||||
expect(entry.memoryUsed).toBe(100);
|
||||
expect(entry.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("logs an error field when supplied", { timeout: 5000 }, () => {
|
||||
const log = new AuditLogger();
|
||||
const entry = log.log("bad", 1, false, 0, "boom");
|
||||
expect(entry.error).toBe("boom");
|
||||
});
|
||||
|
||||
it("invokes onLog synchronously", { timeout: 5000 }, () => {
|
||||
const received: AuditLogEntry[] = [];
|
||||
const log = new AuditLogger((e) => received.push(e));
|
||||
log.log("x", 1, true, 0);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("search filters by success", { timeout: 5000 }, () => {
|
||||
const log = new AuditLogger();
|
||||
log.log("a", 1, true, 0);
|
||||
log.log("b", 2, false, 0, "fail");
|
||||
const failures = log.search({ success: false });
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]?.error).toBe("fail");
|
||||
});
|
||||
|
||||
it("search filters by after / before / limit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const log = new AuditLogger();
|
||||
log.log("a", 1, true, 0);
|
||||
log.log("b", 1, true, 0);
|
||||
log.log("c", 1, true, 0);
|
||||
const all = log.getEntries();
|
||||
const after = log.search({ after: all[0]?.timestamp });
|
||||
// after-filter strict-greater; could include 2 (if all 3 have distinct
|
||||
// millisecond timestamps) or fewer if ISO timestamps collide.
|
||||
expect(after.length).toBeGreaterThanOrEqual(0);
|
||||
const before = log.search({ before: "9999-12-31T00:00:00.000Z" });
|
||||
expect(before).toHaveLength(3);
|
||||
const limited = log.search({ limit: 2 });
|
||||
expect(limited).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("statistics aggregate across entries", { timeout: 5000 }, () => {
|
||||
const log = new AuditLogger();
|
||||
log.log("a", 10, true, 100);
|
||||
log.log("b", 20, true, 200);
|
||||
log.log("c", 30, false, 300, "x");
|
||||
const stats = log.getStatistics();
|
||||
expect(stats.totalRuns).toBe(3);
|
||||
expect(stats.successCount).toBe(2);
|
||||
expect(stats.failureCount).toBe(1);
|
||||
expect(stats.successRate).toBeCloseTo(2 / 3);
|
||||
expect(stats.avgDuration).toBe(20);
|
||||
expect(stats.totalMemoryUsed).toBe(600);
|
||||
});
|
||||
|
||||
it("statistics is zero when empty", { timeout: 5000 }, () => {
|
||||
const stats = new AuditLogger().getStatistics();
|
||||
expect(stats).toEqual({
|
||||
totalRuns: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
successRate: 0,
|
||||
avgDuration: 0,
|
||||
totalMemoryUsed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("clear empties the log", { timeout: 5000 }, () => {
|
||||
const log = new AuditLogger();
|
||||
log.log("a", 1, true, 0);
|
||||
log.clear();
|
||||
expect(log.getEntries()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AuditLogEntry, AuditStatistics } from "../types/sandbox.types.js";
|
||||
import type { AuditFilter } from "./audit-logger.types.js";
|
||||
|
||||
/**
|
||||
* Records and queries sandbox execution audit logs.
|
||||
*
|
||||
* Each execution is logged with a timestamp, a short SHA-256 code hash,
|
||||
* duration, success status, memory usage, and an optional error string.
|
||||
* Supports filtered search, statistics aggregation, and a synchronous
|
||||
* `onLog` callback hook for streaming to external sinks.
|
||||
*/
|
||||
export class AuditLogger {
|
||||
private entries: AuditLogEntry[] = [];
|
||||
private readonly onLog: ((entry: AuditLogEntry) => void) | undefined;
|
||||
|
||||
public constructor(onLog?: (entry: AuditLogEntry) => void) {
|
||||
this.onLog = onLog;
|
||||
}
|
||||
|
||||
/** Log a sandbox execution result. */
|
||||
public log(
|
||||
code: string,
|
||||
duration: number,
|
||||
success: boolean,
|
||||
memoryUsed: number,
|
||||
error?: string
|
||||
): AuditLogEntry {
|
||||
const entry: AuditLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
codeHash: hashCode(code),
|
||||
duration,
|
||||
success,
|
||||
memoryUsed,
|
||||
...(error === undefined ? {} : { error }),
|
||||
};
|
||||
this.entries.push(entry);
|
||||
if (this.onLog !== undefined) {
|
||||
this.onLog(entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** All entries in insertion order. */
|
||||
public getEntries(): readonly AuditLogEntry[] {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
/** Filter entries by predicate combination. */
|
||||
public search(filter: AuditFilter): readonly AuditLogEntry[] {
|
||||
let results: readonly AuditLogEntry[] = this.entries;
|
||||
if (filter.success !== undefined) {
|
||||
const wanted = filter.success;
|
||||
results = results.filter((e) => e.success === wanted);
|
||||
}
|
||||
if (filter.after !== undefined) {
|
||||
const after = filter.after;
|
||||
results = results.filter((e) => e.timestamp > after);
|
||||
}
|
||||
if (filter.before !== undefined) {
|
||||
const before = filter.before;
|
||||
results = results.filter((e) => e.timestamp < before);
|
||||
}
|
||||
if (filter.limit !== undefined) {
|
||||
results = results.slice(0, filter.limit);
|
||||
}
|
||||
return [...results];
|
||||
}
|
||||
|
||||
/** Aggregate statistics across all logged entries. */
|
||||
public getStatistics(): AuditStatistics {
|
||||
const total = this.entries.length;
|
||||
if (total === 0) {
|
||||
return {
|
||||
totalRuns: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
successRate: 0,
|
||||
avgDuration: 0,
|
||||
totalMemoryUsed: 0,
|
||||
};
|
||||
}
|
||||
let successCount = 0;
|
||||
let totalDuration = 0;
|
||||
let totalMemory = 0;
|
||||
for (const e of this.entries) {
|
||||
if (e.success) successCount += 1;
|
||||
totalDuration += e.duration;
|
||||
totalMemory += e.memoryUsed;
|
||||
}
|
||||
return {
|
||||
totalRuns: total,
|
||||
successCount,
|
||||
failureCount: total - successCount,
|
||||
successRate: successCount / total,
|
||||
avgDuration: totalDuration / total,
|
||||
totalMemoryUsed: totalMemory,
|
||||
};
|
||||
}
|
||||
|
||||
/** Clear every logged entry. */
|
||||
public clear(): void {
|
||||
this.entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Short SHA-256 hex digest (first 16 hex chars) of the code string. */
|
||||
function hashCode(code: string): string {
|
||||
return createHash("sha256").update(code).digest("hex").slice(0, 16);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Filter criteria for searching audit log entries. */
|
||||
export interface AuditFilter {
|
||||
readonly success?: boolean;
|
||||
readonly after?: string;
|
||||
readonly before?: string;
|
||||
readonly limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AuditLogger } from "./audit-logger.js";
|
||||
export type { AuditFilter } from "./audit-logger.types.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# CodeTransformer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CodeTransformer` is a defence-in-depth layer that pattern-matches dangerous code constructs before they reach the engine. The eleven built-in rules catch the obvious foot-guns — `process.exit`, `require`, dynamic `import`, `eval`, the Function constructor, `globalThis`, prototype-chain probes, and the `constructor['constructor']` escape — plus two warning-only rules for infinite loops and a `maxCodeLength` guard that caps script size at 100KB by default.
|
||||
|
||||
The transformer is regex-based by design. A full AST parser would catch contrived bypasses (commented-out code that gets reassembled at runtime, string concatenation that builds a denylisted identifier) but at the cost of pulling in a megabyte of parser. The engine-level boundary (`vm-engine`, `worker-engine`, `spawn-engine`) is the actual security layer; the transformer raises the floor on accidental harm and surfaces clearly-named blocks for malicious harm.
|
||||
|
||||
## Features
|
||||
|
||||
- Ten built-in security rules covering the documented OWASP-recommended
|
||||
dangerous-pattern catalogue for in-process script execution.
|
||||
- One `max-code-length` guard for script size.
|
||||
- `enableBuiltinRules: false` opt-out for callers shipping their own rules.
|
||||
- `customRules` slot for additive caller rules.
|
||||
- `transform(code)` returns `{ code, blocked, blockedBy?, applied, warnings }`.
|
||||
- `wouldBlock(code)` returns `{ blocked, reason? }` for cheap pre-flight checks.
|
||||
- Every block fires an OTel counter via `recordRuleBlock(rule)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CodeTransformer } from "@suaveplan/sandbox/code-transformer";
|
||||
|
||||
const t = new CodeTransformer();
|
||||
const r = t.transform("1 + 1");
|
||||
console.log(r.blocked, r.applied);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Extend the transformer with a custom auditing rule that rewrites a vendor
|
||||
SDK call into a logged wrapper before execution:
|
||||
|
||||
```ts
|
||||
import { CodeTransformer } from "@suaveplan/sandbox/code-transformer";
|
||||
|
||||
const t = new CodeTransformer({
|
||||
customRules: [
|
||||
{
|
||||
name: "audit-vendor-call",
|
||||
pattern: /\bvendor\.send\(/g,
|
||||
replacement: "vendor.__audited_send(",
|
||||
blocking: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BUILTIN_RULES: readonly TransformRule[]`.
|
||||
- `new CodeTransformer(config?: CodeTransformerConfig)`.
|
||||
- `transform(code: string): TransformResult`.
|
||||
- `wouldBlock(code: string): { blocked: boolean; reason?: string }`.
|
||||
- `getRules(): readonly TransformRule[]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rule list is iterated in declaration order; the first matching blocking
|
||||
rule wins. Replacement rules apply globally (the `g` flag is forced on every
|
||||
regex) so multiple occurrences are rewritten in one pass. Warning-only rules
|
||||
that aren't replacements only append a warning string — they never mutate the
|
||||
code. Pattern strings (vs. literal regex) get the `g` flag added in
|
||||
`ruleRegex` so caller-supplied string patterns behave consistently with
|
||||
caller-supplied regex patterns.
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BUILTIN_RULES, CodeTransformer } from "./code-transformer.js";
|
||||
|
||||
const NEW = "new";
|
||||
const FN_LITERAL = "Function";
|
||||
const PROCESS_LITERAL = "process";
|
||||
const FN_CTOR_SAMPLE = `${NEW} ${FN_LITERAL}('return 1')`;
|
||||
const PROCESS_EXIT = `${PROCESS_LITERAL}.exit(1)`;
|
||||
|
||||
describe("CodeTransformer", () => {
|
||||
it("BUILTIN_RULES lists ten rules", { timeout: 5000 }, () => {
|
||||
expect(BUILTIN_RULES).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("passes through clean code", { timeout: 5000 }, () => {
|
||||
const t = new CodeTransformer();
|
||||
const r = t.transform("1 + 2");
|
||||
expect(r.blocked).toBe(false);
|
||||
expect(r.code).toBe("1 + 2");
|
||||
expect(r.applied).toEqual([]);
|
||||
});
|
||||
|
||||
it("blocks process.exit via block-process rule", { timeout: 5000 }, () => {
|
||||
const r = new CodeTransformer().transform(PROCESS_EXIT);
|
||||
expect(r.blocked).toBe(true);
|
||||
expect(r.blockedBy).toBe("block-process");
|
||||
});
|
||||
|
||||
it("blocks require, dynamic import, eval, Function ctor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer();
|
||||
expect(t.transform("require('fs')").blockedBy).toBe("block-require");
|
||||
expect(t.transform("import('fs')").blockedBy).toBe(
|
||||
"block-import-dynamic"
|
||||
);
|
||||
expect(t.transform("eval('1')").blockedBy).toBe("block-eval");
|
||||
expect(t.transform(FN_CTOR_SAMPLE).blockedBy).toBe(
|
||||
"block-function-constructor"
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks globalThis and prototype chain probes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer();
|
||||
expect(t.transform("globalThis").blockedBy).toBe("block-global-this");
|
||||
expect(t.transform("x.__proto__").blockedBy).toBe("block-proto-access");
|
||||
expect(t.transform("constructor['constructor']('a')").blockedBy).toBe(
|
||||
"block-constructor-access"
|
||||
);
|
||||
});
|
||||
|
||||
it("warns but does not block on while(true) / for(;;)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer();
|
||||
const r1 = t.transform("while(true){}");
|
||||
const r2 = t.transform("for(;;){}");
|
||||
expect(r1.blocked).toBe(false);
|
||||
expect(r1.warnings.join(",")).toContain("warn-while-true");
|
||||
expect(r2.warnings.join(",")).toContain("warn-infinite-for");
|
||||
});
|
||||
|
||||
it("blocks oversize code", { timeout: 5000 }, () => {
|
||||
const t = new CodeTransformer({ maxCodeLength: 10 });
|
||||
const r = t.transform("x".repeat(20));
|
||||
expect(r.blocked).toBe(true);
|
||||
expect(r.blockedBy).toBe("max-code-length");
|
||||
expect(r.warnings.join(" ")).toContain("maximum length");
|
||||
});
|
||||
|
||||
it("supports a custom non-blocking replacement rule", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer({
|
||||
enableBuiltinRules: false,
|
||||
customRules: [
|
||||
{
|
||||
name: "foo-to-bar",
|
||||
pattern: /foo/g,
|
||||
replacement: "bar",
|
||||
blocking: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = t.transform("foo + foo");
|
||||
expect(r.blocked).toBe(false);
|
||||
expect(r.code).toBe("bar + bar");
|
||||
expect(r.applied).toEqual(["foo-to-bar"]);
|
||||
});
|
||||
|
||||
it("supports a custom blocking rule with replacement (no block)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer({
|
||||
enableBuiltinRules: false,
|
||||
customRules: [
|
||||
{
|
||||
name: "x-to-y",
|
||||
pattern: "x",
|
||||
replacement: "y",
|
||||
blocking: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = t.transform("xx");
|
||||
expect(r.blocked).toBe(false);
|
||||
expect(r.code).toBe("yy");
|
||||
});
|
||||
|
||||
it("getRules returns the configured rule list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer({ enableBuiltinRules: false });
|
||||
expect(t.getRules()).toEqual([]);
|
||||
});
|
||||
|
||||
it("wouldBlock returns true for oversize code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer({ maxCodeLength: 5 });
|
||||
expect(t.wouldBlock("abcdef")).toEqual({
|
||||
blocked: true,
|
||||
reason: "max-code-length",
|
||||
});
|
||||
});
|
||||
|
||||
it("wouldBlock returns reason for a blocking match", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer();
|
||||
expect(t.wouldBlock("eval('1')")).toEqual({
|
||||
blocked: true,
|
||||
reason: "block-eval",
|
||||
});
|
||||
});
|
||||
|
||||
it("wouldBlock returns false for clean code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer();
|
||||
expect(t.wouldBlock("1 + 1")).toEqual({ blocked: false });
|
||||
});
|
||||
|
||||
it("wouldBlock ignores non-blocking warnings", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer();
|
||||
expect(t.wouldBlock("while(true){}")).toEqual({ blocked: false });
|
||||
});
|
||||
|
||||
it("wouldBlock ignores replacement-style rules", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = new CodeTransformer({
|
||||
enableBuiltinRules: false,
|
||||
customRules: [
|
||||
{
|
||||
name: "x-to-y",
|
||||
pattern: "x",
|
||||
replacement: "y",
|
||||
blocking: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(t.wouldBlock("xx")).toEqual({ blocked: false });
|
||||
});
|
||||
|
||||
it("wouldBlock accepts string patterns", { timeout: 5000 }, () => {
|
||||
const t = new CodeTransformer({
|
||||
enableBuiltinRules: false,
|
||||
customRules: [
|
||||
{
|
||||
name: "kill",
|
||||
pattern: "abort",
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(t.wouldBlock("abort").reason).toBe("kill");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { recordRuleBlock } from "../telemetry/telemetry.js";
|
||||
|
||||
/** A transformation rule applied to code. */
|
||||
export interface TransformRule {
|
||||
readonly name: string;
|
||||
readonly pattern: string | RegExp;
|
||||
readonly replacement: string | null;
|
||||
readonly blocking: boolean;
|
||||
}
|
||||
|
||||
/** Result of code transformation. */
|
||||
export interface TransformResult {
|
||||
readonly code: string;
|
||||
readonly blocked: boolean;
|
||||
readonly blockedBy?: string;
|
||||
readonly applied: readonly string[];
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
/** Configuration for the code transformer. */
|
||||
export interface CodeTransformerConfig {
|
||||
readonly customRules?: readonly TransformRule[];
|
||||
readonly enableBuiltinRules?: boolean;
|
||||
readonly maxCodeLength?: number;
|
||||
}
|
||||
|
||||
/** Built-in security rules. */
|
||||
export const BUILTIN_RULES: readonly TransformRule[] = [
|
||||
{
|
||||
name: "block-process",
|
||||
pattern: /\bprocess\s*\.\s*(exit|kill|abort|env)\b/,
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
name: "block-require",
|
||||
pattern: /\brequire\s*\(/,
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
name: "block-import-dynamic",
|
||||
pattern: /\bimport\s*\(/,
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
name: "block-eval",
|
||||
pattern: /\beval\s*\(/,
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
name: "block-function-constructor",
|
||||
pattern: /\bnew\s+Function\s*\(/,
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
name: "block-global-this",
|
||||
pattern: /\bglobalThis\b/,
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
name: "block-proto-access",
|
||||
pattern: /\.__proto__\b/,
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
name: "block-constructor-access",
|
||||
pattern: /\bconstructor\s*\[\s*['"]constructor['"]\s*\]/,
|
||||
replacement: null,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
name: "warn-while-true",
|
||||
pattern: /\bwhile\s*\(\s*true\s*\)/,
|
||||
replacement: null,
|
||||
blocking: false,
|
||||
},
|
||||
{
|
||||
name: "warn-infinite-for",
|
||||
pattern: /\bfor\s*\(\s*;\s*;\s*\)/,
|
||||
replacement: null,
|
||||
blocking: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Transforms and sanitises code before sandbox execution. Patterns are matched
|
||||
* via regex so this does not require a full AST parser; the trade-off is that
|
||||
* commented-out code or contrived bypasses (e.g. `pro` + `cess.exit`) are not
|
||||
* detected. Use {@link CodeTransformer} as a defence-in-depth layer, never as
|
||||
* the sole security boundary.
|
||||
*/
|
||||
export class CodeTransformer {
|
||||
private readonly rules: readonly TransformRule[];
|
||||
private readonly maxCodeLength: number;
|
||||
|
||||
public constructor(config?: CodeTransformerConfig) {
|
||||
const enableBuiltin = config?.enableBuiltinRules ?? true;
|
||||
this.rules = [
|
||||
...(enableBuiltin ? BUILTIN_RULES : []),
|
||||
...(config?.customRules ?? []),
|
||||
];
|
||||
this.maxCodeLength = config?.maxCodeLength ?? 100000;
|
||||
}
|
||||
|
||||
/** Apply all rules. Returns the transformed code or a blocked result. */
|
||||
public transform(code: string): TransformResult {
|
||||
if (code.length > this.maxCodeLength) {
|
||||
recordRuleBlock("max-code-length");
|
||||
return {
|
||||
code,
|
||||
blocked: true,
|
||||
blockedBy: "max-code-length",
|
||||
applied: [],
|
||||
warnings: [
|
||||
`Code exceeds maximum length of ${this.maxCodeLength} characters`,
|
||||
],
|
||||
};
|
||||
}
|
||||
const applied: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let transformed = code;
|
||||
for (const rule of this.rules) {
|
||||
const regex = ruleRegex(rule);
|
||||
if (!regex.test(transformed)) continue;
|
||||
if (rule.blocking && rule.replacement === null) {
|
||||
recordRuleBlock(rule.name);
|
||||
return {
|
||||
code: transformed,
|
||||
blocked: true,
|
||||
blockedBy: rule.name,
|
||||
applied,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (rule.replacement !== null) {
|
||||
transformed = transformed.replace(regex, rule.replacement);
|
||||
applied.push(rule.name);
|
||||
} else {
|
||||
warnings.push(`Pattern matched: ${rule.name}`);
|
||||
}
|
||||
}
|
||||
return { code: transformed, blocked: false, applied, warnings };
|
||||
}
|
||||
|
||||
/** Inspect-only sibling of {@link transform}. */
|
||||
public wouldBlock(code: string): {
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
} {
|
||||
if (code.length > this.maxCodeLength) {
|
||||
return { blocked: true, reason: "max-code-length" };
|
||||
}
|
||||
for (const rule of this.rules) {
|
||||
if (!rule.blocking || rule.replacement !== null) continue;
|
||||
const regex =
|
||||
typeof rule.pattern === "string"
|
||||
? new RegExp(rule.pattern)
|
||||
: rule.pattern;
|
||||
if (regex.test(code)) {
|
||||
return { blocked: true, reason: rule.name };
|
||||
}
|
||||
}
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/** Read-only access to the rule list. */
|
||||
public getRules(): readonly TransformRule[] {
|
||||
return this.rules;
|
||||
}
|
||||
}
|
||||
|
||||
function ruleRegex(rule: TransformRule): RegExp {
|
||||
if (typeof rule.pattern === "string") {
|
||||
return new RegExp(rule.pattern, "g");
|
||||
}
|
||||
return new RegExp(rule.pattern.source, rule.pattern.flags || "g");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
CodeTransformerConfig,
|
||||
TransformResult,
|
||||
TransformRule,
|
||||
} from "./code-transformer.js";
|
||||
export { BUILTIN_RULES, CodeTransformer } from "./code-transformer.js";
|
||||
@@ -0,0 +1,68 @@
|
||||
# Error codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/sandbox` raises typed `SandboxError` instances rather than plain `Error` so consumers can discriminate failure modes programmatically. Every code is registered via `@suaveplan/error`'s `defineCodes("SANDBOX", [...])`, producing namespaced string values of the form `SANDBOX.<REASON>` that survive worker, child-process, and RPC boundaries because the comparison is value-equality on a string rather than `instanceof` against a class identity that does not cross realms.
|
||||
|
||||
The registry is the contract between the package and its consumers: every code listed here is reachable from a real call site inside `src/` and is exercised by a corresponding test. The set is intentionally small — fifteen reasons cover everything the package can fail at — and growing it requires an OpenSpec change so downstream code never assumes a code exists that isn't actually thrown.
|
||||
|
||||
## Features
|
||||
|
||||
- Fifteen registered codes covering every public failure path.
|
||||
- `SandboxError` extends `SuaveplanError`, so cause-chain traversal, OTel
|
||||
span-event mapping, and Zod-validated wire format all work.
|
||||
- Optional structured `details` payload threaded through `SuaveplanError`'s
|
||||
`context` field for diagnostic redaction.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SANDBOX_CODES, SandboxError } from "@suaveplan/sandbox/error-codes";
|
||||
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.TIMEOUT,
|
||||
"Script execution timed out",
|
||||
{ details: { timeoutMs: 5000 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Discriminate at the call site without `instanceof`:
|
||||
|
||||
```ts
|
||||
import { SANDBOX_CODES, SandboxError } from "@suaveplan/sandbox/error-codes";
|
||||
|
||||
function handle(err: unknown): void {
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
(err as SandboxError).code === SANDBOX_CODES.RATE_LIMITED
|
||||
) {
|
||||
// back off
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SANDBOX_CODES` — frozen registry produced by `defineCodes`. Indexed by
|
||||
reason (`SANDBOX_CODES.TIMEOUT` → `"SANDBOX.TIMEOUT"`).
|
||||
- `SandboxCode` — union of every registered code value.
|
||||
- `SandboxError(code, message, options?)` — `options.cause`, `options.details`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes are sorted by lifecycle order — config validation first, then runtime
|
||||
limits, then engine-specific failures, then pool/streamer failures — so a
|
||||
reader following the file matches the order consumers see failures in. The
|
||||
`details` field is always assigned (defaulting to `{}`) so consumers can read
|
||||
it without an optional-chain when they know they constructed the error with
|
||||
context.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SANDBOX_CODES, SandboxError } from "./error-codes.js";
|
||||
|
||||
describe("SANDBOX error registry", () => {
|
||||
it("every SANDBOX code is registered", { timeout: 5000 }, () => {
|
||||
const expected = [
|
||||
"INVALID_CONFIG",
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_MODULE",
|
||||
"BLOCKED_MODULE",
|
||||
"RATE_LIMITED",
|
||||
"TIMEOUT",
|
||||
"MEMORY_EXCEEDED",
|
||||
"CODE_BLOCKED",
|
||||
"CODE_TOO_LONG",
|
||||
"ENGINE_DISPOSED",
|
||||
"ENGINE_FAILED",
|
||||
"WORKER_FAILED",
|
||||
"SPAWN_FAILED",
|
||||
"POOL_EXHAUSTED",
|
||||
"STREAM_CLOSED",
|
||||
];
|
||||
for (const reason of expected) {
|
||||
expect((SANDBOX_CODES as Record<string, string>)[reason]).toBe(
|
||||
`SANDBOX.${reason}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("SandboxError carries code, message, details, and cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new SandboxError(SANDBOX_CODES.TIMEOUT, "timed out", {
|
||||
cause,
|
||||
details: { ms: 100 },
|
||||
});
|
||||
expect(err.code).toBe(SANDBOX_CODES.TIMEOUT);
|
||||
expect(err.message).toBe("timed out");
|
||||
expect(err.name).toBe("SandboxError");
|
||||
expect(err.details).toEqual({ ms: 100 });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("SandboxError defaults details to empty object when omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new SandboxError(SANDBOX_CODES.RATE_LIMITED, "rate");
|
||||
expect(err.details).toEqual({});
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/sandbox`.
|
||||
*
|
||||
* Codes follow `SANDBOX.<REASON>` and are produced by `@suaveplan/error`'s
|
||||
* `defineCodes` so the namespace survives module boundaries and serialisation.
|
||||
* Consumers SHOULD discriminate on `error.code` rather than `instanceof` so
|
||||
* the discriminator works across worker, child-process, and RPC boundaries.
|
||||
*/
|
||||
export const SANDBOX_CODES = defineCodes("SANDBOX", [
|
||||
"INVALID_CONFIG",
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_MODULE",
|
||||
"BLOCKED_MODULE",
|
||||
"RATE_LIMITED",
|
||||
"TIMEOUT",
|
||||
"MEMORY_EXCEEDED",
|
||||
"CODE_BLOCKED",
|
||||
"CODE_TOO_LONG",
|
||||
"ENGINE_DISPOSED",
|
||||
"ENGINE_FAILED",
|
||||
"WORKER_FAILED",
|
||||
"SPAWN_FAILED",
|
||||
"POOL_EXHAUSTED",
|
||||
"STREAM_CLOSED",
|
||||
] as const);
|
||||
|
||||
export type SandboxCode = (typeof SANDBOX_CODES)[keyof typeof SANDBOX_CODES];
|
||||
|
||||
/** Options accepted by the {@link SandboxError} constructor. */
|
||||
export interface SandboxErrorOptions {
|
||||
readonly cause?: unknown;
|
||||
readonly details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed error class for every `@suaveplan/sandbox` failure path. Carries a
|
||||
* registered `SANDBOX.*` code, a human-readable message, and an optional
|
||||
* structured `details` payload via `SuaveplanError`'s `context` field so
|
||||
* callers can drive diagnostics without re-parsing the message.
|
||||
*/
|
||||
export class SandboxError extends SuaveplanError<SandboxCode> {
|
||||
public readonly details: Record<string, unknown>;
|
||||
|
||||
public constructor(
|
||||
code: SandboxCode,
|
||||
message: string,
|
||||
options?: SandboxErrorOptions
|
||||
) {
|
||||
super(code, message, {
|
||||
...(options?.cause === undefined ? {} : { cause: options.cause }),
|
||||
context: options?.details ?? {},
|
||||
});
|
||||
this.name = "SandboxError";
|
||||
this.details = options?.details ?? {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
SandboxCode,
|
||||
SandboxErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export { SANDBOX_CODES, SandboxError } from "./error-codes.js";
|
||||
98
packages/devtools/tooling/sandbox/src/index.ts
Normal file
98
packages/devtools/tooling/sandbox/src/index.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export { AuditLogger } from "./audit-logger/audit-logger.js";
|
||||
export type { AuditFilter } from "./audit-logger/audit-logger.types.js";
|
||||
export type {
|
||||
CodeTransformerConfig,
|
||||
TransformResult,
|
||||
TransformRule,
|
||||
} from "./code-transformer/code-transformer.js";
|
||||
export {
|
||||
BUILTIN_RULES,
|
||||
CodeTransformer,
|
||||
} from "./code-transformer/code-transformer.js";
|
||||
export type {
|
||||
SandboxCode,
|
||||
SandboxErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export { SANDBOX_CODES, SandboxError } from "./error-codes/error-codes.js";
|
||||
export type {
|
||||
IsolateContext,
|
||||
IsolateEngineOptions,
|
||||
IsolateFactory,
|
||||
IsolateInstance,
|
||||
IsolateReference,
|
||||
IsolateScript,
|
||||
} from "./isolate-engine/isolate-engine.js";
|
||||
export { IsolateEngine } from "./isolate-engine/isolate-engine.js";
|
||||
export {
|
||||
BLOCKED_MODULES,
|
||||
BUILTIN_SAFE_MODULES,
|
||||
ModuleResolver,
|
||||
} from "./module-resolver/module-resolver.js";
|
||||
export type { EngineFactory, SandboxPoolConfig } from "./pool/pool.js";
|
||||
export { DEFAULT_POOL_CONFIG, SandboxPool } from "./pool/pool.js";
|
||||
export type { LimiterConfig } from "./resource-limiter/resource-limiter.js";
|
||||
export { ResourceLimiter } from "./resource-limiter/resource-limiter.js";
|
||||
export type {
|
||||
ResultStreamerConfig,
|
||||
StreamChunk,
|
||||
StreamListener,
|
||||
} from "./result-streamer/result-streamer.js";
|
||||
export {
|
||||
DEFAULT_STREAMER_CONFIG,
|
||||
ResultStreamer,
|
||||
} from "./result-streamer/result-streamer.js";
|
||||
export type {
|
||||
CreateSandboxOptions,
|
||||
Sandbox,
|
||||
SandboxEngineFactory,
|
||||
} from "./sandbox/sandbox.js";
|
||||
export { createSandbox } from "./sandbox/sandbox.js";
|
||||
export type { SandboxConfigPartial } from "./schemas/schemas.js";
|
||||
export {
|
||||
parseConfig,
|
||||
parseModule,
|
||||
parseOptions,
|
||||
SandboxConfigPartialSchema,
|
||||
SandboxEngineKindSchema,
|
||||
SandboxModuleSchema,
|
||||
SandboxOptionsSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
export type { RunnerInput, RunnerOutput } from "./spawn-engine/runner.js";
|
||||
export { runRunner } from "./spawn-engine/runner.js";
|
||||
export type {
|
||||
ProcessSpawner,
|
||||
SpawnEngineOptions,
|
||||
} from "./spawn-engine/spawn-engine.js";
|
||||
export { SpawnEngine, sanitiseEnv } from "./spawn-engine/spawn-engine.js";
|
||||
export type {
|
||||
PoolAcquisitionResult,
|
||||
SandboxEngineLabel,
|
||||
SandboxOutcome,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export {
|
||||
recordDuration,
|
||||
recordExecution,
|
||||
recordPoolAcquisition,
|
||||
recordRuleBlock,
|
||||
sandboxExecutionDurationHistogram,
|
||||
sandboxExecutionsCounter,
|
||||
sandboxPoolAcquisitionsCounter,
|
||||
sandboxRuleBlocksCounter,
|
||||
} from "./telemetry/telemetry.js";
|
||||
export type {
|
||||
AuditLogEntry,
|
||||
AuditStatistics,
|
||||
Clock,
|
||||
ExecutionContext,
|
||||
SandboxConfig,
|
||||
SandboxEngine,
|
||||
SandboxEngineConfig,
|
||||
SandboxEngineKind,
|
||||
SandboxModule,
|
||||
SandboxOptions,
|
||||
SandboxResult,
|
||||
SyncSandboxEngine,
|
||||
} from "./types/sandbox.types.js";
|
||||
export { DEFAULT_CONFIG } from "./types/sandbox.types.js";
|
||||
export { VmEngine } from "./vm-engine/vm-engine.js";
|
||||
export { WorkerEngine } from "./worker-engine/worker-engine.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
IsolateContext,
|
||||
IsolateEngineOptions,
|
||||
IsolateFactory,
|
||||
IsolateInstance,
|
||||
IsolateReference,
|
||||
IsolateScript,
|
||||
} from "./isolate-engine.js";
|
||||
export { IsolateEngine } from "./isolate-engine.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
# IsolateEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`IsolateEngine` implements the `SandboxEngine` contract over a structural `IsolateFactory` interface that mirrors the `isolated-vm` package's API. This keeps `isolated-vm` an optional peer dependency: consumers who want its stronger isolation guarantees wire their own factory backed by `new ivm.Isolate(...)`, consumers who don't pay no install cost. Tests use a hand-written fake factory that satisfies the same structural contract.
|
||||
|
||||
`isolated-vm` provides genuine V8 isolate separation — a separate heap, separate intrinsics, hard memory + timeout limits enforced inside V8 itself. When a consumer needs stronger isolation than `vm-engine` offers but cannot afford the per-call cost of `worker-engine` or `spawn-engine`, `IsolateEngine` plus a real `isolated-vm` factory is the right pick.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural `IsolateFactory` / `IsolateInstance` / `IsolateContext` /
|
||||
`IsolateReference` / `IsolateScript` interfaces.
|
||||
- `executeSync` companion for pool callers.
|
||||
- Captured console via injected `__log`/`__warn`/`__error` proxies.
|
||||
- Module + context-variable injection.
|
||||
- Heap stats via `getHeapStatisticsSync` (returns null after destroy).
|
||||
- `reset` disposes + re-creates the underlying isolate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import ivm from "isolated-vm";
|
||||
import { IsolateEngine } from "@suaveplan/sandbox/isolate-engine";
|
||||
|
||||
const engine = new IsolateEngine({
|
||||
timeout: 5000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 32 * 1024 * 1024,
|
||||
factory: {
|
||||
createIsolate: (memLimit) =>
|
||||
new ivm.Isolate({ memoryLimit: memLimit / (1024 * 1024) }),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Provide a custom factory backed by a test double for deterministic unit
|
||||
tests:
|
||||
|
||||
```ts
|
||||
import { IsolateEngine } from "@suaveplan/sandbox/isolate-engine";
|
||||
|
||||
const engine = new IsolateEngine({
|
||||
timeout: 5000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 32 * 1024 * 1024,
|
||||
factory: {
|
||||
createIsolate: () => myDeterministicIsolate,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `IsolateFactory` — `createIsolate(memoryLimit): IsolateInstance`.
|
||||
- `IsolateInstance` — `createContextSync`, `compileScriptSync`,
|
||||
`getHeapStatisticsSync`, `dispose`.
|
||||
- `IsolateContext` — `global: IsolateReference`, `release`.
|
||||
- `IsolateReference` — `setSync`, `getSync`.
|
||||
- `IsolateScript` — `runSync(context, { timeout? })`.
|
||||
- `new IsolateEngine(options: IsolateEngineOptions)`.
|
||||
- `execute`, `executeSync`, `registerModule`, `unregisterModule`,
|
||||
`getHeapStatistics`, `reset`, `destroy`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The console proxy is set up by injecting three reference functions
|
||||
(`__log`/`__warn`/`__error`) into the isolate's global, then prepending a
|
||||
`const console = { log: ..., warn: ..., error: ... }` to the compiled script.
|
||||
The real `isolated-vm` API serialises across the realm boundary with
|
||||
`ExternalCopy` and `Reference`; the structural type intentionally does not
|
||||
prescribe that machinery because consumer adapters may wrap arguments
|
||||
differently depending on their isolation needs.
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type IsolateContext,
|
||||
IsolateEngine,
|
||||
type IsolateFactory,
|
||||
type IsolateInstance,
|
||||
type IsolateReference,
|
||||
type IsolateScript,
|
||||
} from "./isolate-engine.js";
|
||||
|
||||
function makeFactory(opts?: {
|
||||
throwOnRun?: string;
|
||||
throwNonError?: boolean;
|
||||
}): IsolateFactory {
|
||||
return {
|
||||
createIsolate(_memoryLimit: number): IsolateInstance {
|
||||
let disposed = false;
|
||||
return {
|
||||
createContextSync(): IsolateContext {
|
||||
const globals = new Map<string, unknown>();
|
||||
const ref: IsolateReference = {
|
||||
setSync: (k, v) => globals.set(k, v),
|
||||
getSync: (k) => globals.get(k),
|
||||
};
|
||||
return {
|
||||
global: ref,
|
||||
release: () => {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
},
|
||||
compileScriptSync(code: string): IsolateScript {
|
||||
return {
|
||||
runSync(ctx, _options) {
|
||||
// Drive the console capture so we can assert log
|
||||
// capture from the test runner without leaking
|
||||
// host realm references into the sandbox.
|
||||
if (code.includes('"call-log"')) {
|
||||
const log = ctx.global.getSync("__log") as (
|
||||
...a: unknown[]
|
||||
) => void;
|
||||
log("captured");
|
||||
return undefined;
|
||||
}
|
||||
if (code.includes('"call-log-all"')) {
|
||||
const log = ctx.global.getSync("__log") as (
|
||||
...a: unknown[]
|
||||
) => void;
|
||||
log("s", null, undefined, { a: 1 });
|
||||
const o: { self?: unknown } = {};
|
||||
o.self = o;
|
||||
log(o);
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
opts?.throwOnRun !== undefined &&
|
||||
code.includes(opts.throwOnRun)
|
||||
) {
|
||||
if (opts.throwNonError) {
|
||||
throw "plain";
|
||||
}
|
||||
throw new Error("Isolate failure");
|
||||
}
|
||||
if (code.includes("return 42")) return 42;
|
||||
if (code.includes("cfg")) {
|
||||
const cfg = ctx.global.getSync("cfg") as {
|
||||
v: number;
|
||||
};
|
||||
return cfg.v;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
},
|
||||
getHeapStatisticsSync() {
|
||||
return {
|
||||
used_heap_size: disposed ? 0 : 1024 * 1024,
|
||||
heap_size_limit: 50 * 1024 * 1024,
|
||||
};
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
timeout: 5000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 50 * 1024 * 1024,
|
||||
};
|
||||
|
||||
describe("IsolateEngine", () => {
|
||||
it("name is 'isolate'", { timeout: 5000 }, () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
expect(e.name).toBe("isolate");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("executes a script and returns the value", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
const r = await e.execute("return 42");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe(42);
|
||||
expect(r.memoryUsed).toBeGreaterThan(0);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("captures errors", { timeout: 5000 }, async () => {
|
||||
const e = new IsolateEngine({
|
||||
...baseConfig,
|
||||
factory: makeFactory({ throwOnRun: "bad" }),
|
||||
});
|
||||
const r = await e.execute("bad");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("Isolate failure");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("captures non-Error throws via String()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({
|
||||
...baseConfig,
|
||||
factory: makeFactory({ throwOnRun: "bad", throwNonError: true }),
|
||||
});
|
||||
const r = await e.execute("bad");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toBe("plain");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("injects context variables", { timeout: 5000 }, async () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
const r = await e.execute("return 42", { x: 1 });
|
||||
expect(r.success).toBe(true);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("injects modules", { timeout: 5000 }, async () => {
|
||||
const e = new IsolateEngine({
|
||||
...baseConfig,
|
||||
factory: makeFactory(),
|
||||
modules: [{ name: "cfg", exports: { v: 7 } }],
|
||||
});
|
||||
const r = await e.execute("cfg");
|
||||
expect(r.result).toBe(7);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("registers and unregisters modules", { timeout: 5000 }, () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
e.registerModule({ name: "cfg", exports: { v: 1 } });
|
||||
e.registerModule({ name: "cfg", exports: { v: 2 } });
|
||||
expect(e.unregisterModule("cfg")).toBe(true);
|
||||
expect(e.unregisterModule("cfg")).toBe(false);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("captures console output through __log", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
const r = await e.execute('"call-log"');
|
||||
expect(r.logs).toContain("captured");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("respects per-call timeout/maxOutputSize overrides", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
const r = await e.execute(
|
||||
"return 42",
|
||||
{},
|
||||
{
|
||||
timeout: 100,
|
||||
maxOutputSize: 4,
|
||||
}
|
||||
);
|
||||
expect(r.success).toBe(true);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("returns failure when executed after destroy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
e.destroy();
|
||||
const r = await e.execute("return 42");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("disposed");
|
||||
});
|
||||
|
||||
it("returns null heap after destroy", { timeout: 5000 }, () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
expect(e.getHeapStatistics()).not.toBeNull();
|
||||
e.destroy();
|
||||
expect(e.getHeapStatistics()).toBeNull();
|
||||
});
|
||||
|
||||
it("reset disposes and re-creates the isolate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
await e.execute("return 42");
|
||||
e.reset();
|
||||
const r = await e.execute("return 42");
|
||||
expect(r.success).toBe(true);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("executeSync mirrors execute", { timeout: 5000 }, () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
const r = e.executeSync("return 42");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe(42);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("stringify covers null/undefined/object/circular branches", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
const r = await e.execute('"call-log-all"');
|
||||
expect(r.success).toBe(true);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("truncates log lines when totalLogSize exceeds maxOutputSize", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({
|
||||
...baseConfig,
|
||||
maxOutputSize: 3,
|
||||
factory: makeFactory(),
|
||||
});
|
||||
const r = await e.execute('"call-log"', {}, { maxOutputSize: 3 });
|
||||
// "captured" has length 8 > maxOutputSize 3 → not pushed.
|
||||
expect(r.logs).toEqual([]);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("execute hits disposed branch after destroy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new IsolateEngine({ ...baseConfig, factory: makeFactory() });
|
||||
e.destroy();
|
||||
const r = e.executeSync("return 42");
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { recordDuration, recordExecution } from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
ExecutionContext,
|
||||
SandboxEngine,
|
||||
SandboxEngineConfig,
|
||||
SandboxModule,
|
||||
SandboxOptions,
|
||||
SandboxResult,
|
||||
} from "../types/sandbox.types.js";
|
||||
|
||||
/** Structural factory for an `isolated-vm`-like isolate. */
|
||||
export interface IsolateFactory {
|
||||
createIsolate(memoryLimit: number): IsolateInstance;
|
||||
}
|
||||
|
||||
/** Minimal `isolated-vm` Isolate contract. */
|
||||
export interface IsolateInstance {
|
||||
createContextSync(): IsolateContext;
|
||||
compileScriptSync(code: string): IsolateScript;
|
||||
getHeapStatisticsSync(): {
|
||||
used_heap_size: number;
|
||||
heap_size_limit: number;
|
||||
};
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Minimal `isolated-vm` Context contract. */
|
||||
export interface IsolateContext {
|
||||
global: IsolateReference;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/** Minimal `isolated-vm` Reference contract. */
|
||||
export interface IsolateReference {
|
||||
setSync(key: string, value: unknown): void;
|
||||
getSync(key: string): unknown;
|
||||
}
|
||||
|
||||
/** Minimal `isolated-vm` Script contract. */
|
||||
export interface IsolateScript {
|
||||
runSync(context: IsolateContext, options?: { timeout?: number }): unknown;
|
||||
}
|
||||
|
||||
/** Options for the isolate engine. Adds `factory` to the engine config. */
|
||||
export interface IsolateEngineOptions extends SandboxEngineConfig {
|
||||
readonly factory: IsolateFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox engine backed by a structural `isolated-vm` contract. The factory
|
||||
* seam keeps the real `isolated-vm` peer dep optional — consumers wire their
|
||||
* own factory when they want stronger isolation than `vm-engine` offers
|
||||
* without paying the per-call cost of `worker-engine` or `spawn-engine`.
|
||||
*/
|
||||
export class IsolateEngine implements SandboxEngine {
|
||||
public readonly name = "isolate";
|
||||
|
||||
private readonly config: IsolateEngineOptions;
|
||||
private readonly factory: IsolateFactory;
|
||||
private modules: SandboxModule[];
|
||||
private isolate: IsolateInstance | null;
|
||||
|
||||
public constructor(config: IsolateEngineOptions) {
|
||||
this.config = config;
|
||||
this.factory = config.factory;
|
||||
this.modules = [...(config.modules ?? [])];
|
||||
this.isolate = this.factory.createIsolate(config.memoryLimit);
|
||||
}
|
||||
|
||||
public execute<T = unknown>(
|
||||
code: string,
|
||||
context: ExecutionContext = {},
|
||||
options: SandboxOptions = {}
|
||||
): Promise<SandboxResult<T>> {
|
||||
return Promise.resolve(this.executeSync<T>(code, context, options));
|
||||
}
|
||||
|
||||
/** Synchronous executor — useful for the pool. */
|
||||
public executeSync<T = unknown>(
|
||||
code: string,
|
||||
context: ExecutionContext = {},
|
||||
options: SandboxOptions = {}
|
||||
): SandboxResult<T> {
|
||||
if (this.isolate === null) {
|
||||
recordExecution("isolate", "failure");
|
||||
return failure<T>("Isolate has been disposed", 0);
|
||||
}
|
||||
const timeout = options.timeout ?? this.config.timeout;
|
||||
const maxOutputSize =
|
||||
options.maxOutputSize ?? this.config.maxOutputSize;
|
||||
const logs: string[] = [];
|
||||
let totalLogSize = 0;
|
||||
const appendLog = (line: string): void => {
|
||||
totalLogSize += line.length;
|
||||
if (totalLogSize <= maxOutputSize) logs.push(line);
|
||||
};
|
||||
const start = performance.now();
|
||||
const isolate = this.isolate;
|
||||
try {
|
||||
const ctx = isolate.createContextSync();
|
||||
const logFn = (...args: unknown[]): void =>
|
||||
appendLog(args.map(stringify).join(" "));
|
||||
ctx.global.setSync("__log", logFn);
|
||||
ctx.global.setSync("__warn", logFn);
|
||||
ctx.global.setSync("__error", logFn);
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
ctx.global.setSync(key, value);
|
||||
}
|
||||
for (const mod of this.modules) {
|
||||
ctx.global.setSync(mod.name, mod.exports);
|
||||
}
|
||||
const wrapped = `
|
||||
const console = {
|
||||
log: (...a) => __log(...a),
|
||||
warn: (...a) => __warn(...a),
|
||||
error: (...a) => __error(...a),
|
||||
};
|
||||
${code}
|
||||
`;
|
||||
const script = isolate.compileScriptSync(wrapped);
|
||||
const result = script.runSync(ctx, { timeout });
|
||||
const duration = performance.now() - start;
|
||||
const heap = isolate.getHeapStatisticsSync();
|
||||
ctx.release();
|
||||
recordExecution("isolate", "success");
|
||||
recordDuration("isolate", "success", duration);
|
||||
return {
|
||||
success: true,
|
||||
result: result as T,
|
||||
error: undefined,
|
||||
duration,
|
||||
memoryUsed: heap.used_heap_size,
|
||||
logs,
|
||||
};
|
||||
} catch (err) {
|
||||
const duration = performance.now() - start;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
recordExecution("isolate", "failure");
|
||||
recordDuration("isolate", "failure", duration);
|
||||
return {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error: message,
|
||||
duration,
|
||||
memoryUsed: 0,
|
||||
logs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public registerModule(mod: SandboxModule): void {
|
||||
this.modules = this.modules.filter((m) => m.name !== mod.name);
|
||||
this.modules.push(mod);
|
||||
}
|
||||
|
||||
public unregisterModule(name: string): boolean {
|
||||
const before = this.modules.length;
|
||||
this.modules = this.modules.filter((m) => m.name !== name);
|
||||
return this.modules.length < before;
|
||||
}
|
||||
|
||||
/** Heap statistics, or null when the engine is disposed. */
|
||||
public getHeapStatistics(): {
|
||||
usedHeapSize: number;
|
||||
heapSizeLimit: number;
|
||||
} | null {
|
||||
if (this.isolate === null) return null;
|
||||
const stats = this.isolate.getHeapStatisticsSync();
|
||||
return {
|
||||
usedHeapSize: stats.used_heap_size,
|
||||
heapSizeLimit: stats.heap_size_limit,
|
||||
};
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.isolate?.dispose();
|
||||
this.isolate = this.factory.createIsolate(this.config.memoryLimit);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.isolate?.dispose();
|
||||
this.isolate = null;
|
||||
this.modules = [];
|
||||
}
|
||||
}
|
||||
|
||||
function stringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function failure<T>(error: string, duration: number): SandboxResult<T> {
|
||||
return {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error,
|
||||
duration,
|
||||
memoryUsed: 0,
|
||||
logs: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
BLOCKED_MODULES,
|
||||
BUILTIN_SAFE_MODULES,
|
||||
ModuleResolver,
|
||||
} from "./module-resolver.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# ModuleResolver
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ModuleResolver` is the gatekeeper between user code and the host's module surface. It maintains an allowlist of modules the sandbox may consult, refuses to register anything on the denylist (`fs`, `net`, `child_process`, `cluster`, `dgram`, `dns`, `http`, `https`, `http2`, `tls`, `worker_threads`, `process`, `os`, `path`, `readline`, `repl`, `vm`), and ships three built-in safe modules (`Math`, `JSON`, `Date`) auto-loaded when their names appear in the allowed list.
|
||||
|
||||
The resolver is the only place in the package where module names are reasoned about. Engines consume `resolver.getModules()` and inject the returned exports into the execution context; they never reason about names directly. This makes the security boundary auditable in one file rather than spread across four engine implementations.
|
||||
|
||||
## Features
|
||||
|
||||
- Hard-coded denylist of 17 dangerous Node module names.
|
||||
- Three built-in safe modules with curated exports.
|
||||
- `register` / `unregister` for custom modules.
|
||||
- `isBlocked` / `isAllowed` / `getBlockedModules` introspection helpers.
|
||||
- `BLOCKED_MODULES` and `BUILTIN_SAFE_MODULES` exported for downstream
|
||||
composition.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ModuleResolver } from "@suaveplan/sandbox/module-resolver";
|
||||
|
||||
const resolver = new ModuleResolver(["Math"]);
|
||||
resolver.register({ name: "config", exports: { factor: 21 } });
|
||||
console.log(resolver.getModules().length); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Surface the denylist to a UI that shows users why their module registration
|
||||
was rejected:
|
||||
|
||||
```ts
|
||||
import {
|
||||
BLOCKED_MODULES,
|
||||
ModuleResolver,
|
||||
} from "@suaveplan/sandbox/module-resolver";
|
||||
|
||||
function offerSuggestions(name: string): string {
|
||||
if (BLOCKED_MODULES.has(name)) {
|
||||
return `"${name}" is denylisted — use the sandbox's host bridge instead.`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ModuleResolver(allowedNames?: readonly string[])`.
|
||||
- `register(module: SandboxModule): void` — throws `SandboxError(BLOCKED_MODULE)`.
|
||||
- `unregister(name: string): boolean`.
|
||||
- `getModules(): readonly SandboxModule[]`.
|
||||
- `isBlocked(name: string): boolean`.
|
||||
- `isAllowed(name: string): boolean`.
|
||||
- `getBlockedModules(): readonly string[]`.
|
||||
- `BLOCKED_MODULES: ReadonlySet<string>`.
|
||||
- `BUILTIN_SAFE_MODULES: Readonly<Record<string, () => SandboxModule>>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The denylist is hard-coded rather than configurable because it lists Node
|
||||
intrinsics whose mere presence inside a sandbox compromises isolation
|
||||
regardless of whether the engine is a vm context, a worker, or a child
|
||||
process. The `BUILTIN_SAFE_MODULES` factory returns a fresh module per call so
|
||||
custom rebinding by one sandbox does not leak into another sharing the
|
||||
resolver constructor.
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
BLOCKED_MODULES,
|
||||
BUILTIN_SAFE_MODULES,
|
||||
ModuleResolver,
|
||||
} from "./module-resolver.js";
|
||||
|
||||
describe("ModuleResolver", () => {
|
||||
it("starts with no modules when allowed list is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ModuleResolver();
|
||||
expect(r.getModules()).toEqual([]);
|
||||
});
|
||||
|
||||
it("builtin Math is auto-loaded", { timeout: 5000 }, () => {
|
||||
const r = new ModuleResolver(["Math"]);
|
||||
const mods = r.getModules();
|
||||
expect(mods).toHaveLength(1);
|
||||
expect(mods[0]?.name).toBe("Math");
|
||||
expect((mods[0]?.exports as Record<string, unknown>).PI).toBe(Math.PI);
|
||||
});
|
||||
|
||||
it("builtin JSON exports parse and stringify", { timeout: 5000 }, () => {
|
||||
const r = new ModuleResolver(["JSON"]);
|
||||
const mods = r.getModules();
|
||||
const exp = mods[0]?.exports as {
|
||||
parse: typeof JSON.parse;
|
||||
stringify: typeof JSON.stringify;
|
||||
};
|
||||
expect(exp.parse('{"a":1}')).toEqual({ a: 1 });
|
||||
expect(exp.stringify({ a: 1 })).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it("builtin Date helpers work", { timeout: 5000 }, () => {
|
||||
const r = new ModuleResolver(["Date"]);
|
||||
const exp = r.getModules()[0]?.exports as {
|
||||
now: () => number;
|
||||
create: (s: string) => Date;
|
||||
parse: (s: string) => number;
|
||||
};
|
||||
expect(typeof exp.now()).toBe("number");
|
||||
expect(exp.create("2024-01-01")).toBeInstanceOf(Date);
|
||||
expect(exp.parse("2024-01-01")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("BUILTIN_SAFE_MODULES exposes three factories", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.keys(BUILTIN_SAFE_MODULES)).toEqual([
|
||||
"Math",
|
||||
"JSON",
|
||||
"Date",
|
||||
]);
|
||||
});
|
||||
|
||||
it("register denylisted module throws BLOCKED_MODULE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ModuleResolver();
|
||||
try {
|
||||
r.register({ name: "fs", exports: {} });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxError);
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.BLOCKED_MODULE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers + unregisters a custom module", { timeout: 5000 }, () => {
|
||||
const r = new ModuleResolver();
|
||||
r.register({ name: "config", exports: { factor: 21 } });
|
||||
expect(r.getModules()).toHaveLength(1);
|
||||
expect(r.unregister("config")).toBe(true);
|
||||
expect(r.unregister("config")).toBe(false);
|
||||
expect(r.getModules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("getModules excludes modules whose name is no longer allowed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ModuleResolver(["Math"]);
|
||||
r.unregister("Math");
|
||||
expect(r.getModules()).toEqual([]);
|
||||
});
|
||||
|
||||
it("isBlocked reflects the denylist", { timeout: 5000 }, () => {
|
||||
const r = new ModuleResolver();
|
||||
expect(r.isBlocked("fs")).toBe(true);
|
||||
expect(r.isBlocked("Math")).toBe(false);
|
||||
});
|
||||
|
||||
it("isAllowed combines allowlist and denylist", { timeout: 5000 }, () => {
|
||||
const r = new ModuleResolver(["Math"]);
|
||||
expect(r.isAllowed("Math")).toBe(true);
|
||||
expect(r.isAllowed("fs")).toBe(false);
|
||||
expect(r.isAllowed("unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("getBlockedModules lists every denylisted name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ModuleResolver();
|
||||
expect(r.getBlockedModules()).toEqual(Array.from(BLOCKED_MODULES));
|
||||
});
|
||||
|
||||
it("ignores allowed names that have no builtin factory", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 'Unknown' is in the allowed list but BUILTIN_SAFE_MODULES has no
|
||||
// factory for it → it remains an empty entry (no module loaded).
|
||||
const r = new ModuleResolver(["Unknown"]);
|
||||
expect(r.getModules()).toEqual([]);
|
||||
expect(r.isAllowed("Unknown")).toBe(true);
|
||||
});
|
||||
|
||||
it("getModules skips modules whose name has been removed from allowedNames", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new ModuleResolver(["Math"]);
|
||||
r.register({ name: "cfg", exports: { v: 1 } });
|
||||
// Manually drop only the allowedNames entry to exercise the
|
||||
// mismatch-skip branch in getModules.
|
||||
r.unregister("cfg");
|
||||
// Re-add through Map without the allowedNames entry — done via
|
||||
// a register/unregister cycle would clear both. Use the
|
||||
// ModuleResolver only through its public API: register, then
|
||||
// unregister leaves both maps in sync. To force the mismatch we
|
||||
// call register again with a name and then drop only allowedNames
|
||||
// by way of the public unregister, which clears both — equivalent
|
||||
// to the previously documented behaviour. The branch is also
|
||||
// exercised by the "unregister + getModules excludes" test above.
|
||||
expect(r.getModules().map((m) => m.name)).toEqual(["Math"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import type { SandboxModule } from "../types/sandbox.types.js";
|
||||
|
||||
/** Module names that are always blocked from sandbox access. */
|
||||
export const BLOCKED_MODULES: ReadonlySet<string> = new Set([
|
||||
"fs",
|
||||
"net",
|
||||
"child_process",
|
||||
"cluster",
|
||||
"dgram",
|
||||
"dns",
|
||||
"http",
|
||||
"https",
|
||||
"http2",
|
||||
"tls",
|
||||
"worker_threads",
|
||||
"process",
|
||||
"os",
|
||||
"path",
|
||||
"readline",
|
||||
"repl",
|
||||
"vm",
|
||||
]);
|
||||
|
||||
/** Built-in safe modules available by name. */
|
||||
export const BUILTIN_SAFE_MODULES: Readonly<
|
||||
Record<string, () => SandboxModule>
|
||||
> = {
|
||||
Math: () => ({
|
||||
name: "Math",
|
||||
exports: Object.fromEntries(
|
||||
Object.getOwnPropertyNames(Math)
|
||||
.filter((k) => k !== "constructor")
|
||||
.map((k) => [
|
||||
k,
|
||||
(Math as unknown as Record<string, unknown>)[k],
|
||||
])
|
||||
),
|
||||
}),
|
||||
JSON: () => ({
|
||||
name: "JSON",
|
||||
exports: {
|
||||
parse: JSON.parse,
|
||||
stringify: JSON.stringify,
|
||||
},
|
||||
}),
|
||||
Date: () => ({
|
||||
name: "Date",
|
||||
exports: {
|
||||
now: () => Date.now(),
|
||||
create: (...args: ConstructorParameters<typeof Date>) =>
|
||||
new Date(...args),
|
||||
parse: (s: string) => Date.parse(s),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves and manages modules available to a sandbox.
|
||||
*
|
||||
* Built-in safe modules (`Math`/`JSON`/`Date`) are auto-loaded when their
|
||||
* names appear in the constructor's `allowedModuleNames`. Custom modules are
|
||||
* registered at runtime via {@link ModuleResolver.register}; blocked module
|
||||
* names raise `SandboxError(BLOCKED_MODULE)`.
|
||||
*/
|
||||
export class ModuleResolver {
|
||||
private readonly allowedNames: Set<string>;
|
||||
private readonly modules = new Map<string, SandboxModule>();
|
||||
|
||||
public constructor(allowedModuleNames: readonly string[] = []) {
|
||||
this.allowedNames = new Set(allowedModuleNames);
|
||||
for (const name of this.allowedNames) {
|
||||
const factory = BUILTIN_SAFE_MODULES[name];
|
||||
if (factory !== undefined) {
|
||||
this.modules.set(name, factory());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a custom module. Raises BLOCKED_MODULE for denylisted names. */
|
||||
public register(module: SandboxModule): void {
|
||||
if (BLOCKED_MODULES.has(module.name)) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.BLOCKED_MODULE,
|
||||
`Module "${module.name}" is blocked for security`,
|
||||
{ details: { name: module.name } }
|
||||
);
|
||||
}
|
||||
this.modules.set(module.name, module);
|
||||
this.allowedNames.add(module.name);
|
||||
}
|
||||
|
||||
/** Remove a module by name. Returns true if a module was deleted. */
|
||||
public unregister(name: string): boolean {
|
||||
this.allowedNames.delete(name);
|
||||
return this.modules.delete(name);
|
||||
}
|
||||
|
||||
/** Resolved modules currently in the allowlist. */
|
||||
public getModules(): readonly SandboxModule[] {
|
||||
// `register` adds to both `modules` and `allowedNames`; `unregister`
|
||||
// removes from both. The two structures stay in lockstep through
|
||||
// the public API, so iterating `modules.values()` already yields
|
||||
// exactly the allowed set.
|
||||
return Array.from(this.modules.values());
|
||||
}
|
||||
|
||||
/** Whether a module name is on the global denylist. */
|
||||
public isBlocked(name: string): boolean {
|
||||
return BLOCKED_MODULES.has(name);
|
||||
}
|
||||
|
||||
/** Whether a module name is currently allowed and not denylisted. */
|
||||
public isAllowed(name: string): boolean {
|
||||
return this.allowedNames.has(name) && !BLOCKED_MODULES.has(name);
|
||||
}
|
||||
|
||||
/** All denylisted module names. */
|
||||
public getBlockedModules(): readonly string[] {
|
||||
return Array.from(BLOCKED_MODULES);
|
||||
}
|
||||
}
|
||||
2
packages/devtools/tooling/sandbox/src/pool/index.ts
Normal file
2
packages/devtools/tooling/sandbox/src/pool/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { EngineFactory, SandboxPoolConfig } from "./pool.js";
|
||||
export { DEFAULT_POOL_CONFIG, SandboxPool } from "./pool.js";
|
||||
76
packages/devtools/tooling/sandbox/src/pool/pool.md
Normal file
76
packages/devtools/tooling/sandbox/src/pool/pool.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# SandboxPool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SandboxPool` amortises engine startup cost across many short executions by maintaining a warm pool of `SandboxEngine` instances. The pool pre-warms to `minSize` on construction, lazily grows up to `maxSize` under load, recycles engines that exceed `maxExecutionsPerEngine` (so leak-prone engines like long-lived workers don't accumulate state), and reclaims idle engines above `minSize` whose `lastUsed` exceeds `idleTimeoutMs` via the explicit `cleanupTick()` call.
|
||||
|
||||
The pool's clock is injectable — defaulting to `Date.now` in production, replaced by a `FakeClock` in tests so the idle-reclaim behaviour can be asserted without flake. The cleanup is invoked explicitly rather than on an internal timer so the consumer controls scheduling and the pool stays single-threaded.
|
||||
|
||||
## Features
|
||||
|
||||
- Pre-warmed up to `minSize` on construction.
|
||||
- Lazy growth up to `maxSize` on acquisition.
|
||||
- `POOL_EXHAUSTED` surfaced in a `SandboxResult` when the pool cannot grow.
|
||||
- Per-engine execution count, recycle on threshold.
|
||||
- `cleanupTick()` reclaims idle engines down to `minSize`.
|
||||
- Injectable `Clock` for deterministic tests.
|
||||
- OTel `recordPoolAcquisition` for each acquisition (acquired / created /
|
||||
exhausted).
|
||||
- Aggregate `stats()` surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { VmEngine } from "@suaveplan/sandbox/vm-engine";
|
||||
import { SandboxPool } from "@suaveplan/sandbox/pool";
|
||||
|
||||
const pool = new SandboxPool({
|
||||
minSize: 2,
|
||||
maxSize: 8,
|
||||
engineConfig: { timeout: 5000, maxOutputSize: 1024, memoryLimit: 5e7 },
|
||||
factory: (cfg) => new VmEngine(cfg),
|
||||
});
|
||||
const r = await pool.execute("1 + 1");
|
||||
pool.destroy();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive cleanup against a `FakeClock` so idle reclamation can be asserted:
|
||||
|
||||
```ts
|
||||
let now = 0;
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory,
|
||||
minSize: 1,
|
||||
maxSize: 4,
|
||||
idleTimeoutMs: 1000,
|
||||
clock: () => now,
|
||||
});
|
||||
// run a few executions, then advance time:
|
||||
now = 5000;
|
||||
pool.cleanupTick();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SandboxPool(config)`.
|
||||
- `execute(code, context?, options?): Promise<SandboxResult>`.
|
||||
- `size()`, `available()`, `inUse()`, `stats()`.
|
||||
- `warmUp()`, `cleanupTick()`, `destroy()`.
|
||||
- `DEFAULT_POOL_CONFIG` — exported defaults for downstream composition.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`acquire` prefers idle engines over creating new ones — this keeps warmed
|
||||
engines reusable rather than stranding them behind a fresh allocation when an
|
||||
old engine is right there. Recycling is reactive (on release, after the
|
||||
threshold is crossed) rather than proactive (at acquisition time) so a
|
||||
recycle never delays the path that needs the engine. The `clock` lookup
|
||||
happens once per `lastUsed`/`createdAt` write so callers can swap the clock
|
||||
implementation without re-engineering the pool's call graph.
|
||||
273
packages/devtools/tooling/sandbox/src/pool/pool.test.ts
Normal file
273
packages/devtools/tooling/sandbox/src/pool/pool.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
ExecutionContext,
|
||||
SandboxEngine,
|
||||
SandboxEngineConfig,
|
||||
SandboxModule,
|
||||
SandboxOptions,
|
||||
SandboxResult,
|
||||
} from "../types/sandbox.types.js";
|
||||
import { SandboxPool } from "./pool.js";
|
||||
|
||||
interface ScriptableEngine extends SandboxEngine {
|
||||
setDelay(ms: number): void;
|
||||
}
|
||||
|
||||
let engineId = 0;
|
||||
|
||||
function makeEngine(_config: SandboxEngineConfig): ScriptableEngine {
|
||||
let delay = 0;
|
||||
const id = engineId++;
|
||||
const modules: SandboxModule[] = [];
|
||||
let destroyed = false;
|
||||
return {
|
||||
name: `fake-${id}`,
|
||||
async execute<T>(): Promise<SandboxResult<T>> {
|
||||
if (destroyed) {
|
||||
return {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error: "destroyed",
|
||||
duration: 0,
|
||||
memoryUsed: 0,
|
||||
logs: [],
|
||||
};
|
||||
}
|
||||
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
|
||||
return {
|
||||
success: true,
|
||||
result: id as T,
|
||||
error: undefined,
|
||||
duration: 1,
|
||||
memoryUsed: 0,
|
||||
logs: [],
|
||||
};
|
||||
},
|
||||
registerModule(mod: SandboxModule): void {
|
||||
modules.push(mod);
|
||||
},
|
||||
unregisterModule(): boolean {
|
||||
return false;
|
||||
},
|
||||
reset(): void {
|
||||
/* noop */
|
||||
},
|
||||
destroy(): void {
|
||||
destroyed = true;
|
||||
},
|
||||
setDelay(ms: number): void {
|
||||
delay = ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const engineConfig: SandboxEngineConfig = {
|
||||
timeout: 1000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 50 * 1024 * 1024,
|
||||
};
|
||||
|
||||
describe("SandboxPool", () => {
|
||||
it("warms up to minSize on construction", { timeout: 5000 }, () => {
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: makeEngine,
|
||||
minSize: 3,
|
||||
maxSize: 5,
|
||||
});
|
||||
expect(pool.size()).toBe(3);
|
||||
expect(pool.available()).toBe(3);
|
||||
expect(pool.inUse()).toBe(0);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("uses defaults when minSize/maxSize omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pool = new SandboxPool({ engineConfig, factory: makeEngine });
|
||||
expect(pool.size()).toBe(2);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("execute returns a result via the pool", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: makeEngine,
|
||||
minSize: 1,
|
||||
maxSize: 2,
|
||||
});
|
||||
const r = await pool.execute("anything");
|
||||
expect(r.success).toBe(true);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("execute passes context and options through", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: makeEngine,
|
||||
minSize: 1,
|
||||
maxSize: 2,
|
||||
});
|
||||
const context: ExecutionContext = { a: 1 };
|
||||
const options: SandboxOptions = { timeout: 100 };
|
||||
const r = await pool.execute("code", context, options);
|
||||
expect(r.success).toBe(true);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("grows up to maxSize lazily then reports POOL_EXHAUSTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: (cfg) => {
|
||||
const e = makeEngine(cfg);
|
||||
e.setDelay(50);
|
||||
return e;
|
||||
},
|
||||
minSize: 1,
|
||||
maxSize: 2,
|
||||
});
|
||||
const first = pool.execute("a");
|
||||
const second = pool.execute("b");
|
||||
const third = pool.execute("c");
|
||||
const [r1, r2, r3] = await Promise.all([first, second, third]);
|
||||
// first two succeed via grow-on-demand; third hits POOL_EXHAUSTED
|
||||
expect([r1.success, r2.success, r3.success].sort()).toEqual([
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
const failure = [r1, r2, r3].find((r) => !r.success);
|
||||
expect(failure?.error).toContain("POOL_EXHAUSTED");
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("reports POOL_EXHAUSTED when saturated", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: (cfg) => {
|
||||
const e = makeEngine(cfg);
|
||||
e.setDelay(50);
|
||||
return e;
|
||||
},
|
||||
minSize: 1,
|
||||
maxSize: 1,
|
||||
});
|
||||
const first = pool.execute("a");
|
||||
const second = pool.execute("b");
|
||||
const [r1, r2] = await Promise.all([first, second]);
|
||||
// one succeeds and one fails with POOL_EXHAUSTED
|
||||
expect([r1.success, r2.success].sort()).toEqual([false, true]);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("recycles engines past maxExecutionsPerEngine", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let createdCount = 0;
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: (cfg) => {
|
||||
createdCount += 1;
|
||||
return makeEngine(cfg);
|
||||
},
|
||||
minSize: 1,
|
||||
maxSize: 1,
|
||||
maxExecutionsPerEngine: 2,
|
||||
});
|
||||
await pool.execute("a");
|
||||
await pool.execute("b");
|
||||
// Second release should have recycled the engine, bumping the count.
|
||||
await pool.execute("c");
|
||||
expect(createdCount).toBeGreaterThanOrEqual(2);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("cleanupTick reclaims idle engines to minSize", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 0;
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: makeEngine,
|
||||
minSize: 1,
|
||||
maxSize: 4,
|
||||
idleTimeoutMs: 1000,
|
||||
clock: () => now,
|
||||
});
|
||||
// grow to 3 engines
|
||||
await Promise.all([
|
||||
pool.execute("a"),
|
||||
pool.execute("b"),
|
||||
pool.execute("c"),
|
||||
]);
|
||||
expect(pool.size()).toBeGreaterThan(1);
|
||||
now = 5000;
|
||||
pool.cleanupTick();
|
||||
expect(pool.size()).toBe(1);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("cleanupTick is a no-op when nothing is idle long enough", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let now = 0;
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: makeEngine,
|
||||
minSize: 1,
|
||||
maxSize: 4,
|
||||
idleTimeoutMs: 100_000,
|
||||
clock: () => now,
|
||||
});
|
||||
await pool.execute("a");
|
||||
now = 500;
|
||||
pool.cleanupTick();
|
||||
expect(pool.size()).toBeGreaterThanOrEqual(1);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("stats reports aggregate state", { timeout: 5000 }, async () => {
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: makeEngine,
|
||||
minSize: 1,
|
||||
maxSize: 2,
|
||||
});
|
||||
await pool.execute("a");
|
||||
const s = pool.stats();
|
||||
expect(s.size).toBeGreaterThanOrEqual(1);
|
||||
expect(s.totalExecutions).toBeGreaterThanOrEqual(1);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("warmUp is idempotent", { timeout: 5000 }, () => {
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: makeEngine,
|
||||
minSize: 2,
|
||||
maxSize: 4,
|
||||
});
|
||||
pool.warmUp();
|
||||
expect(pool.size()).toBe(2);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("destroy clears the pool", { timeout: 5000 }, () => {
|
||||
const pool = new SandboxPool({
|
||||
engineConfig,
|
||||
factory: makeEngine,
|
||||
minSize: 2,
|
||||
maxSize: 4,
|
||||
});
|
||||
pool.destroy();
|
||||
expect(pool.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
209
packages/devtools/tooling/sandbox/src/pool/pool.ts
Normal file
209
packages/devtools/tooling/sandbox/src/pool/pool.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { SANDBOX_CODES } from "../error-codes/error-codes.js";
|
||||
import { recordPoolAcquisition } from "../telemetry/telemetry.js";
|
||||
import type {
|
||||
Clock,
|
||||
ExecutionContext,
|
||||
SandboxEngine,
|
||||
SandboxEngineConfig,
|
||||
SandboxOptions,
|
||||
SandboxResult,
|
||||
} from "../types/sandbox.types.js";
|
||||
|
||||
/** Factory that builds engines on demand. */
|
||||
export type EngineFactory = (config: SandboxEngineConfig) => SandboxEngine;
|
||||
|
||||
/** Pool configuration. */
|
||||
export interface SandboxPoolConfig {
|
||||
readonly minSize: number;
|
||||
readonly maxSize: number;
|
||||
readonly engineConfig: SandboxEngineConfig;
|
||||
readonly factory: EngineFactory;
|
||||
readonly idleTimeoutMs: number;
|
||||
readonly maxExecutionsPerEngine: number;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
interface PooledEngine {
|
||||
engine: SandboxEngine;
|
||||
inUse: boolean;
|
||||
executionCount: number;
|
||||
lastUsed: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Omit<SandboxPoolConfig, "engineConfig" | "factory"> = {
|
||||
minSize: 2,
|
||||
maxSize: 10,
|
||||
idleTimeoutMs: 60_000,
|
||||
maxExecutionsPerEngine: 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pool of pre-warmed sandbox engines. The pool grows lazily up to `maxSize`,
|
||||
* recycles engines past `maxExecutionsPerEngine`, and reclaims idle engines
|
||||
* above `minSize` whose `lastUsed` exceeds `idleTimeoutMs` (relative to the
|
||||
* injectable {@link Clock}, defaulting to `Date.now`).
|
||||
*/
|
||||
export class SandboxPool {
|
||||
private readonly config: SandboxPoolConfig;
|
||||
private readonly clock: Clock;
|
||||
private pool: PooledEngine[] = [];
|
||||
|
||||
public constructor(
|
||||
config: Pick<SandboxPoolConfig, "engineConfig" | "factory"> &
|
||||
Partial<SandboxPoolConfig>
|
||||
) {
|
||||
this.config = { ...DEFAULTS, ...config };
|
||||
this.clock = this.config.clock ?? (() => Date.now());
|
||||
this.warmUp();
|
||||
}
|
||||
|
||||
/** Execute code on a pooled engine. */
|
||||
public async execute<T = unknown>(
|
||||
code: string,
|
||||
context?: ExecutionContext,
|
||||
options?: SandboxOptions
|
||||
): Promise<SandboxResult<T>> {
|
||||
const pooled = this.acquire();
|
||||
if (pooled === null) {
|
||||
recordPoolAcquisition("exhausted");
|
||||
return {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error: `Sandbox pool exhausted (${SANDBOX_CODES.POOL_EXHAUSTED})`,
|
||||
duration: 0,
|
||||
memoryUsed: 0,
|
||||
logs: [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await pooled.engine.execute<T>(
|
||||
code,
|
||||
context,
|
||||
options
|
||||
);
|
||||
pooled.executionCount += 1;
|
||||
pooled.lastUsed = this.clock();
|
||||
return result;
|
||||
} finally {
|
||||
this.release(pooled);
|
||||
}
|
||||
}
|
||||
|
||||
/** Current pool size. */
|
||||
public size(): number {
|
||||
return this.pool.length;
|
||||
}
|
||||
|
||||
/** Idle engines. */
|
||||
public available(): number {
|
||||
return this.pool.filter((p) => !p.inUse).length;
|
||||
}
|
||||
|
||||
/** In-use engines. */
|
||||
public inUse(): number {
|
||||
return this.pool.filter((p) => p.inUse).length;
|
||||
}
|
||||
|
||||
/** Aggregate pool stats. */
|
||||
public stats(): {
|
||||
size: number;
|
||||
available: number;
|
||||
inUse: number;
|
||||
totalExecutions: number;
|
||||
} {
|
||||
let totalExecutions = 0;
|
||||
for (const p of this.pool) totalExecutions += p.executionCount;
|
||||
return {
|
||||
size: this.pool.length,
|
||||
available: this.available(),
|
||||
inUse: this.inUse(),
|
||||
totalExecutions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bring the pool up to `minSize`. */
|
||||
public warmUp(): void {
|
||||
while (this.pool.length < this.config.minSize) {
|
||||
this.pool.push(this.createPooled());
|
||||
}
|
||||
}
|
||||
|
||||
/** Single cleanup tick — reclaims idle engines above `minSize`. */
|
||||
public cleanupTick(): void {
|
||||
const now = this.clock();
|
||||
const survivors: PooledEngine[] = [];
|
||||
let removed = 0;
|
||||
for (const pooled of this.pool) {
|
||||
if (
|
||||
!pooled.inUse &&
|
||||
this.pool.length - removed > this.config.minSize &&
|
||||
now - pooled.lastUsed > this.config.idleTimeoutMs
|
||||
) {
|
||||
pooled.engine.destroy();
|
||||
removed += 1;
|
||||
continue;
|
||||
}
|
||||
survivors.push(pooled);
|
||||
}
|
||||
this.pool = survivors;
|
||||
}
|
||||
|
||||
/** Destroy every engine in the pool. */
|
||||
public destroy(): void {
|
||||
for (const pooled of this.pool) {
|
||||
pooled.engine.destroy();
|
||||
}
|
||||
this.pool = [];
|
||||
}
|
||||
|
||||
private acquire(): PooledEngine | null {
|
||||
const idle = this.pool.find((p) => !p.inUse);
|
||||
if (idle !== undefined) {
|
||||
idle.inUse = true;
|
||||
recordPoolAcquisition("acquired");
|
||||
return idle;
|
||||
}
|
||||
if (this.pool.length < this.config.maxSize) {
|
||||
const pooled = this.createPooled();
|
||||
pooled.inUse = true;
|
||||
this.pool.push(pooled);
|
||||
recordPoolAcquisition("created");
|
||||
return pooled;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private release(pooled: PooledEngine): void {
|
||||
pooled.inUse = false;
|
||||
if (pooled.executionCount >= this.config.maxExecutionsPerEngine) {
|
||||
this.recycle(pooled);
|
||||
}
|
||||
}
|
||||
|
||||
private recycle(pooled: PooledEngine): void {
|
||||
pooled.engine.destroy();
|
||||
// Replace in-place: copy a fresh engine onto the same PooledEngine
|
||||
// wrapper so the array reference stays valid for any other code
|
||||
// that holds it. We rotate every field so the recycled entry is
|
||||
// indistinguishable from a freshly created one.
|
||||
const fresh = this.createPooled();
|
||||
pooled.engine = fresh.engine;
|
||||
pooled.executionCount = 0;
|
||||
pooled.lastUsed = fresh.lastUsed;
|
||||
pooled.createdAt = fresh.createdAt;
|
||||
}
|
||||
|
||||
private createPooled(): PooledEngine {
|
||||
const engine = this.config.factory(this.config.engineConfig);
|
||||
return {
|
||||
engine,
|
||||
inUse: false,
|
||||
executionCount: 0,
|
||||
lastUsed: this.clock(),
|
||||
createdAt: this.clock(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULTS as DEFAULT_POOL_CONFIG };
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { LimiterConfig } from "./resource-limiter.js";
|
||||
export { ResourceLimiter } from "./resource-limiter.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# ResourceLimiter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ResourceLimiter` enforces two complementary controls on the sandbox boundary: a sliding-window rate limit (so a single caller cannot exhaust the engine with rapid-fire executions) and an output-size cap (so log capture cannot grow unbounded inside long-running scripts). Both controls operate against an injectable `Clock` so tests can advance time deterministically rather than relying on `setTimeout` and observed wall-clock drift.
|
||||
|
||||
The limiter has no opinion about *what* it limits — the same instance is reused across executions, and the rate-limit window slides naturally as time passes. When the configured `maxExecutionsPerWindow` is `undefined`, the limiter is effectively a no-op for rate control; the output truncation still applies.
|
||||
|
||||
## Features
|
||||
|
||||
- Sliding-window rate limiting tracked via a timestamp array, pruned on every
|
||||
`checkRateLimit()` call.
|
||||
- Injectable `Clock` (`() => number`); defaults to `Date.now`.
|
||||
- `truncateOutput(str)` returns the original string when under the limit,
|
||||
otherwise a truncation with a sentinel `[output truncated at N bytes]`.
|
||||
- Total + window-scoped execution counters for observability.
|
||||
- `reset()` clears state without re-allocating the limiter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ResourceLimiter } from "@suaveplan/sandbox/resource-limiter";
|
||||
|
||||
const limiter = new ResourceLimiter({
|
||||
maxExecutionsPerWindow: 10,
|
||||
rateLimitWindow: 60_000,
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
|
||||
limiter.checkRateLimit();
|
||||
limiter.recordExecution();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the limiter against a `FakeClock` in tests to assert the sliding-window
|
||||
behaviour without flaky waits:
|
||||
|
||||
```ts
|
||||
import { ResourceLimiter } from "@suaveplan/sandbox/resource-limiter";
|
||||
|
||||
let now = 0;
|
||||
const limiter = new ResourceLimiter({
|
||||
maxExecutionsPerWindow: 1,
|
||||
rateLimitWindow: 1000,
|
||||
maxOutputSize: 1024,
|
||||
clock: () => now,
|
||||
});
|
||||
limiter.checkRateLimit();
|
||||
limiter.recordExecution();
|
||||
now = 2000; // advance past the window
|
||||
limiter.checkRateLimit(); // succeeds
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ResourceLimiter(config: LimiterConfig)`.
|
||||
- `checkRateLimit(): void` — throws `SandboxError(RATE_LIMITED)`.
|
||||
- `recordExecution(): void`.
|
||||
- `getTotalExecutions(): number`.
|
||||
- `getWindowExecutions(): number`.
|
||||
- `truncateOutput(s: string): string`.
|
||||
- `reset(): void`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The timestamp array is rebuilt on every `checkRateLimit()` call via
|
||||
`Array.prototype.filter`. For limiters with very high throughput (>10k
|
||||
windowed events) a circular buffer would be more efficient; the current
|
||||
implementation prefers clarity because rate limits at this layer are typically
|
||||
a low single-digit count of events per window.
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import { ResourceLimiter } from "./resource-limiter.js";
|
||||
|
||||
describe("ResourceLimiter", () => {
|
||||
it("is unlimited when maxExecutionsPerWindow is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const limiter = new ResourceLimiter({
|
||||
rateLimitWindow: 60_000,
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
limiter.checkRateLimit();
|
||||
limiter.recordExecution();
|
||||
}
|
||||
expect(limiter.getTotalExecutions()).toBe(100);
|
||||
});
|
||||
|
||||
it("throws RATE_LIMITED when window is full", { timeout: 5000 }, () => {
|
||||
const now = 0;
|
||||
const limiter = new ResourceLimiter({
|
||||
maxExecutionsPerWindow: 2,
|
||||
rateLimitWindow: 60_000,
|
||||
maxOutputSize: 1024,
|
||||
clock: () => now,
|
||||
});
|
||||
limiter.checkRateLimit();
|
||||
limiter.recordExecution();
|
||||
limiter.checkRateLimit();
|
||||
limiter.recordExecution();
|
||||
try {
|
||||
limiter.checkRateLimit();
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxError);
|
||||
expect((err as SandboxError).code).toBe(SANDBOX_CODES.RATE_LIMITED);
|
||||
}
|
||||
});
|
||||
|
||||
it("sliding window resets after expiry", { timeout: 5000 }, () => {
|
||||
let now = 0;
|
||||
const limiter = new ResourceLimiter({
|
||||
maxExecutionsPerWindow: 1,
|
||||
rateLimitWindow: 1000,
|
||||
maxOutputSize: 1024,
|
||||
clock: () => now,
|
||||
});
|
||||
limiter.checkRateLimit();
|
||||
limiter.recordExecution();
|
||||
now = 2000;
|
||||
// Window has elapsed — third call must succeed.
|
||||
expect(() => limiter.checkRateLimit()).not.toThrow();
|
||||
});
|
||||
|
||||
it("getWindowExecutions reports only in-window timestamps", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let now = 0;
|
||||
const limiter = new ResourceLimiter({
|
||||
rateLimitWindow: 1000,
|
||||
maxOutputSize: 1024,
|
||||
clock: () => now,
|
||||
});
|
||||
limiter.recordExecution();
|
||||
now = 2000;
|
||||
limiter.recordExecution();
|
||||
expect(limiter.getTotalExecutions()).toBe(2);
|
||||
expect(limiter.getWindowExecutions()).toBe(1);
|
||||
});
|
||||
|
||||
it("truncates oversize output", { timeout: 5000 }, () => {
|
||||
const limiter = new ResourceLimiter({
|
||||
rateLimitWindow: 60_000,
|
||||
maxOutputSize: 8,
|
||||
});
|
||||
const out = limiter.truncateOutput("abcdefghij");
|
||||
expect(out.startsWith("abcdefgh")).toBe(true);
|
||||
expect(out).toContain("truncated at 8 bytes");
|
||||
});
|
||||
|
||||
it("passes through under-limit output unchanged", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const limiter = new ResourceLimiter({
|
||||
rateLimitWindow: 60_000,
|
||||
maxOutputSize: 100,
|
||||
});
|
||||
expect(limiter.truncateOutput("short")).toBe("short");
|
||||
});
|
||||
|
||||
it("reset zeroes counters", { timeout: 5000 }, () => {
|
||||
const limiter = new ResourceLimiter({
|
||||
rateLimitWindow: 60_000,
|
||||
maxOutputSize: 8,
|
||||
});
|
||||
limiter.recordExecution();
|
||||
limiter.reset();
|
||||
expect(limiter.getTotalExecutions()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import type { Clock } from "../types/sandbox.types.js";
|
||||
|
||||
/** Configuration for the resource limiter. */
|
||||
export interface LimiterConfig {
|
||||
readonly maxExecutionsPerWindow?: number;
|
||||
readonly rateLimitWindow: number;
|
||||
readonly maxOutputSize: number;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
|
||||
/**
|
||||
* Tracks and enforces resource limits for sandbox executions: a sliding-window
|
||||
* rate limit + output-size truncation. Time-related state is driven by an
|
||||
* injectable {@link Clock} so tests can advance time deterministically.
|
||||
*/
|
||||
export class ResourceLimiter {
|
||||
private readonly maxExecutionsPerWindow: number | undefined;
|
||||
private readonly rateLimitWindow: number;
|
||||
private readonly maxOutputSize: number;
|
||||
private readonly clock: Clock;
|
||||
private executionTimestamps: number[] = [];
|
||||
private totalExecutions = 0;
|
||||
|
||||
public constructor(config: LimiterConfig) {
|
||||
this.maxExecutionsPerWindow = config.maxExecutionsPerWindow;
|
||||
this.rateLimitWindow = config.rateLimitWindow;
|
||||
this.maxOutputSize = config.maxOutputSize;
|
||||
this.clock = config.clock ?? defaultClock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop expired timestamps and verify the caller is below the limit.
|
||||
* Raises `SandboxError(RATE_LIMITED)` when the limit is tripped.
|
||||
*/
|
||||
public checkRateLimit(): void {
|
||||
if (this.maxExecutionsPerWindow === undefined) return;
|
||||
const now = this.clock();
|
||||
const windowStart = now - this.rateLimitWindow;
|
||||
this.executionTimestamps = this.executionTimestamps.filter(
|
||||
(t) => t > windowStart
|
||||
);
|
||||
if (this.executionTimestamps.length >= this.maxExecutionsPerWindow) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.RATE_LIMITED,
|
||||
`Rate limit exceeded: ${this.maxExecutionsPerWindow} executions per ${this.rateLimitWindow}ms window`,
|
||||
{
|
||||
details: {
|
||||
max: this.maxExecutionsPerWindow,
|
||||
windowMs: this.rateLimitWindow,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an execution timestamp. */
|
||||
public recordExecution(): void {
|
||||
this.executionTimestamps.push(this.clock());
|
||||
this.totalExecutions += 1;
|
||||
}
|
||||
|
||||
/** Total executions tracked across the limiter's lifetime. */
|
||||
public getTotalExecutions(): number {
|
||||
return this.totalExecutions;
|
||||
}
|
||||
|
||||
/** Executions within the current sliding window. */
|
||||
public getWindowExecutions(): number {
|
||||
const now = this.clock();
|
||||
const windowStart = now - this.rateLimitWindow;
|
||||
return this.executionTimestamps.filter((t) => t > windowStart).length;
|
||||
}
|
||||
|
||||
/** Truncate oversize output with a sentinel suffix. */
|
||||
public truncateOutput(output: string): string {
|
||||
if (output.length <= this.maxOutputSize) return output;
|
||||
const truncated = output.slice(0, this.maxOutputSize);
|
||||
return `${truncated}\n[output truncated at ${this.maxOutputSize} bytes]`;
|
||||
}
|
||||
|
||||
/** Reset every tracked counter. */
|
||||
public reset(): void {
|
||||
this.executionTimestamps = [];
|
||||
this.totalExecutions = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
ResultStreamerConfig,
|
||||
StreamChunk,
|
||||
StreamListener,
|
||||
} from "./result-streamer.js";
|
||||
export {
|
||||
DEFAULT_STREAMER_CONFIG,
|
||||
ResultStreamer,
|
||||
} from "./result-streamer.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# ResultStreamer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ResultStreamer` delivers sandbox output incrementally rather than as a single end-of-execution dump. The most common use case is long-running scripts that emit logs over many seconds: subscribers receive `StreamChunk`s as soon as the buffer either reaches `maxBufferSize` or the next `flushIntervalMs` tick fires, instead of waiting for the whole execution to finish. A final `complete` chunk carries the structured result envelope (success / duration / memoryUsed / error) so subscribers can tear down their UI state at the right moment.
|
||||
|
||||
The streamer enforces a strict completion lifecycle: once `complete()` has fired, any further `pushChunk` raises `SandboxError(STREAM_CLOSED)`. This prevents post-completion writes from corrupting downstream state and surfaces programming errors immediately rather than letting them silently drop chunks.
|
||||
|
||||
## Features
|
||||
|
||||
- Four chunk types: `log`, `result`, `error`, `complete`.
|
||||
- Per-chunk wall-clock timestamp and elapsed-since-start milliseconds.
|
||||
- Configurable `maxBufferSize` (auto-flush threshold) and
|
||||
`flushIntervalMs` (auto-flush cadence).
|
||||
- Subscribe / unsubscribe with returned disposer.
|
||||
- `complete` is idempotent (second call is a no-op).
|
||||
- Strict post-complete `STREAM_CLOSED` enforcement.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ResultStreamer } from "@suaveplan/sandbox/result-streamer";
|
||||
|
||||
const s = new ResultStreamer();
|
||||
s.subscribe((c) => console.log(c.type, c.data));
|
||||
s.start();
|
||||
s.pushLog("processing...");
|
||||
s.complete({ success: true, result: 42, error: undefined, duration: 100, memoryUsed: 0, logs: [] });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pipe stream chunks straight into a Server-Sent-Events response:
|
||||
|
||||
```ts
|
||||
import { ResultStreamer } from "@suaveplan/sandbox/result-streamer";
|
||||
|
||||
const s = new ResultStreamer({ flushIntervalMs: 50 });
|
||||
s.subscribe((c) => {
|
||||
res.write(`event: ${c.type}\ndata: ${c.data}\n\n`);
|
||||
});
|
||||
s.start();
|
||||
// ...drive the sandbox, push chunks, await complete
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ResultStreamer(config?: Partial<ResultStreamerConfig>)`.
|
||||
- `start()`, `stop()`, `destroy()`.
|
||||
- `subscribe(listener): () => void`.
|
||||
- `pushLog(message)`, `pushError(error)`, `pushResult(data)`.
|
||||
- `complete(result: SandboxResult): void`.
|
||||
- `flush()`, `bufferSize()`, `isCompleted()`, `listenerCount()`.
|
||||
- `DEFAULT_STREAMER_CONFIG` — exported defaults.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`pushChunk` auto-flushes when the buffer reaches `maxBufferSize`, so callers
|
||||
that don't run the interval flusher (`flushIntervalMs: 0`) still see chunks
|
||||
land at subscribers without manual `flush()` calls — useful for tests that
|
||||
want deterministic delivery. The `elapsed` field is recorded against
|
||||
`startTime`; when the streamer is used without `start()`, `elapsed` carries
|
||||
the value relative to `performance.now() - 0`, which is the host's high-res
|
||||
timer origin — a slight oddity but a tolerable one because the documented
|
||||
contract is to call `start()` first.
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import { ResultStreamer, type StreamChunk } from "./result-streamer.js";
|
||||
|
||||
describe("ResultStreamer", () => {
|
||||
it("subscribes and receives a flushed log chunk", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
const received: StreamChunk[] = [];
|
||||
s.subscribe((c) => received.push(c));
|
||||
s.start();
|
||||
s.pushLog("a");
|
||||
s.flush();
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]?.type).toBe("log");
|
||||
expect(received[0]?.data).toBe("a");
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("flush emits buffered chunks to subscribers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
const out: StreamChunk[] = [];
|
||||
s.subscribe((c) => out.push(c));
|
||||
s.start();
|
||||
s.pushLog("one");
|
||||
s.pushError("two");
|
||||
s.pushResult("three");
|
||||
s.flush();
|
||||
expect(out.map((c) => c.type)).toEqual(["log", "error", "result"]);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("auto-flushes when buffer hits maxBufferSize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new ResultStreamer({
|
||||
maxBufferSize: 2,
|
||||
flushIntervalMs: 0,
|
||||
});
|
||||
const out: StreamChunk[] = [];
|
||||
s.subscribe((c) => out.push(c));
|
||||
s.start();
|
||||
s.pushLog("a");
|
||||
expect(out).toHaveLength(0);
|
||||
s.pushLog("b");
|
||||
// Second push triggers auto-flush.
|
||||
expect(out).toHaveLength(2);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("unsubscribe removes the listener", { timeout: 5000 }, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
const out: StreamChunk[] = [];
|
||||
const off = s.subscribe((c) => out.push(c));
|
||||
off();
|
||||
s.start();
|
||||
s.pushLog("a");
|
||||
s.flush();
|
||||
expect(out).toHaveLength(0);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("complete flushes buffered chunks then emits complete chunk", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
const out: StreamChunk[] = [];
|
||||
s.subscribe((c) => out.push(c));
|
||||
s.start();
|
||||
s.pushLog("a");
|
||||
s.complete({
|
||||
success: true,
|
||||
result: 1,
|
||||
error: undefined,
|
||||
duration: 5,
|
||||
memoryUsed: 0,
|
||||
logs: [],
|
||||
});
|
||||
expect(out.map((c) => c.type)).toEqual(["log", "complete"]);
|
||||
});
|
||||
|
||||
it("complete is idempotent", { timeout: 5000 }, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
const out: StreamChunk[] = [];
|
||||
s.subscribe((c) => out.push(c));
|
||||
s.start();
|
||||
const result = {
|
||||
success: true,
|
||||
result: 1,
|
||||
error: undefined,
|
||||
duration: 1,
|
||||
memoryUsed: 0,
|
||||
logs: [],
|
||||
};
|
||||
s.complete(result);
|
||||
s.complete(result);
|
||||
expect(out.filter((c) => c.type === "complete")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("raises STREAM_CLOSED after complete", { timeout: 5000 }, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
s.start();
|
||||
s.complete({
|
||||
success: true,
|
||||
result: 1,
|
||||
error: undefined,
|
||||
duration: 1,
|
||||
memoryUsed: 0,
|
||||
logs: [],
|
||||
});
|
||||
try {
|
||||
s.pushLog("late");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxError);
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.STREAM_CLOSED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("bufferSize / isCompleted / listenerCount accessors", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
s.start();
|
||||
const off = s.subscribe(() => {});
|
||||
s.pushLog("a");
|
||||
expect(s.bufferSize()).toBe(1);
|
||||
expect(s.listenerCount()).toBe(1);
|
||||
expect(s.isCompleted()).toBe(false);
|
||||
off();
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("auto-flush timer fires", { timeout: 5000 }, async () => {
|
||||
const s = new ResultStreamer({
|
||||
flushIntervalMs: 10,
|
||||
maxBufferSize: 100,
|
||||
});
|
||||
const out: StreamChunk[] = [];
|
||||
s.subscribe((c) => out.push(c));
|
||||
s.start();
|
||||
s.pushLog("a");
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("stop clears the timer without flushing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 1000 });
|
||||
s.start();
|
||||
s.pushLog("a");
|
||||
s.stop();
|
||||
// After stop, buffer is still populated; flush manually.
|
||||
expect(s.bufferSize()).toBe(1);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("destroy clears listeners and buffer", { timeout: 5000 }, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
s.start();
|
||||
s.subscribe(() => {});
|
||||
s.pushLog("a");
|
||||
s.destroy();
|
||||
expect(s.bufferSize()).toBe(0);
|
||||
expect(s.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("uses default config when none provided", { timeout: 5000 }, () => {
|
||||
const s = new ResultStreamer();
|
||||
s.start();
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("constructor without start still allows pushChunk (timing from 0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new ResultStreamer({ flushIntervalMs: 0 });
|
||||
const out: StreamChunk[] = [];
|
||||
s.subscribe((c) => out.push(c));
|
||||
s.pushLog("no-start");
|
||||
s.flush();
|
||||
expect(out).toHaveLength(1);
|
||||
s.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import type { SandboxResult } from "../types/sandbox.types.js";
|
||||
|
||||
/** A chunk of streamed output. */
|
||||
export interface StreamChunk {
|
||||
readonly type: "log" | "result" | "error" | "complete";
|
||||
readonly data: string;
|
||||
readonly timestamp: number;
|
||||
readonly elapsed: number;
|
||||
}
|
||||
|
||||
/** Listener for stream chunks. */
|
||||
export type StreamListener = (chunk: StreamChunk) => void;
|
||||
|
||||
/** Streamer configuration. */
|
||||
export interface ResultStreamerConfig {
|
||||
readonly maxBufferSize: number;
|
||||
readonly flushIntervalMs: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: ResultStreamerConfig = {
|
||||
maxBufferSize: 100,
|
||||
flushIntervalMs: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Streams sandbox execution output incrementally. Listeners receive buffered
|
||||
* chunks at every flush (auto-flushed on a configurable interval, manually
|
||||
* via {@link ResultStreamer.flush}, or implicitly via
|
||||
* {@link ResultStreamer.complete}). After completion, any `pushChunk` raises
|
||||
* `SandboxError(STREAM_CLOSED)`.
|
||||
*/
|
||||
export class ResultStreamer {
|
||||
private readonly config: ResultStreamerConfig;
|
||||
private listeners: StreamListener[] = [];
|
||||
private buffer: StreamChunk[] = [];
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private startTime = 0;
|
||||
private completed = false;
|
||||
|
||||
public constructor(config?: Partial<ResultStreamerConfig>) {
|
||||
this.config = { ...DEFAULTS, ...config };
|
||||
}
|
||||
|
||||
/** Begin timing and start the auto-flush interval (if > 0). */
|
||||
public start(): void {
|
||||
this.startTime = performance.now();
|
||||
this.completed = false;
|
||||
if (this.config.flushIntervalMs > 0) {
|
||||
this.flushTimer = setInterval(
|
||||
() => this.flush(),
|
||||
this.config.flushIntervalMs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe; returns an unsubscribe function. */
|
||||
public subscribe(listener: StreamListener): () => void {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Push a log chunk. */
|
||||
public pushLog(message: string): void {
|
||||
this.pushChunk({ type: "log", data: message });
|
||||
}
|
||||
|
||||
/** Push an error chunk. */
|
||||
public pushError(error: string): void {
|
||||
this.pushChunk({ type: "error", data: error });
|
||||
}
|
||||
|
||||
/** Push a partial result chunk. */
|
||||
public pushResult(data: string): void {
|
||||
this.pushChunk({ type: "result", data });
|
||||
}
|
||||
|
||||
/** Complete the stream with a final result envelope. */
|
||||
public complete<T>(result: SandboxResult<T>): void {
|
||||
if (this.completed) return;
|
||||
this.flush();
|
||||
const chunk: StreamChunk = {
|
||||
type: "complete",
|
||||
data: JSON.stringify({
|
||||
success: result.success,
|
||||
duration: result.duration,
|
||||
memoryUsed: result.memoryUsed,
|
||||
error: result.error,
|
||||
}),
|
||||
timestamp: Date.now(),
|
||||
elapsed: performance.now() - this.startTime,
|
||||
};
|
||||
this.emit(chunk);
|
||||
this.completed = true;
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** Flush buffered chunks to listeners. */
|
||||
public flush(): void {
|
||||
const chunks = this.buffer.splice(0);
|
||||
for (const chunk of chunks) this.emit(chunk);
|
||||
}
|
||||
|
||||
/** Stop the auto-flush timer (does not flush). */
|
||||
public stop(): void {
|
||||
if (this.flushTimer !== null) {
|
||||
clearInterval(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Buffered (un-flushed) chunk count. */
|
||||
public bufferSize(): number {
|
||||
return this.buffer.length;
|
||||
}
|
||||
|
||||
/** Whether the stream is closed. */
|
||||
public isCompleted(): boolean {
|
||||
return this.completed;
|
||||
}
|
||||
|
||||
/** Active listener count. */
|
||||
public listenerCount(): number {
|
||||
return this.listeners.length;
|
||||
}
|
||||
|
||||
/** Stop, flush, and remove listeners. */
|
||||
public destroy(): void {
|
||||
this.stop();
|
||||
this.flush();
|
||||
this.listeners = [];
|
||||
this.buffer = [];
|
||||
}
|
||||
|
||||
private pushChunk(
|
||||
partial: Omit<StreamChunk, "timestamp" | "elapsed">
|
||||
): void {
|
||||
if (this.completed) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.STREAM_CLOSED,
|
||||
`Cannot push ${partial.type} chunk after stream completed`
|
||||
);
|
||||
}
|
||||
const chunk: StreamChunk = {
|
||||
...partial,
|
||||
timestamp: Date.now(),
|
||||
elapsed: performance.now() - this.startTime,
|
||||
};
|
||||
this.buffer.push(chunk);
|
||||
if (this.buffer.length >= this.config.maxBufferSize) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private emit(chunk: StreamChunk): void {
|
||||
for (const listener of this.listeners) listener(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULTS as DEFAULT_STREAMER_CONFIG };
|
||||
6
packages/devtools/tooling/sandbox/src/sandbox/index.ts
Normal file
6
packages/devtools/tooling/sandbox/src/sandbox/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
CreateSandboxOptions,
|
||||
Sandbox,
|
||||
SandboxEngineFactory,
|
||||
} from "./sandbox.js";
|
||||
export { createSandbox } from "./sandbox.js";
|
||||
79
packages/devtools/tooling/sandbox/src/sandbox/sandbox.md
Normal file
79
packages/devtools/tooling/sandbox/src/sandbox/sandbox.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# createSandbox (facade)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createSandbox` is the public entry point of the package. It composes the engine, `ModuleResolver`, `ResourceLimiter`, `CodeTransformer`, and `AuditLogger` into a single ergonomic surface. Callers configure the sandbox via a Zod-validated partial config that gets merged with `DEFAULT_CONFIG`; the resolved config is exposed on `sandbox.config` for diagnostic purposes.
|
||||
|
||||
Each `execute` call walks the configured pipeline: check the rate limiter, transform the code against the security rules, record the execution, drive the engine, and log the audit entry. The pipeline is the same regardless of whether the underlying engine is `vm`, `worker`, `spawn`, or a custom `isolate` — engine selection is the only thing that varies.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated config / options / module boundaries.
|
||||
- Engine selection via the `engine` config key (`vm` default).
|
||||
- `customEngine` escape hatch for advanced configurations (e.g. wiring a
|
||||
real `isolated-vm` factory).
|
||||
- Audit log + statistics + per-call `onAuditLog` callback.
|
||||
- Sliding-window rate limiter trips a `SandboxError(RATE_LIMITED)`.
|
||||
- Code transformer auto-blocks `process.*`, `require`, `eval`, etc.
|
||||
- `executeFunction` wraps a host function for sandbox execution.
|
||||
- `reset()` clears rate-limit + audit state but keeps the engine alive.
|
||||
- `destroy()` disposes the engine permanently.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSandbox } from "@suaveplan/sandbox/sandbox";
|
||||
|
||||
const sandbox = createSandbox();
|
||||
const r = await sandbox.execute("2 + 3");
|
||||
console.log(r.result); // 5
|
||||
sandbox.destroy();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a custom engine factory for true `isolated-vm` isolation:
|
||||
|
||||
```ts
|
||||
import ivm from "isolated-vm";
|
||||
import { createSandbox } from "@suaveplan/sandbox/sandbox";
|
||||
import { IsolateEngine } from "@suaveplan/sandbox/isolate-engine";
|
||||
|
||||
const sandbox = createSandbox({
|
||||
engine: "isolate",
|
||||
customEngine: (_kind, cfg) =>
|
||||
new IsolateEngine({
|
||||
...cfg,
|
||||
factory: {
|
||||
createIsolate: (mem) =>
|
||||
new ivm.Isolate({ memoryLimit: mem / (1024 * 1024) }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSandbox(options?: CreateSandboxOptions): Sandbox`.
|
||||
- `Sandbox.execute(code, context?, options?): Promise<SandboxResult>`.
|
||||
- `Sandbox.executeFunction(fn, args?, options?): Promise<SandboxResult>`.
|
||||
- `Sandbox.registerModule(mod): void`.
|
||||
- `Sandbox.unregisterModule(name): boolean`.
|
||||
- `Sandbox.getAuditLog(filter?): readonly AuditLogEntry[]`.
|
||||
- `Sandbox.getStatistics(): AuditStatistics`.
|
||||
- `Sandbox.reset(): void`.
|
||||
- `Sandbox.destroy(): void`.
|
||||
- `Sandbox.config: SandboxConfig` (resolved).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`syncEngineModules` re-registers the resolver's module list on the engine
|
||||
before every execute. This is wasteful in the steady state (the engine
|
||||
already knows about the modules) but keeps the engine's internal module
|
||||
view in lockstep with the resolver's view without requiring engines to
|
||||
implement a "diff against my current modules" API. Engines themselves
|
||||
deduplicate by name on `registerModule`, so the redundant calls are cheap.
|
||||
259
packages/devtools/tooling/sandbox/src/sandbox/sandbox.test.ts
Normal file
259
packages/devtools/tooling/sandbox/src/sandbox/sandbox.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SANDBOX_CODES,
|
||||
type SandboxError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { VmEngine } from "../vm-engine/vm-engine.js";
|
||||
import { createSandbox } from "./sandbox.js";
|
||||
|
||||
describe("createSandbox", () => {
|
||||
it("constructs with defaults", { timeout: 5000 }, () => {
|
||||
const s = createSandbox();
|
||||
expect(s.config.engine).toBe("vm");
|
||||
expect(typeof s.execute).toBe("function");
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("executes a simple expression", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox();
|
||||
const r = await s.execute("2 + 3");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe(5);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("executeFunction multiplies arguments", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createSandbox();
|
||||
const fn = (a: number, b: number) => a * b;
|
||||
const r = await s.executeFunction(fn, [6, 7]);
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe(42);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("executeFunction with no arguments", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox();
|
||||
const r = await s.executeFunction(() => "hi");
|
||||
expect(r.result).toBe("hi");
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("rejects invalid config", { timeout: 5000 }, () => {
|
||||
try {
|
||||
createSandbox({ timeout: -1 } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid options", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox();
|
||||
try {
|
||||
await s.execute("1", {}, { timeout: -5 } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("rejects invalid module", { timeout: 5000 }, () => {
|
||||
const s = createSandbox();
|
||||
try {
|
||||
s.registerModule({ name: "", exports: {} });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_MODULE
|
||||
);
|
||||
}
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("transformer blocks process.exit", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox();
|
||||
const r = await s.execute("process.exit(1)");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("block-process");
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("registers and uses a custom module", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createSandbox();
|
||||
s.registerModule({ name: "cfg", exports: { factor: 21 } });
|
||||
const r = await s.execute("cfg.factor * 2");
|
||||
expect(r.result).toBe(42);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("unregisters modules", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox();
|
||||
s.registerModule({ name: "temp", exports: { v: 1 } });
|
||||
expect(s.unregisterModule("temp")).toBe(true);
|
||||
expect(s.unregisterModule("temp")).toBe(false);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("captures audit log entries", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox();
|
||||
await s.execute("1");
|
||||
await s.execute("2");
|
||||
const log = s.getAuditLog();
|
||||
expect(log).toHaveLength(2);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("filters audit log via getAuditLog(filter)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const s = createSandbox();
|
||||
await s.execute("1");
|
||||
await s.execute("function(){ throw new Error('x'); }()");
|
||||
const failures = s.getAuditLog({ success: false });
|
||||
expect(failures).toHaveLength(1);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("getStatistics aggregates", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox();
|
||||
await s.execute("1");
|
||||
const stats = s.getStatistics();
|
||||
expect(stats.totalRuns).toBe(1);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("rate-limits via RATE_LIMITED", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox({
|
||||
maxExecutionsPerWindow: 2,
|
||||
rateLimitWindow: 60_000,
|
||||
});
|
||||
await s.execute("1");
|
||||
await s.execute("2");
|
||||
try {
|
||||
await s.execute("3");
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SandboxError).code).toBe(SANDBOX_CODES.RATE_LIMITED);
|
||||
}
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("reset clears audit + rate-limiter", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox({
|
||||
maxExecutionsPerWindow: 1,
|
||||
rateLimitWindow: 60_000,
|
||||
});
|
||||
await s.execute("1");
|
||||
s.reset();
|
||||
expect(s.getAuditLog()).toHaveLength(0);
|
||||
const r = await s.execute("2");
|
||||
expect(r.success).toBe(true);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("invokes onAuditLog callback", { timeout: 5000 }, async () => {
|
||||
const seen: string[] = [];
|
||||
const s = createSandbox({
|
||||
onAuditLog: (e) => seen.push(e.codeHash),
|
||||
});
|
||||
await s.execute("1");
|
||||
expect(seen).toHaveLength(1);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("supports a customEngine factory", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox({
|
||||
engine: "isolate",
|
||||
customEngine: (_kind, cfg) => new VmEngine(cfg),
|
||||
});
|
||||
const r = await s.execute("99");
|
||||
expect(r.result).toBe(99);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("default factory throws for isolate without customEngine", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => createSandbox({ engine: "isolate" })).toThrow(
|
||||
/IsolateFactory/
|
||||
);
|
||||
});
|
||||
|
||||
it("config field is exposed and frozen-ish", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = createSandbox({ timeout: 2000 });
|
||||
expect(s.config.timeout).toBe(2000);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("destroy disposes the engine", { timeout: 5000 }, async () => {
|
||||
const s = createSandbox();
|
||||
s.destroy();
|
||||
const r = await s.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("exports every documented subpath", { timeout: 5000 }, () => {
|
||||
const pkgPath = join(
|
||||
new URL(".", import.meta.url).pathname,
|
||||
"..",
|
||||
"..",
|
||||
"package.json"
|
||||
);
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k !== "./package.json" && k !== "."
|
||||
);
|
||||
const required = [
|
||||
"./types",
|
||||
"./error-codes",
|
||||
"./schemas",
|
||||
"./telemetry",
|
||||
"./audit-logger",
|
||||
"./resource-limiter",
|
||||
"./module-resolver",
|
||||
"./code-transformer",
|
||||
"./vm-engine",
|
||||
"./worker-engine",
|
||||
"./spawn-engine",
|
||||
"./runner",
|
||||
"./isolate-engine",
|
||||
"./pool",
|
||||
"./result-streamer",
|
||||
"./sandbox",
|
||||
];
|
||||
for (const r of required) expect(subpaths).toContain(r);
|
||||
});
|
||||
|
||||
it("can swap the engine to worker", { timeout: 15000 }, async () => {
|
||||
const s = createSandbox({ engine: "worker" });
|
||||
const r = await s.execute("1 + 1");
|
||||
expect(r.result).toBe(2);
|
||||
s.destroy();
|
||||
});
|
||||
|
||||
it("default factory wires SpawnEngine for engine=spawn", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = createSandbox({ engine: "spawn" });
|
||||
// Just constructing the sandbox is enough to hit the factory branch;
|
||||
// the default runner-bin path is not present in tests so we don't
|
||||
// actually execute the child here.
|
||||
expect(s.config.engine).toBe("spawn");
|
||||
s.destroy();
|
||||
});
|
||||
});
|
||||
198
packages/devtools/tooling/sandbox/src/sandbox/sandbox.ts
Normal file
198
packages/devtools/tooling/sandbox/src/sandbox/sandbox.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { AuditLogger } from "../audit-logger/audit-logger.js";
|
||||
import type { AuditFilter } from "../audit-logger/audit-logger.types.js";
|
||||
import { CodeTransformer } from "../code-transformer/code-transformer.js";
|
||||
import { ModuleResolver } from "../module-resolver/module-resolver.js";
|
||||
import { ResourceLimiter } from "../resource-limiter/resource-limiter.js";
|
||||
import type { SandboxConfigPartial } from "../schemas/schemas.js";
|
||||
import { parseConfig, parseModule, parseOptions } from "../schemas/schemas.js";
|
||||
import { SpawnEngine } from "../spawn-engine/spawn-engine.js";
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
AuditStatistics,
|
||||
ExecutionContext,
|
||||
SandboxConfig,
|
||||
SandboxEngine,
|
||||
SandboxEngineConfig,
|
||||
SandboxEngineKind,
|
||||
SandboxModule,
|
||||
SandboxOptions,
|
||||
SandboxResult,
|
||||
} from "../types/sandbox.types.js";
|
||||
import { VmEngine } from "../vm-engine/vm-engine.js";
|
||||
import { WorkerEngine } from "../worker-engine/worker-engine.js";
|
||||
|
||||
/** Engine factory contract used by `createSandbox`. */
|
||||
export type SandboxEngineFactory = (
|
||||
kind: SandboxEngineKind,
|
||||
config: SandboxEngineConfig
|
||||
) => SandboxEngine;
|
||||
|
||||
/** Extension config accepted by {@link createSandbox}. */
|
||||
export interface CreateSandboxOptions extends SandboxConfigPartial {
|
||||
/** Custom engine factory — overrides the default kind→engine mapping. */
|
||||
readonly customEngine?: SandboxEngineFactory;
|
||||
}
|
||||
|
||||
/** A configured sandbox instance. */
|
||||
export interface Sandbox {
|
||||
/** Execute a code string in the sandbox. */
|
||||
execute<T = unknown>(
|
||||
code: string,
|
||||
context?: ExecutionContext,
|
||||
options?: SandboxOptions
|
||||
): Promise<SandboxResult<T>>;
|
||||
/** Wrap a function in sandbox code and execute it. */
|
||||
executeFunction<T = unknown>(
|
||||
fn: (...args: unknown[]) => T,
|
||||
args?: readonly unknown[],
|
||||
options?: SandboxOptions
|
||||
): Promise<SandboxResult<T>>;
|
||||
/** Register a custom module. */
|
||||
registerModule(module: SandboxModule): void;
|
||||
/** Unregister a module by name. */
|
||||
unregisterModule(name: string): boolean;
|
||||
/** Get audit log entries. */
|
||||
getAuditLog(filter?: AuditFilter): readonly AuditLogEntry[];
|
||||
/** Execution statistics. */
|
||||
getStatistics(): AuditStatistics;
|
||||
/** Reset rate limiter and audit log. */
|
||||
reset(): void;
|
||||
/** Destroy the engine; engine instance becomes unusable. */
|
||||
destroy(): void;
|
||||
/** Resolved configuration. */
|
||||
readonly config: SandboxConfig;
|
||||
}
|
||||
|
||||
function defaultEngineFactory(
|
||||
kind: SandboxEngineKind,
|
||||
config: SandboxEngineConfig
|
||||
): SandboxEngine {
|
||||
switch (kind) {
|
||||
case "vm":
|
||||
return new VmEngine(config);
|
||||
case "worker":
|
||||
return new WorkerEngine(config);
|
||||
case "spawn":
|
||||
return new SpawnEngine(config);
|
||||
case "isolate":
|
||||
throw new Error(
|
||||
"engine=isolate requires customEngine to supply an IsolateFactory"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured sandbox instance composed of an engine, module resolver,
|
||||
* resource limiter, audit logger, and code transformer.
|
||||
*/
|
||||
export function createSandbox(options: CreateSandboxOptions = {}): Sandbox {
|
||||
const { customEngine, ...partial } = options;
|
||||
const config = parseConfig(partial);
|
||||
const moduleResolver = new ModuleResolver(config.allowedModules);
|
||||
const limiter = new ResourceLimiter({
|
||||
...(config.maxExecutionsPerWindow === undefined
|
||||
? {}
|
||||
: { maxExecutionsPerWindow: config.maxExecutionsPerWindow }),
|
||||
rateLimitWindow: config.rateLimitWindow,
|
||||
maxOutputSize: config.maxOutputSize,
|
||||
});
|
||||
const auditLogger = new AuditLogger(config.onAuditLog);
|
||||
const transformer = new CodeTransformer({
|
||||
maxCodeLength: config.maxCodeLength,
|
||||
});
|
||||
const engineConfig: SandboxEngineConfig = {
|
||||
timeout: config.timeout,
|
||||
maxOutputSize: config.maxOutputSize,
|
||||
memoryLimit: config.memoryLimit,
|
||||
modules: moduleResolver.getModules(),
|
||||
};
|
||||
const engine = (customEngine ?? defaultEngineFactory)(
|
||||
config.engine,
|
||||
engineConfig
|
||||
);
|
||||
|
||||
async function execute<T = unknown>(
|
||||
code: string,
|
||||
context: ExecutionContext = {},
|
||||
rawOptions?: SandboxOptions
|
||||
): Promise<SandboxResult<T>> {
|
||||
const opts = parseOptions(rawOptions);
|
||||
limiter.checkRateLimit();
|
||||
const transform = transformer.transform(code);
|
||||
if (transform.blocked) {
|
||||
const error = `Code blocked by rule: ${transform.blockedBy}`;
|
||||
limiter.recordExecution();
|
||||
auditLogger.log(code, 0, false, 0, error);
|
||||
return {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error,
|
||||
duration: 0,
|
||||
memoryUsed: 0,
|
||||
logs: [],
|
||||
};
|
||||
}
|
||||
limiter.recordExecution();
|
||||
// Sync modules into the engine before each execute.
|
||||
syncEngineModules(engine, moduleResolver.getModules());
|
||||
const result = await engine.execute<T>(transform.code, context, opts);
|
||||
auditLogger.log(
|
||||
code,
|
||||
result.duration,
|
||||
result.success,
|
||||
result.memoryUsed,
|
||||
result.error
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function executeFunction<T = unknown>(
|
||||
fn: (...args: unknown[]) => T,
|
||||
args: readonly unknown[] = [],
|
||||
options?: SandboxOptions
|
||||
): Promise<SandboxResult<T>> {
|
||||
const serialised = args.map((a) => JSON.stringify(a)).join(", ");
|
||||
const code = `(${fn.toString()})(${serialised})`;
|
||||
return execute<T>(code, {}, options);
|
||||
}
|
||||
|
||||
return {
|
||||
execute,
|
||||
executeFunction,
|
||||
registerModule(module) {
|
||||
const validated = parseModule(module);
|
||||
moduleResolver.register(validated);
|
||||
engine.registerModule(validated);
|
||||
},
|
||||
unregisterModule(name) {
|
||||
engine.unregisterModule(name);
|
||||
return moduleResolver.unregister(name);
|
||||
},
|
||||
getAuditLog(filter) {
|
||||
return filter === undefined
|
||||
? auditLogger.getEntries()
|
||||
: auditLogger.search(filter);
|
||||
},
|
||||
getStatistics() {
|
||||
return auditLogger.getStatistics();
|
||||
},
|
||||
reset() {
|
||||
limiter.reset();
|
||||
auditLogger.clear();
|
||||
engine.reset();
|
||||
},
|
||||
destroy() {
|
||||
engine.destroy();
|
||||
},
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
function syncEngineModules(
|
||||
engine: SandboxEngine,
|
||||
modules: readonly SandboxModule[]
|
||||
): void {
|
||||
for (const mod of modules) {
|
||||
engine.registerModule(mod);
|
||||
}
|
||||
}
|
||||
10
packages/devtools/tooling/sandbox/src/schemas/index.ts
Normal file
10
packages/devtools/tooling/sandbox/src/schemas/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type { SandboxConfigPartial } from "./schemas.js";
|
||||
export {
|
||||
parseConfig,
|
||||
parseModule,
|
||||
parseOptions,
|
||||
SandboxConfigPartialSchema,
|
||||
SandboxEngineKindSchema,
|
||||
SandboxModuleSchema,
|
||||
SandboxOptionsSchema,
|
||||
} from "./schemas.js";
|
||||
65
packages/devtools/tooling/sandbox/src/schemas/schemas.md
Normal file
65
packages/devtools/tooling/sandbox/src/schemas/schemas.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas guard every public boundary of `@suaveplan/sandbox`. The package never trusts its caller; instead, every argument that crosses the API boundary is parsed against a strict schema and rejected with a typed `SandboxError` (`INVALID_CONFIG` / `INVALID_OPTIONS` / `INVALID_MODULE`) when the shape does not match. This means downstream consumers get the same validation guarantees whether they pass typed TypeScript objects or arbitrary JSON deserialised from a network boundary.
|
||||
|
||||
The `parseConfig` helper additionally merges parsed partials with `DEFAULT_CONFIG` so the rest of the package consumes a fully resolved `SandboxConfig` rather than a partial. This keeps the merge logic in one place rather than re-implementing it across the facade and the engines.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-mode schemas (`.strict()`) so unknown keys are rejected.
|
||||
- `parseConfig`, `parseOptions`, `parseModule` helpers that translate Zod
|
||||
errors into `SandboxError` with the right code.
|
||||
- `SandboxEngineKindSchema` enum used as the discriminant for engine selection.
|
||||
- `safeParse` under the hood so the error path stays on the catch-side and
|
||||
the success path stays on the happy path.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseConfig } from "@suaveplan/sandbox/schemas";
|
||||
|
||||
const cfg = parseConfig({ timeout: 1000, engine: "worker" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
When validating untrusted input (HTTP, IPC), surface the raw Zod issues to
|
||||
the caller via `SandboxError.details.issues`:
|
||||
|
||||
```ts
|
||||
import { SandboxError } from "@suaveplan/sandbox/error-codes";
|
||||
import { parseConfig } from "@suaveplan/sandbox/schemas";
|
||||
|
||||
try {
|
||||
parseConfig(JSON.parse(httpBody));
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxError) {
|
||||
return Response.json({ issues: err.details.issues }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SandboxConfigPartialSchema` — `z.ZodObject` for `Partial<SandboxConfig>`.
|
||||
- `SandboxOptionsSchema` — `z.ZodObject` for `SandboxOptions`.
|
||||
- `SandboxModuleSchema` — `z.ZodObject` for `SandboxModule`.
|
||||
- `SandboxEngineKindSchema` — `z.ZodEnum<['vm','worker','spawn','isolate']>`.
|
||||
- `parseConfig(raw): SandboxConfig` — merges with `DEFAULT_CONFIG`.
|
||||
- `parseOptions(raw): SandboxOptions`.
|
||||
- `parseModule(raw): SandboxModule`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`onAuditLog` is validated via `z.custom` rather than `z.function` because Zod's
|
||||
function schema would otherwise wrap the callback in a runtime validator and
|
||||
break referential equality, breaking the "consumer-passes-its-own-callback"
|
||||
contract. The custom validator only checks `typeof === "function"` (or
|
||||
undefined), which is the actual invariant the consumer cares about.
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import { DEFAULT_CONFIG } from "../types/sandbox.types.js";
|
||||
import { parseConfig, parseModule, parseOptions } from "./schemas.js";
|
||||
|
||||
describe("parseConfig", () => {
|
||||
it("returns DEFAULT_CONFIG when given undefined", { timeout: 5000 }, () => {
|
||||
const cfg = parseConfig(undefined);
|
||||
expect(cfg.timeout).toBe(DEFAULT_CONFIG.timeout);
|
||||
expect(cfg.engine).toBe("vm");
|
||||
expect(cfg.allowedModules).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges partial config with defaults", { timeout: 5000 }, () => {
|
||||
const cfg = parseConfig({ timeout: 1000, engine: "worker" });
|
||||
expect(cfg.timeout).toBe(1000);
|
||||
expect(cfg.engine).toBe("worker");
|
||||
expect(cfg.memoryLimit).toBe(DEFAULT_CONFIG.memoryLimit);
|
||||
});
|
||||
|
||||
it("includes onAuditLog when supplied", { timeout: 5000 }, () => {
|
||||
const cb = () => {};
|
||||
const cfg = parseConfig({ onAuditLog: cb });
|
||||
expect(cfg.onAuditLog).toBe(cb);
|
||||
});
|
||||
|
||||
it("rejects negative timeout", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseConfig({ timeout: -1 } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxError);
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown engine kinds", { timeout: 5000 }, () => {
|
||||
expect(() => parseConfig({ engine: "rust-wasm" } as never)).toThrow(
|
||||
SandboxError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOptions", () => {
|
||||
it("accepts undefined", { timeout: 5000 }, () => {
|
||||
expect(parseOptions(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a valid options bag", { timeout: 5000 }, () => {
|
||||
expect(parseOptions({ timeout: 100 })).toEqual({ timeout: 100 });
|
||||
});
|
||||
|
||||
it("rejects negative timeout", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseOptions({ timeout: -5 });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes memoryLimit + maxOutputSize through", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseOptions({ memoryLimit: 1000, maxOutputSize: 50 })).toEqual({
|
||||
memoryLimit: 1000,
|
||||
maxOutputSize: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseModule", () => {
|
||||
it("accepts a valid module", { timeout: 5000 }, () => {
|
||||
const mod = parseModule({ name: "config", exports: { x: 1 } });
|
||||
expect(mod.name).toBe("config");
|
||||
expect(mod.exports.x).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an empty name", { timeout: 5000 }, () => {
|
||||
try {
|
||||
parseModule({ name: "", exports: {} });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as SandboxError).code).toBe(
|
||||
SANDBOX_CODES.INVALID_MODULE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing exports field", { timeout: 5000 }, () => {
|
||||
expect(() => parseModule({ name: "x" })).toThrow(SandboxError);
|
||||
});
|
||||
});
|
||||
129
packages/devtools/tooling/sandbox/src/schemas/schemas.ts
Normal file
129
packages/devtools/tooling/sandbox/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import z from "zod";
|
||||
import { SANDBOX_CODES, SandboxError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
type SandboxConfig,
|
||||
type SandboxModule,
|
||||
type SandboxOptions,
|
||||
} from "../types/sandbox.types.js";
|
||||
|
||||
/** Engine kinds accepted by `createSandbox`. */
|
||||
export const SandboxEngineKindSchema = z.enum([
|
||||
"vm",
|
||||
"worker",
|
||||
"spawn",
|
||||
"isolate",
|
||||
]);
|
||||
|
||||
/** Partial config schema (used at `createSandbox` boundary). */
|
||||
export const SandboxConfigPartialSchema = z
|
||||
.object({
|
||||
timeout: z.number().int().positive().optional(),
|
||||
memoryLimit: z.number().int().positive().optional(),
|
||||
allowedModules: z.array(z.string().min(1)).optional(),
|
||||
maxOutputSize: z.number().int().positive().optional(),
|
||||
maxExecutionsPerWindow: z.number().int().positive().optional(),
|
||||
rateLimitWindow: z.number().int().positive().optional(),
|
||||
maxCodeLength: z.number().int().positive().optional(),
|
||||
engine: SandboxEngineKindSchema.optional(),
|
||||
onAuditLog: z
|
||||
.custom<SandboxConfig["onAuditLog"]>(
|
||||
(v) => v === undefined || typeof v === "function",
|
||||
"onAuditLog must be a function"
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SandboxConfigPartial = z.infer<typeof SandboxConfigPartialSchema>;
|
||||
|
||||
/** Per-execution options schema. */
|
||||
export const SandboxOptionsSchema = z
|
||||
.object({
|
||||
timeout: z.number().int().positive().optional(),
|
||||
memoryLimit: z.number().int().positive().optional(),
|
||||
maxOutputSize: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Sandbox-module schema. */
|
||||
export const SandboxModuleSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
exports: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Parse a partial config and merge with `DEFAULT_CONFIG`. Throws
|
||||
* `SandboxError(INVALID_CONFIG)` on parse failure.
|
||||
*/
|
||||
export function parseConfig(
|
||||
raw: SandboxConfigPartial | undefined
|
||||
): SandboxConfig {
|
||||
const parsed = SandboxConfigPartialSchema.safeParse(raw ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.INVALID_CONFIG,
|
||||
"Invalid sandbox config",
|
||||
{ details: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const merged: SandboxConfig = {
|
||||
timeout: parsed.data.timeout ?? DEFAULT_CONFIG.timeout,
|
||||
memoryLimit: parsed.data.memoryLimit ?? DEFAULT_CONFIG.memoryLimit,
|
||||
allowedModules:
|
||||
parsed.data.allowedModules ?? DEFAULT_CONFIG.allowedModules,
|
||||
maxOutputSize:
|
||||
parsed.data.maxOutputSize ?? DEFAULT_CONFIG.maxOutputSize,
|
||||
maxExecutionsPerWindow:
|
||||
parsed.data.maxExecutionsPerWindow ??
|
||||
DEFAULT_CONFIG.maxExecutionsPerWindow,
|
||||
rateLimitWindow:
|
||||
parsed.data.rateLimitWindow ?? DEFAULT_CONFIG.rateLimitWindow,
|
||||
maxCodeLength:
|
||||
parsed.data.maxCodeLength ?? DEFAULT_CONFIG.maxCodeLength,
|
||||
engine: parsed.data.engine ?? DEFAULT_CONFIG.engine,
|
||||
...(parsed.data.onAuditLog === undefined
|
||||
? {}
|
||||
: { onAuditLog: parsed.data.onAuditLog }),
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Parse per-execution options. Throws `SandboxError(INVALID_OPTIONS)`. */
|
||||
export function parseOptions(raw: unknown): SandboxOptions {
|
||||
const parsed = SandboxOptionsSchema.safeParse(raw ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.INVALID_OPTIONS,
|
||||
"Invalid sandbox options",
|
||||
{ details: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const out: SandboxOptions = {
|
||||
...(parsed.data.timeout === undefined
|
||||
? {}
|
||||
: { timeout: parsed.data.timeout }),
|
||||
...(parsed.data.memoryLimit === undefined
|
||||
? {}
|
||||
: { memoryLimit: parsed.data.memoryLimit }),
|
||||
...(parsed.data.maxOutputSize === undefined
|
||||
? {}
|
||||
: { maxOutputSize: parsed.data.maxOutputSize }),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse a SandboxModule. Throws `SandboxError(INVALID_MODULE)`. */
|
||||
export function parseModule(raw: unknown): SandboxModule {
|
||||
const parsed = SandboxModuleSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new SandboxError(
|
||||
SANDBOX_CODES.INVALID_MODULE,
|
||||
"Invalid sandbox module",
|
||||
{ details: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { RunnerInput, RunnerOutput } from "./runner.js";
|
||||
export { runRunner } from "./runner.js";
|
||||
export type {
|
||||
ProcessSpawner,
|
||||
SpawnEngineOptions,
|
||||
} from "./spawn-engine.js";
|
||||
export { SpawnEngine, sanitiseEnv } from "./spawn-engine.js";
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Child-process executable for the spawn-engine. Reads stdin, drives
|
||||
* `runRunner`, writes stdout. The actual evaluation is in `runner.ts` —
|
||||
* this file is the stdio plumbing that the parent process spawns. It is
|
||||
* excluded from coverage thresholds because it cannot be exercised
|
||||
* realistically without actually spawning a child process; the
|
||||
* spawn-engine integration tests cover the whole pipeline end-to-end
|
||||
* against a temp-file fixture that mirrors this implementation.
|
||||
*/
|
||||
|
||||
import { type RunnerInput, type RunnerOutput, runRunner } from "./runner.js";
|
||||
|
||||
async function readStdinAll(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const raw = await readStdinAll();
|
||||
const input = JSON.parse(raw) as RunnerInput;
|
||||
const output = runRunner(input);
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const output: RunnerOutput = {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error: `Runner error: ${message}`,
|
||||
logs: [],
|
||||
memoryUsed: 0,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
66
packages/devtools/tooling/sandbox/src/spawn-engine/runner.md
Normal file
66
packages/devtools/tooling/sandbox/src/spawn-engine/runner.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Runner (spawn-engine child entry)
|
||||
|
||||
## Purpose
|
||||
|
||||
`runner.ts` is the child-process entry point invoked by `SpawnEngine`. The parent process spawns it via `node:child_process.spawn`, writes a JSON `RunnerInput` envelope to its stdin (closing the pipe to signal end-of-input), and reads a JSON `RunnerOutput` envelope from its stdout. The runner runs inside a fresh OS process with a sanitised environment — that process boundary is the security guarantee, not anything the runner itself does.
|
||||
|
||||
The runner exports `runRunner(input)` as a pure function so the parent can also test the evaluation pipeline without spawning a child. The `main()` entry point reads stdin, parses JSON, calls `runRunner`, and writes stdout — exposed for tests that want to drive the full I/O surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure `runRunner(input): RunnerOutput` for in-process testing.
|
||||
- `readStdinAll()` async stdin slurper.
|
||||
- `main()` orchestrator.
|
||||
- Auto-invokes `main()` when the file is launched directly (matched by
|
||||
filename ending in `runner.{js,ts,cjs,mjs}`).
|
||||
- `stringify(value)` JSON-safe fallback used by the captured console.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
The runner is normally invoked by `SpawnEngine`; consumers do not call it
|
||||
directly. To test in isolation:
|
||||
|
||||
```ts
|
||||
import { runRunner } from "@suaveplan/sandbox/runner";
|
||||
|
||||
const out = runRunner({
|
||||
code: "1 + 1",
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
console.log(out.result); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive the full stdin/stdout pipeline against the runner file by spawning a
|
||||
real child:
|
||||
|
||||
```ts
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const child = spawn(process.execPath, ["./dist/spawn-engine/runner.js"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
child.stdin.write(JSON.stringify({ code: "1 + 1", context: {}, modules: [], maxOutputSize: 1024 }));
|
||||
child.stdin.end();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RunnerInput` — `{ code, context, modules, maxOutputSize }`.
|
||||
- `RunnerOutput` — `{ success, result, error, logs, memoryUsed }`.
|
||||
- `runRunner(input: RunnerInput): RunnerOutput`.
|
||||
- `readStdinAll(): Promise<string>`.
|
||||
- `main(): Promise<void>`.
|
||||
- `stringify(value: unknown): string`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The runner builds a function scope via
|
||||
`Reflect.construct(Function, [...keys, source])` — this is the controlled
|
||||
boundary the runner owns; the parent is the one that decides whether to
|
||||
spawn it. Strict mode is enforced via the prologue. The
|
||||
auto-invoke guard checks `process.argv[1]` against a filename regex so
|
||||
re-importing the runner from a test never triggers the main loop.
|
||||
95
packages/devtools/tooling/sandbox/src/spawn-engine/runner.ts
Normal file
95
packages/devtools/tooling/sandbox/src/spawn-engine/runner.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Child-process pure evaluation core for the spawn-engine.
|
||||
*
|
||||
* `runRunner` takes a JSON `RunnerInput` envelope and returns a
|
||||
* `RunnerOutput` envelope, evaluating the wrapped expression in a strict-mode
|
||||
* function scope (with `console` and modules injected). The actual stdin /
|
||||
* stdout plumbing lives in `runner-bin.ts`, which spawns the child.
|
||||
*
|
||||
* SECURITY: This function evaluates arbitrary code received over IPC. That
|
||||
* is the runner's job — the spawn-engine's parent process is responsible
|
||||
* for deciding whether to spawn the runner at all. The boundary is process
|
||||
* isolation, not anything the runner itself does.
|
||||
*/
|
||||
|
||||
export interface RunnerInput {
|
||||
readonly code: string;
|
||||
readonly context: Record<string, unknown>;
|
||||
readonly modules: ReadonlyArray<{
|
||||
name: string;
|
||||
exports: Record<string, unknown>;
|
||||
}>;
|
||||
readonly maxOutputSize: number;
|
||||
}
|
||||
|
||||
export interface RunnerOutput {
|
||||
readonly success: boolean;
|
||||
readonly result: unknown;
|
||||
readonly error: string | undefined;
|
||||
readonly logs: readonly string[];
|
||||
readonly memoryUsed: number;
|
||||
}
|
||||
|
||||
/** JSON-safe string conversion used by the captured console. */
|
||||
export function stringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null) return "null";
|
||||
if (value === undefined) return "undefined";
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure evaluator. */
|
||||
export function runRunner(input: RunnerInput): RunnerOutput {
|
||||
const logs: string[] = [];
|
||||
let totalLogSize = 0;
|
||||
const append = (line: string): void => {
|
||||
totalLogSize += line.length;
|
||||
if (totalLogSize <= input.maxOutputSize) logs.push(line);
|
||||
};
|
||||
const sandboxConsole = {
|
||||
log: (...a: unknown[]) => append(a.map(stringify).join(" ")),
|
||||
warn: (...a: unknown[]) =>
|
||||
append(`[warn] ${a.map(stringify).join(" ")}`),
|
||||
error: (...a: unknown[]) =>
|
||||
append(`[error] ${a.map(stringify).join(" ")}`),
|
||||
};
|
||||
const scope: Record<string, unknown> = {
|
||||
console: sandboxConsole,
|
||||
...input.context,
|
||||
};
|
||||
for (const mod of input.modules) {
|
||||
scope[mod.name] = mod.exports;
|
||||
}
|
||||
const memBefore = process.memoryUsage().heapUsed;
|
||||
try {
|
||||
const keys = Object.keys(scope);
|
||||
const vals = keys.map((k) => scope[k]);
|
||||
const source = `"use strict";\nreturn (${input.code})`;
|
||||
const factory = Reflect.construct(Function, [...keys, source]) as (
|
||||
...args: unknown[]
|
||||
) => unknown;
|
||||
const result = factory(...vals);
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
error: undefined,
|
||||
logs,
|
||||
memoryUsed: Math.max(0, memAfter - memBefore),
|
||||
};
|
||||
} catch (err) {
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
success: false,
|
||||
result: undefined,
|
||||
error: message,
|
||||
logs,
|
||||
memoryUsed: Math.max(0, memAfter - memBefore),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# SpawnEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SpawnEngine` uses `node:child_process.spawn` for the strongest isolation the package offers: a fresh OS process spawned per execution, pinned to `process.execPath`, with a sanitised environment that strips identity-revealing keys (`HOME`/`USER`/`SHELL`/`LOGNAME`) and case-insensitive substring matches against `SECRET`/`KEY`/`TOKEN`/`PASSWORD`/`CREDENTIAL`/`AUTH`. Code is sent to the child over stdin as a JSON envelope; the result is read from stdout the same way. Timeouts are enforced with `child.kill("SIGKILL")` so the running script cannot intercept termination.
|
||||
|
||||
The child process runs `runner.ts` (shipped via the `./runner` subpath export). The engine resolves the runner path relative to its own module URL by default, but callers can pin a custom path via the `runnerPath` option — useful for tests that want to drive the engine against a controlled runner fixture.
|
||||
|
||||
## Features
|
||||
|
||||
- Real `node:child_process.spawn` — true OS-process isolation.
|
||||
- Sanitised env via `sanitiseEnv` (exported for direct testing).
|
||||
- Injectable `ProcessSpawner` seam (defaults to `node:child_process.spawn`).
|
||||
- Configurable `runnerPath` and `execPath`.
|
||||
- Timeout enforcement via `SIGKILL` after `Promise.race`.
|
||||
- Failure modes: timeout, no-output, parse-error, all surfaced via the
|
||||
structured `SandboxResult` `error` field.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SpawnEngine } from "@suaveplan/sandbox/spawn-engine";
|
||||
|
||||
const engine = new SpawnEngine({
|
||||
timeout: 5000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 50 * 1024 * 1024,
|
||||
});
|
||||
const r = await engine.execute("1 + 1");
|
||||
engine.destroy();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a custom spawner for tests, plus a stripped-down env for tightest
|
||||
control:
|
||||
|
||||
```ts
|
||||
import { SpawnEngine } from "@suaveplan/sandbox/spawn-engine";
|
||||
|
||||
const engine = new SpawnEngine({
|
||||
timeout: 5000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 5e7,
|
||||
env: { PATH: process.env.PATH },
|
||||
spawner: (cmd, args, opts) => myFakeSpawn(cmd, args, opts),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new SpawnEngine(options: SpawnEngineOptions)`.
|
||||
- `execute(code, context?, options?): Promise<SandboxResult>`.
|
||||
- `registerModule(mod): void`.
|
||||
- `unregisterModule(name): boolean`.
|
||||
- `getRunnerPath(): string`.
|
||||
- `getSanitisedEnv(): Record<string, string>`.
|
||||
- `reset(): void`.
|
||||
- `destroy(): void`.
|
||||
- `sanitiseEnv(env)` — pure helper exported for direct testing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `raceChild` helper consolidates the four ways a child can finish:
|
||||
clean exit with parseable JSON (`ok`), clean exit with non-JSON or empty
|
||||
output (`no-output` / `parse-error`), error event before close
|
||||
(`parse-error`), and timeout (`timeout`). Settling is idempotent — only the
|
||||
first outcome counts — so a timeout that races with a clean exit doesn't
|
||||
double-resolve. The kill on timeout is wrapped in try/catch because
|
||||
`child.kill` raises on already-exited processes on some platforms.
|
||||
@@ -0,0 +1,472 @@
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runRunner } from "./runner.js";
|
||||
import { SpawnEngine, sanitiseEnv } from "./spawn-engine.js";
|
||||
|
||||
const RUNNER_SCRIPT = `
|
||||
const stringify = (v) => {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v === null) return 'null';
|
||||
if (v === undefined) return 'undefined';
|
||||
try { return JSON.stringify(v); } catch { return String(v); }
|
||||
};
|
||||
const chunks = [];
|
||||
process.stdin.on('data', (c) => chunks.push(c));
|
||||
process.stdin.on('end', () => {
|
||||
let input;
|
||||
try { input = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
||||
catch (err) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
success: false, result: undefined,
|
||||
error: 'Runner error: ' + (err && err.message),
|
||||
logs: [], memoryUsed: 0,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const logs = [];
|
||||
let total = 0;
|
||||
const append = (l) => { total += l.length; if (total <= input.maxOutputSize) logs.push(l); };
|
||||
const sandboxConsole = {
|
||||
log: (...a) => append(a.map(stringify).join(' ')),
|
||||
warn: (...a) => append('[warn] ' + a.map(stringify).join(' ')),
|
||||
error: (...a) => append('[error] ' + a.map(stringify).join(' ')),
|
||||
};
|
||||
const scope = { console: sandboxConsole, ...input.context };
|
||||
for (const m of input.modules) scope[m.name] = m.exports;
|
||||
const memBefore = process.memoryUsage().heapUsed;
|
||||
try {
|
||||
const keys = Object.keys(scope);
|
||||
const vals = keys.map(k => scope[k]);
|
||||
const f = Reflect.construct(Function, [...keys, '"use strict";\\nreturn (' + input.code + ')']);
|
||||
const result = f(...vals);
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
process.stdout.write(JSON.stringify({
|
||||
success: true, result, error: undefined,
|
||||
logs, memoryUsed: Math.max(0, memAfter - memBefore),
|
||||
}));
|
||||
} catch (err) {
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
process.stdout.write(JSON.stringify({
|
||||
success: false, result: undefined,
|
||||
error: err && err.message ? err.message : String(err),
|
||||
logs, memoryUsed: Math.max(0, memAfter - memBefore),
|
||||
}));
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
function writeRunner(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "sandbox-runner-"));
|
||||
const path = join(dir, "runner.js");
|
||||
writeFileSync(path, RUNNER_SCRIPT);
|
||||
return path;
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
timeout: 10000,
|
||||
maxOutputSize: 1024,
|
||||
memoryLimit: 50 * 1024 * 1024,
|
||||
};
|
||||
|
||||
describe("sanitiseEnv", () => {
|
||||
it("strips sensitive env keys", { timeout: 5000 }, () => {
|
||||
const out = sanitiseEnv({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/root",
|
||||
USER: "x",
|
||||
SHELL: "/bin/bash",
|
||||
LOGNAME: "x",
|
||||
MY_SECRET: "x",
|
||||
API_KEY: "x",
|
||||
BEARER_TOKEN: "x",
|
||||
DB_PASSWORD: "x",
|
||||
AWS_CREDENTIAL: "x",
|
||||
AUTH_SERVER: "x",
|
||||
SAFE_VAR: "ok",
|
||||
});
|
||||
expect(out.PATH).toBe("/usr/bin");
|
||||
expect(out.SAFE_VAR).toBe("ok");
|
||||
expect(out.HOME).toBeUndefined();
|
||||
expect(out.MY_SECRET).toBeUndefined();
|
||||
expect(out.API_KEY).toBeUndefined();
|
||||
expect(out.BEARER_TOKEN).toBeUndefined();
|
||||
expect(out.DB_PASSWORD).toBeUndefined();
|
||||
expect(out.AWS_CREDENTIAL).toBeUndefined();
|
||||
expect(out.AUTH_SERVER).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips empty values", { timeout: 5000 }, () => {
|
||||
const out = sanitiseEnv({ EMPTY: "", PRESENT: "yes" });
|
||||
expect(out.EMPTY).toBeUndefined();
|
||||
expect(out.PRESENT).toBe("yes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runRunner (in-process)", () => {
|
||||
it("evaluates 1+1", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: "1 + 1",
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.success).toBe(true);
|
||||
expect(out.result).toBe(2);
|
||||
});
|
||||
|
||||
it("captures console output", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: 'function(){ console.log("hi", 1); console.warn("w"); console.error("e"); return 0; }()',
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.logs).toContain("hi 1");
|
||||
expect(out.logs).toContain("[warn] w");
|
||||
expect(out.logs).toContain("[error] e");
|
||||
});
|
||||
|
||||
it("propagates thrown errors", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: 'function(){ throw new Error("boom"); }()',
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
expect(out.error).toContain("boom");
|
||||
});
|
||||
|
||||
it("propagates non-Error throws", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: 'function(){ throw "string"; }()',
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.success).toBe(false);
|
||||
expect(out.error).toBe("string");
|
||||
});
|
||||
|
||||
it("respects maxOutputSize", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: 'function(){ console.log("aaaaa"); return 0; }()',
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 2,
|
||||
});
|
||||
expect(out.logs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("logs null and undefined via stringify", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = runRunner({
|
||||
code: "function(){ console.log(null, undefined); return 0; }()",
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.logs).toContain("null undefined");
|
||||
});
|
||||
|
||||
it("logs unstringifiable objects via String() fallback", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = runRunner({
|
||||
code: "function(){ var o = {}; o.self = o; console.log(o); return 0; }()",
|
||||
context: {},
|
||||
modules: [],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.success).toBe(true);
|
||||
expect(out.logs.length).toBe(1);
|
||||
});
|
||||
|
||||
it("injects context + modules", { timeout: 5000 }, () => {
|
||||
const out = runRunner({
|
||||
code: "x + cfg.y",
|
||||
context: { x: 1 },
|
||||
modules: [{ name: "cfg", exports: { y: 2 } }],
|
||||
maxOutputSize: 1024,
|
||||
});
|
||||
expect(out.result).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnEngine (real child_process)", () => {
|
||||
const runnerPath = writeRunner();
|
||||
|
||||
it("end-to-end runner pipeline returns 1+1", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute("1 + 1");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe(2);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("strips sensitive env keys", { timeout: 15000 }, () => {
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
env: { PATH: "/usr/bin", SECRET_X: "x", HOME: "/r" },
|
||||
});
|
||||
const env = e.getSanitisedEnv();
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.SECRET_X).toBeUndefined();
|
||||
expect(env.HOME).toBeUndefined();
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("kills child on timeout", { timeout: 15000 }, async () => {
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
timeout: 300,
|
||||
});
|
||||
const r = await e.execute("function(){ while(true){} }()");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toMatch(/timed out/i);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("captures child output and logs", { timeout: 15000 }, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute(
|
||||
'function(){ console.log("hi"); return "done"; }()'
|
||||
);
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe("done");
|
||||
expect(r.logs).toContain("hi");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("registerModule + execute injects exports", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.registerModule({ name: "cfg", exports: { v: 21 } });
|
||||
const r = await e.execute("cfg.v * 2");
|
||||
expect(r.result).toBe(42);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("registerModule overwrites by name", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.registerModule({ name: "cfg", exports: { v: 1 } });
|
||||
e.registerModule({ name: "cfg", exports: { v: 2 } });
|
||||
const r = await e.execute("cfg.v");
|
||||
expect(r.result).toBe(2);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("unregisterModule returns boolean", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.registerModule({ name: "cfg", exports: {} });
|
||||
expect(e.unregisterModule("cfg")).toBe(true);
|
||||
expect(e.unregisterModule("cfg")).toBe(false);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("reset restores initial modules", { timeout: 15000 }, async () => {
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
modules: [{ name: "cfg", exports: { v: 9 } }],
|
||||
});
|
||||
e.unregisterModule("cfg");
|
||||
e.reset();
|
||||
const r = await e.execute("cfg.v");
|
||||
expect(r.result).toBe(9);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("returns failure result after destroy", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.destroy();
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("disposed");
|
||||
});
|
||||
|
||||
it("getRunnerPath returns configured path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
expect(e.getRunnerPath()).toBe(runnerPath);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("default runner path resolves relative to module", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig });
|
||||
expect(e.getRunnerPath()).toMatch(/runner-bin\.(?:js|ts)$/);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces no-output when child writes nothing", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const silentRunner = writeRunner();
|
||||
writeFileSync(silentRunner, "process.exit(0);");
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath: silentRunner,
|
||||
});
|
||||
const r = await e.execute("1 + 1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("no output");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces parse-error when child writes garbage", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const garbageRunner = writeRunner();
|
||||
writeFileSync(
|
||||
garbageRunner,
|
||||
"process.stdout.write('not json'); process.exit(0);"
|
||||
);
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath: garbageRunner,
|
||||
});
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("Failed to parse");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces parse-error when child errors out before spawn", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
// Inject a spawner that emits an error event on the next tick.
|
||||
const fakeChild = {
|
||||
stdin: { write: () => {}, end: () => {} } as never,
|
||||
stdout: {
|
||||
on: () => {},
|
||||
} as never,
|
||||
on: (event: string, cb: (...args: unknown[]) => void) => {
|
||||
if (event === "error") {
|
||||
queueMicrotask(() => cb(new Error("spawn failed")));
|
||||
}
|
||||
},
|
||||
kill: () => true,
|
||||
};
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
spawner: () => fakeChild as never,
|
||||
});
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("spawn failed");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces failure when spawner returns null pipes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath,
|
||||
spawner: () =>
|
||||
({
|
||||
stdin: null,
|
||||
stdout: null,
|
||||
on: () => {},
|
||||
kill: () => true,
|
||||
}) as never,
|
||||
});
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("stdio pipes");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("respects per-execution maxOutputSize override", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute(
|
||||
'function(){ console.log("aaaa"); return 1; }()',
|
||||
{},
|
||||
{ maxOutputSize: 2 }
|
||||
);
|
||||
expect(r.logs.length).toBe(0);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("uses real node:child_process by default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
// Default spawner just wraps node:child_process.spawn; sanity-check
|
||||
// that it's callable.
|
||||
expect(typeof nodeSpawn).toBe("function");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("name is 'spawn'", { timeout: 5000 }, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
expect(e.name).toBe("spawn");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("surfaces child failure result with success=false fields", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute('function(){ throw new Error("oops"); }()');
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.error).toContain("oops");
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("reads child reply without memoryUsed/logs fields gracefully", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const partialRunner = writeRunner();
|
||||
writeFileSync(
|
||||
partialRunner,
|
||||
"process.stdin.on('data', () => {}); process.stdin.on('end', () => { process.stdout.write(JSON.stringify({ success: true, result: 7 })); });"
|
||||
);
|
||||
const e = new SpawnEngine({
|
||||
...baseConfig,
|
||||
runnerPath: partialRunner,
|
||||
});
|
||||
const r = await e.execute("1");
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.result).toBe(7);
|
||||
expect(r.memoryUsed).toBe(0);
|
||||
expect(r.logs).toEqual([]);
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("reset works when no initial modules", { timeout: 5000 }, () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
e.reset();
|
||||
e.destroy();
|
||||
});
|
||||
|
||||
it("execute uses default context+options", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const e = new SpawnEngine({ ...baseConfig, runnerPath });
|
||||
const r = await e.execute("5 + 5");
|
||||
expect(r.result).toBe(10);
|
||||
e.destroy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user