Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
import {
type ChildProcess,
spawn as nodeSpawn,
type SpawnOptions,
} from "node:child_process";
import { fileURLToPath } from "node:url";
import { recordDuration, recordExecution } from "../telemetry/telemetry.js";
import type {
ExecutionContext,
SandboxEngine,
SandboxEngineConfig,
SandboxModule,
SandboxOptions,
SandboxResult,
} from "../types/sandbox.types.js";
/** Process spawner seam. Defaults to `node:child_process.spawn`. */
export type ProcessSpawner = (
command: string,
args: readonly string[],
options: SpawnOptions
) => ChildProcess;
/** Spawn-engine config. */
export interface SpawnEngineOptions extends SandboxEngineConfig {
/** Path to the runner script. */
readonly runnerPath?: string;
/** Path to the interpreter binary. Defaults to `process.execPath`. */
readonly execPath?: string;
/** Spawner seam — tests can inject a custom factory. */
readonly spawner?: ProcessSpawner;
/** Source environment for the child. Defaults to `process.env`. */
readonly env?: Readonly<NodeJS.ProcessEnv>;
}
const SENSITIVE_PATTERNS = [
"SECRET",
"KEY",
"TOKEN",
"PASSWORD",
"CREDENTIAL",
"AUTH",
] as const;
const STRIPPED_KEYS = new Set(["HOME", "USER", "SHELL", "LOGNAME"]);
/**
* Strip sensitive + identity-revealing env keys from the source env. Returns
* a fresh object containing only the keys that survive the filter.
*/
export function sanitiseEnv(
source: Readonly<NodeJS.ProcessEnv>
): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (value === undefined || value === "") continue;
if (STRIPPED_KEYS.has(key)) continue;
const upper = key.toUpperCase();
if (SENSITIVE_PATTERNS.some((p) => upper.includes(p))) continue;
out[key] = value;
}
return out;
}
function defaultRunnerPath(): string {
return fileURLToPath(new URL("./runner-bin.js", import.meta.url));
}
/**
* Real `node:child_process`-backed sandbox engine. Each `execute` call spawns
* a fresh child process pinned to `process.execPath`, writes a JSON envelope
* to stdin, reads JSON from stdout, and enforces the timeout via
* `child.kill("SIGKILL")`. The process boundary is the security guarantee.
*/
export class SpawnEngine implements SandboxEngine {
public readonly name = "spawn";
private readonly config: SandboxEngineConfig;
private readonly runnerPath: string;
private readonly execPath: string;
private readonly spawner: ProcessSpawner;
private readonly env: Readonly<NodeJS.ProcessEnv>;
private modules: SandboxModule[];
private disposed = false;
public constructor(options: SpawnEngineOptions) {
this.config = {
timeout: options.timeout,
maxOutputSize: options.maxOutputSize,
memoryLimit: options.memoryLimit,
...(options.modules === undefined
? {}
: { modules: options.modules }),
};
this.modules = [...(options.modules ?? [])];
this.runnerPath = options.runnerPath ?? defaultRunnerPath();
this.execPath = options.execPath ?? process.execPath;
this.spawner =
options.spawner ??
((cmd, args, opts) => nodeSpawn(cmd, [...args], opts));
this.env = options.env ?? process.env;
}
/** Expose the resolved runner path (useful for tooling/diagnostics). */
public getRunnerPath(): string {
return this.runnerPath;
}
/** Expose the sanitised env (also called from tests). */
public getSanitisedEnv(): Record<string, string> {
return sanitiseEnv(this.env);
}
public async execute<T = unknown>(
code: string,
context: ExecutionContext = {},
options: SandboxOptions = {}
): Promise<SandboxResult<T>> {
if (this.disposed) {
recordExecution("spawn", "failure");
return failure<T>("Engine has been disposed", 0);
}
const timeout = options.timeout ?? this.config.timeout;
const maxOutputSize =
options.maxOutputSize ?? this.config.maxOutputSize;
const payload = {
code,
context,
modules: this.modules,
maxOutputSize,
};
const start = performance.now();
const child = this.spawner(this.execPath, [this.runnerPath], {
stdio: ["pipe", "pipe", "pipe"],
env: this.getSanitisedEnv(),
});
if (child.stdin === null || child.stdout === null) {
recordExecution("spawn", "failure");
return failure<T>("Child process did not expose stdio pipes", 0);
}
child.stdin.write(JSON.stringify(payload));
child.stdin.end();
const outcome = await raceChild(child, timeout);
const duration = performance.now() - start;
if (outcome.kind === "timeout") {
recordExecution("spawn", "failure");
recordDuration("spawn", "failure", duration);
return failure<T>("Child execution timed out", duration);
}
if (outcome.kind === "no-output") {
recordExecution("spawn", "failure");
recordDuration("spawn", "failure", duration);
return failure<T>("Child process produced no output", duration);
}
if (outcome.kind === "parse-error") {
recordExecution("spawn", "failure");
recordDuration("spawn", "failure", duration);
return failure<T>(
`Failed to parse child output: ${outcome.error}`,
duration
);
}
const parsed = outcome.result;
recordExecution("spawn", parsed.success ? "success" : "failure");
recordDuration(
"spawn",
parsed.success ? "success" : "failure",
duration
);
return {
success: parsed.success,
result: parsed.result as T,
error: parsed.error,
duration,
memoryUsed: parsed.memoryUsed ?? 0,
logs: parsed.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;
}
public reset(): void {
this.modules = [...(this.config.modules ?? [])];
}
public destroy(): void {
this.disposed = true;
this.modules = [];
}
}
interface ChildReply {
readonly success: boolean;
readonly result?: unknown;
readonly error?: string;
readonly logs?: readonly string[];
readonly memoryUsed?: number;
}
type ChildOutcome =
| { kind: "ok"; result: ChildReply }
| { kind: "timeout" }
| { kind: "no-output" }
| { kind: "parse-error"; error: string };
function raceChild(
child: ChildProcess,
timeout: number
): Promise<ChildOutcome> {
return new Promise<ChildOutcome>((resolve) => {
const chunks: Buffer[] = [];
let settled = false;
const settle = (outcome: ChildOutcome): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(outcome);
};
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
/* ignore */
}
settle({ kind: "timeout" });
}, timeout);
child.stdout?.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
child.on("error", (err: Error) => {
settle({ kind: "parse-error", error: err.message });
});
child.on("close", () => {
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (raw === "") {
settle({ kind: "no-output" });
return;
}
try {
const parsed = JSON.parse(raw) as ChildReply;
settle({ kind: "ok", result: parsed });
} catch (err) {
// JSON.parse only throws SyntaxError (a subclass of Error),
// so `err.message` is always available.
settle({
kind: "parse-error",
error: (err as Error).message,
});
}
});
});
}
function failure<T>(error: string, duration: number): SandboxResult<T> {
return {
success: false,
result: undefined,
error,
duration,
memoryUsed: 0,
logs: [],
};
}

View File

@@ -0,0 +1,15 @@
export type {
PoolAcquisitionResult,
SandboxEngineLabel,
SandboxOutcome,
} from "./telemetry.js";
export {
recordDuration,
recordExecution,
recordPoolAcquisition,
recordRuleBlock,
sandboxExecutionDurationHistogram,
sandboxExecutionsCounter,
sandboxPoolAcquisitionsCounter,
sandboxRuleBlocksCounter,
} from "./telemetry.js";

View File

@@ -0,0 +1,60 @@
# Telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/sandbox` ships first-class OpenTelemetry instrumentation so consumers running the sandbox at any scale can observe execution health without wrapping the API in their own counters. Every public failure mode and every successful execution increments a tagged counter, and every execution duration lands in a histogram tagged by engine kind and outcome.
The meter resolves lazily from the globally registered `MeterProvider`. When no provider is registered the OTel API returns no-op instruments, so the helpers can be called from every code path unconditionally — production code that has opted out of telemetry pays only the no-op cost.
## Features
- Four instruments on the `@suaveplan/sandbox` meter.
- Counter `sandbox_executions_total` — `{ engine, outcome }`.
- Counter `sandbox_rule_blocks_total` — `{ rule }`.
- Counter `sandbox_pool_acquisitions_total` — `{ result }` (`acquired` /
`created` / `exhausted`).
- Histogram `sandbox_execution_duration_ms` — `{ engine, outcome }`.
- Four record helpers so call sites never construct attribute bags manually.
## Usage: Basic
```ts
import { recordExecution } from "@suaveplan/sandbox/telemetry";
recordExecution("vm", "success");
```
## Usage: Advanced
Combine the duration histogram with the success counter to derive a p95
latency by engine kind, scoped by outcome, in your OTel collector:
```promql
histogram_quantile(0.95,
sum(rate(sandbox_execution_duration_ms_bucket{outcome="success"}[5m]))
by (engine, le))
```
## API Reference
- `sandboxExecutionsCounter: Counter`
- `sandboxRuleBlocksCounter: Counter`
- `sandboxPoolAcquisitionsCounter: Counter`
- `sandboxExecutionDurationHistogram: Histogram`
- `recordExecution(engine, outcome): void`
- `recordRuleBlock(rule): void`
- `recordPoolAcquisition(result): void`
- `recordDuration(engine, outcome, ms): void`
## Implementation Notes
Instruments are created at module-load time on a memoised meter — the OTel API
caches by name, so re-importing this module from another package does not
register duplicate instruments. The recording helpers are deliberately
parameter-positional (not options bags) so call sites stay short and the
attribute bag construction happens in one place rather than being repeated at
every recording site.

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordDuration,
recordExecution,
recordPoolAcquisition,
recordRuleBlock,
sandboxExecutionDurationHistogram,
sandboxExecutionsCounter,
sandboxPoolAcquisitionsCounter,
sandboxRuleBlocksCounter,
} from "./telemetry.js";
describe("telemetry", () => {
it("exports four instruments and four recorders", { timeout: 5000 }, () => {
expect(sandboxExecutionsCounter).toBeDefined();
expect(sandboxRuleBlocksCounter).toBeDefined();
expect(sandboxPoolAcquisitionsCounter).toBeDefined();
expect(sandboxExecutionDurationHistogram).toBeDefined();
expect(recordExecution).toBeTypeOf("function");
expect(recordRuleBlock).toBeTypeOf("function");
expect(recordPoolAcquisition).toBeTypeOf("function");
expect(recordDuration).toBeTypeOf("function");
});
it("recorders execute without throwing against the no-op meter", {
timeout: 5000,
}, () => {
expect(() => {
recordExecution("vm", "success");
recordExecution("worker", "failure");
recordExecution("spawn", "success");
recordExecution("isolate", "failure");
recordRuleBlock("block-process");
recordPoolAcquisition("acquired");
recordPoolAcquisition("created");
recordPoolAcquisition("exhausted");
recordDuration("vm", "success", 1.5);
}).not.toThrow();
});
});

View File

@@ -0,0 +1,76 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/sandbox";
/**
* Lazy-bound meter — resolves the globally registered `MeterProvider` on
* first access. When no provider is registered the OTel API returns no-op
* instruments, so the helpers below are safe to call from production code
* that has not opted into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
export type SandboxOutcome = "success" | "failure";
export type SandboxEngineLabel = "vm" | "worker" | "spawn" | "isolate";
export type PoolAcquisitionResult = "acquired" | "created" | "exhausted";
/** Counter incremented once per sandbox execution. */
export const sandboxExecutionsCounter: Counter = meter.createCounter(
"sandbox_executions_total",
{
description:
"Sandbox executions, tagged with engine kind and success/failure outcome",
}
);
/** Counter incremented once per code-transformer rule block. */
export const sandboxRuleBlocksCounter: Counter = meter.createCounter(
"sandbox_rule_blocks_total",
{
description:
"Code-transformer rule blocks, tagged with the offending rule name",
}
);
/** Counter incremented once per `SandboxPool.execute` acquisition attempt. */
export const sandboxPoolAcquisitionsCounter: Counter = meter.createCounter(
"sandbox_pool_acquisitions_total",
{
description:
"Sandbox pool acquisitions, tagged with acquired/created/exhausted result",
}
);
/** Histogram of execution durations in milliseconds. */
export const sandboxExecutionDurationHistogram: Histogram =
meter.createHistogram("sandbox_execution_duration_ms", {
description: "Sandbox execution durations in milliseconds",
unit: "ms",
});
/** Record a single sandbox execution outcome. */
export function recordExecution(
engine: SandboxEngineLabel,
outcome: SandboxOutcome
): void {
sandboxExecutionsCounter.add(1, { engine, outcome });
}
/** Record a code-transformer rule block. */
export function recordRuleBlock(rule: string): void {
sandboxRuleBlocksCounter.add(1, { rule });
}
/** Record a pool acquisition outcome. */
export function recordPoolAcquisition(result: PoolAcquisitionResult): void {
sandboxPoolAcquisitionsCounter.add(1, { result });
}
/** Record an execution duration. */
export function recordDuration(
engine: SandboxEngineLabel,
outcome: SandboxOutcome,
ms: number
): void {
sandboxExecutionDurationHistogram.record(ms, { engine, outcome });
}

View File

@@ -0,0 +1,16 @@
export type {
AuditFilter,
AuditLogEntry,
AuditStatistics,
Clock,
ExecutionContext,
SandboxConfig,
SandboxEngine,
SandboxEngineConfig,
SandboxEngineKind,
SandboxModule,
SandboxOptions,
SandboxResult,
SyncSandboxEngine,
} from "./sandbox.types.js";
export { DEFAULT_CONFIG } from "./sandbox.types.js";

View File

@@ -0,0 +1,124 @@
/**
* Shared types for `@suaveplan/sandbox`.
*
* These are types-only and have no runtime cost. Every public surface in the
* package consumes types declared here so contracts stay single-sourced.
*/
import type { AuditFilter } from "../audit-logger/audit-logger.types.js";
/** Pluggable clock; defaults to `Date.now`. */
export type Clock = () => number;
/** Configuration for creating a sandbox instance. */
export interface SandboxConfig {
readonly timeout: number;
readonly memoryLimit: number;
readonly allowedModules: readonly string[];
readonly maxOutputSize: number;
readonly maxExecutionsPerWindow: number | undefined;
readonly rateLimitWindow: number;
readonly maxCodeLength: number;
readonly engine: SandboxEngineKind;
readonly onAuditLog?: (entry: AuditLogEntry) => void;
}
/** Engine kind selector for `createSandbox`. */
export type SandboxEngineKind = "vm" | "worker" | "spawn" | "isolate";
/** Per-execution option overrides. */
export interface SandboxOptions {
readonly timeout?: number;
readonly memoryLimit?: number;
readonly maxOutputSize?: number;
}
/** Module exposed inside the sandbox. */
export interface SandboxModule {
readonly name: string;
readonly exports: Record<string, unknown>;
}
/** Variables/functions injected into the sandbox execution context. */
export type ExecutionContext = Record<string, unknown>;
/** Structured result of a sandbox execution. */
export interface SandboxResult<T = unknown> {
readonly success: boolean;
readonly result: T | undefined;
readonly error: string | undefined;
readonly duration: number;
readonly memoryUsed: number;
readonly logs: readonly string[];
}
/** Audit log entry. */
export interface AuditLogEntry {
readonly timestamp: string;
readonly codeHash: string;
readonly duration: number;
readonly success: boolean;
readonly memoryUsed: number;
readonly error?: string;
}
/** Aggregate statistics across audit entries. */
export interface AuditStatistics {
readonly totalRuns: number;
readonly successCount: number;
readonly failureCount: number;
readonly successRate: number;
readonly avgDuration: number;
readonly totalMemoryUsed: number;
}
/** Re-export filter contract for audit search. */
export type { AuditFilter };
/** Default sandbox configuration. */
export const DEFAULT_CONFIG: SandboxConfig = {
timeout: 5000,
memoryLimit: 50 * 1024 * 1024,
allowedModules: [],
maxOutputSize: 1024 * 1024,
maxExecutionsPerWindow: undefined,
rateLimitWindow: 60_000,
maxCodeLength: 100_000,
engine: "vm",
};
/** Engine configuration passed to every concrete engine constructor. */
export interface SandboxEngineConfig {
readonly timeout: number;
readonly maxOutputSize: number;
readonly memoryLimit: number;
readonly modules?: readonly SandboxModule[];
}
/** Engine contract. Concrete engines implement this. */
export interface SandboxEngine {
readonly name: string;
execute<T = unknown>(
code: string,
context?: ExecutionContext,
options?: SandboxOptions
): Promise<SandboxResult<T>>;
registerModule(mod: SandboxModule): void;
unregisterModule(name: string): boolean;
reset(): void;
destroy(): void;
}
/** Lower-level synchronous engine (vm, isolate). */
export interface SyncSandboxEngine {
readonly name: string;
executeSync<T = unknown>(
code: string,
context?: ExecutionContext,
options?: SandboxOptions
): SandboxResult<T>;
registerModule(mod: SandboxModule): void;
unregisterModule(name: string): boolean;
reset(): void;
destroy(): void;
}

View File

@@ -0,0 +1 @@
export { VmEngine } from "./vm-engine.js";

View File

@@ -0,0 +1,86 @@
# VmEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VmEngine` is the default sandbox engine. It uses `node:vm` directly: caller code is compiled with `new vm.Script(...)` and executed via `script.runInContext(ctx, { timeout })`. The context is created with `codeGeneration: { strings: false, wasm: false }` so `eval` and the Function constructor are syntactically unavailable inside the sandbox even if the code-transformer's regex filter fails. Context variables and registered modules are injected as own properties of the context object; the host's intrinsics (`Object`, `Array`, etc.) are reachable through the shared realm.
`VmEngine` is NOT a security boundary against adversarial code. `node:vm`'s
context-sharing model means a determined attacker with code-injection can
escape via `this.constructor.constructor("...")` or prototype-chain tricks.
For adversarial isolation use `worker-engine` (OS-thread isolation) or
`spawn-engine` (process isolation).
## Features
- Real `node:vm` execution — synchronous, deterministic.
- Frozen intrinsics flag (`codeGeneration: { strings: false, wasm: false }`).
- Captured console (`log`/`warn`/`error`) with output-size cap.
- Module + context-variable injection per execution.
- V8 heap statistics via `v8.getHeapStatistics()`.
- OTel `recordExecution` + `recordDuration` emission per call.
- `executeSync` companion for pool callers that prefer synchronous APIs.
## Usage: Basic
```ts
import { VmEngine } from "@suaveplan/sandbox/vm-engine";
const engine = new VmEngine({
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
});
const r = await engine.execute("1 + 1");
console.log(r.result); // 2
engine.destroy();
```
## Usage: Advanced
Inject a host bridge as a module so user code can interact with the
controlled-vocabulary API rather than the host's intrinsics:
```ts
import { VmEngine } from "@suaveplan/sandbox/vm-engine";
const engine = new VmEngine({
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
modules: [
{
name: "host",
exports: {
fetchProduct: (id: string) => loadProduct(id),
},
},
],
});
const r = await engine.execute('host.fetchProduct("sku-42")');
```
## API Reference
- `new VmEngine(config: SandboxEngineConfig)`.
- `execute(code, context?, options?): Promise<SandboxResult>`.
- `executeSync(code, context?, options?): SandboxResult`.
- `registerModule(mod): void`.
- `unregisterModule(name): boolean`.
- `getHeapStatistics(): { usedHeapSize, heapSizeLimit } | null`.
- `reset(): void`.
- `destroy(): void`.
## Implementation Notes
The compiled script wraps the user code in `(${code})` so a bare expression
(e.g. `1 + 1`) returns its value rather than throwing a syntax error from a
missing semicolon. An IIFE (`function(){...}()`) compiles to
`(function(){...}())` which is also valid. The wrapper is intentionally
lightweight; callers who want statement-level execution should use
`executeFunction` on the facade or wrap their code in a function themselves.
The `breakOnSigint: true` flag is set so a SIGINT delivered to the host
terminates the running script rather than killing the entire process.

View File

@@ -0,0 +1,248 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VmEngine } from "./vm-engine.js";
const baseConfig = {
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
};
describe("VmEngine", () => {
it("executes a simple expression", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute("1 + 1");
expect(r.success).toBe(true);
expect(r.result).toBe(2);
expect(r.duration).toBeGreaterThanOrEqual(0);
e.destroy();
});
it("captures console output", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
'function(){ console.log("hello", 42); return 1; }()'
);
expect(r.success).toBe(true);
expect(r.logs).toContain("hello 42");
e.destroy();
});
it("captures console.warn with [warn] prefix", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
'function(){ console.warn("w"); return 1; }()'
);
expect(r.logs).toContain("[warn] w");
e.destroy();
});
it("captures console.error with [error] prefix", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
'function(){ console.error("oops"); return 1; }()'
);
expect(r.logs).toContain("[error] oops");
e.destroy();
});
it("logs null and undefined", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
"function(){ console.log(null, undefined); return 0; }()"
);
expect(r.logs).toContain("null undefined");
e.destroy();
});
it("logs objects as JSON", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
"function(){ console.log({a:1}); return 0; }()"
);
expect(r.logs).toContain('{"a":1}');
e.destroy();
});
it("logs unstringifiable objects as String() fallback", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
// Circular ref forces JSON.stringify to throw → fallback to String().
const r = await e.execute(
"function(){ var o = {}; o.self = o; console.log(o); return 0; }()"
);
expect(r.success).toBe(true);
expect(r.logs.length).toBe(1);
e.destroy();
});
it("truncates console output above maxOutputSize", {
timeout: 5000,
}, async () => {
const e = new VmEngine({ ...baseConfig, maxOutputSize: 5 });
const r = await e.execute(
'function(){ console.log("aaaaaaaaaa"); console.log("more"); return 1; }()'
);
// First log line has length 10 > 5 limit, so no log is captured.
expect(r.logs.length).toBe(0);
e.destroy();
});
it("injects context variables", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute("x + y", { x: 2, y: 3 });
expect(r.result).toBe(5);
e.destroy();
});
it("injects modules", { timeout: 5000 }, async () => {
const e = new VmEngine({
...baseConfig,
modules: [{ name: "cfg", exports: { mult: 7 } }],
});
const r = await e.execute("cfg.mult * 6");
expect(r.result).toBe(42);
e.destroy();
});
it("registers and unregisters modules", { timeout: 5000 }, async () => {
const e = new VmEngine(baseConfig);
e.registerModule({ name: "cfg", exports: { v: 1 } });
e.registerModule({ name: "cfg", exports: { v: 2 } }); // overwrite
expect(e.unregisterModule("cfg")).toBe(true);
expect(e.unregisterModule("cfg")).toBe(false);
e.destroy();
});
it("surfaces thrown errors as failure result", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute('function(){ throw new Error("boom"); }()');
expect(r.success).toBe(false);
expect(r.error).toContain("boom");
e.destroy();
});
it("surfaces non-Error throws via String()", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute('function(){ throw "plain"; }()');
expect(r.success).toBe(false);
expect(r.error).toBe("plain");
e.destroy();
});
it("enforces sync timeout", { timeout: 10000 }, async () => {
const e = new VmEngine({ ...baseConfig, timeout: 100 });
const r = await e.execute("function(){ while(true){} }()");
expect(r.success).toBe(false);
expect(r.error).toMatch(/timed out|timeout/i);
e.destroy();
});
it("respects per-execution timeout override", {
timeout: 10000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
"function(){ while(true){} }()",
{},
{ timeout: 100 }
);
expect(r.success).toBe(false);
e.destroy();
});
it("respects per-execution maxOutputSize override", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
const r = await e.execute(
'function(){ console.log("aaaa"); return 1; }()',
{},
{ maxOutputSize: 2 }
);
expect(r.logs.length).toBe(0);
e.destroy();
});
it("getHeapStatistics returns null after destroy", {
timeout: 5000,
}, () => {
const e = new VmEngine(baseConfig);
const stats = e.getHeapStatistics();
expect(stats?.usedHeapSize).toBeGreaterThan(0);
e.destroy();
expect(e.getHeapStatistics()).toBeNull();
});
it("returns failure result after destroy", {
timeout: 5000,
}, async () => {
const e = new VmEngine(baseConfig);
e.destroy();
const r = await e.execute("1");
expect(r.success).toBe(false);
expect(r.error).toContain("disposed");
});
it("reset restores initial modules", { timeout: 5000 }, async () => {
const e = new VmEngine({
...baseConfig,
modules: [{ name: "cfg", exports: { v: 1 } }],
});
e.unregisterModule("cfg");
e.reset();
const r = await e.execute("cfg.v");
expect(r.result).toBe(1);
e.destroy();
});
it("reset works when no initial modules", { timeout: 5000 }, () => {
const e = new VmEngine(baseConfig);
e.reset();
e.destroy();
});
it("name is 'vm'", { timeout: 5000 }, () => {
const e = new VmEngine(baseConfig);
expect(e.name).toBe("vm");
e.destroy();
});
it("executeSync invoked with no context+options uses defaults", {
timeout: 5000,
}, () => {
const e = new VmEngine(baseConfig);
const r = e.executeSync("1 + 1");
expect(r.success).toBe(true);
expect(r.result).toBe(2);
e.destroy();
});
it("executeSync with non-Error thrown via vm", {
timeout: 5000,
}, () => {
const e = new VmEngine(baseConfig);
const r = e.executeSync("function(){ var x = 5; throw x; }()");
expect(r.success).toBe(false);
expect(r.error).toBe("5");
e.destroy();
});
it("executeSync with primitive null throw", {
timeout: 5000,
}, () => {
const e = new VmEngine(baseConfig);
const r = e.executeSync("function(){ throw null; }()");
expect(r.success).toBe(false);
expect(r.error).toBe("null");
e.destroy();
});
});

View File

@@ -0,0 +1,192 @@
import { getHeapStatistics } from "node:v8";
import vm from "node:vm";
import { recordDuration, recordExecution } from "../telemetry/telemetry.js";
import type {
ExecutionContext,
SandboxEngine,
SandboxEngineConfig,
SandboxModule,
SandboxOptions,
SandboxResult,
} from "../types/sandbox.types.js";
/**
* Real `node:vm`-backed sandbox engine. The default engine for
* `createSandbox`. NOT a security boundary against adversarial code —
* `vm.runInContext` shares the host realm; a determined attacker can escape
* via prototype-chain tricks. For adversarial isolation use
* `worker-engine` or `spawn-engine`.
*/
export class VmEngine implements SandboxEngine {
public readonly name = "vm";
private readonly config: SandboxEngineConfig;
private modules: SandboxModule[];
private disposed = false;
public constructor(config: SandboxEngineConfig) {
this.config = config;
this.modules = [...(config.modules ?? [])];
}
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 which iterates many calls. */
public executeSync<T = unknown>(
code: string,
context: ExecutionContext = {},
options: SandboxOptions = {}
): SandboxResult<T> {
if (this.disposed) {
recordExecution("vm", "failure");
return errorResult<T>("Engine has been disposed", 0, 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 sandboxConsole = {
log: (...args: unknown[]) =>
appendLog(args.map(stringify).join(" ")),
warn: (...args: unknown[]) =>
appendLog(`[warn] ${args.map(stringify).join(" ")}`),
error: (...args: unknown[]) =>
appendLog(`[error] ${args.map(stringify).join(" ")}`),
};
const sandboxObject: Record<string, unknown> = {
console: sandboxConsole,
...context,
};
for (const mod of this.modules) {
sandboxObject[mod.name] = mod.exports;
}
const ctx = vm.createContext(sandboxObject, {
codeGeneration: { strings: false, wasm: false },
});
const start = performance.now();
const heapBefore = safeHeapUsed();
try {
const script = new vm.Script(`(${code})`);
const result = script.runInContext(ctx, {
timeout,
breakOnSigint: true,
});
const duration = performance.now() - start;
const heapAfter = safeHeapUsed();
recordExecution("vm", "success");
recordDuration("vm", "success", duration);
return {
success: true,
result: result as T,
error: undefined,
duration,
memoryUsed: Math.max(0, heapAfter - heapBefore),
logs,
};
} catch (err: unknown) {
const duration = performance.now() - start;
const message = toErrorMessage(err);
recordExecution("vm", "failure");
recordDuration("vm", "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;
}
/** Returns engine heap stats or null when disposed. */
public getHeapStatistics(): {
usedHeapSize: number;
heapSizeLimit: number;
} | null {
if (this.disposed) return null;
const stats = getHeapStatistics();
return {
usedHeapSize: stats.used_heap_size,
heapSizeLimit: stats.heap_size_limit,
};
}
public reset(): void {
this.modules = [...(this.config.modules ?? [])];
}
public destroy(): void {
this.disposed = true;
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 toErrorMessage(err: unknown): string {
// The Error class inside a vm.Context is NOT the host Error; instanceof
// is therefore unreliable across the realm boundary. Duck-type on the
// `message` property.
if (
err !== null &&
typeof err === "object" &&
"message" in err &&
typeof (err as { message: unknown }).message === "string"
) {
return (err as { message: string }).message;
}
return String(err);
}
function errorResult<T>(
error: string,
duration: number,
memoryUsed: number
): SandboxResult<T> {
return {
success: false,
result: undefined,
error,
duration,
memoryUsed,
logs: [],
};
}
function safeHeapUsed(): number {
return getHeapStatistics().used_heap_size;
}

View File

@@ -0,0 +1 @@
export { WorkerEngine } from "./worker-engine.js";

View File

@@ -0,0 +1,70 @@
# WorkerEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkerEngine` uses real `node:worker_threads` for true OS-thread isolation. Each `execute` call spawns a fresh `Worker` constructed from an inline source string (`eval: true`), posts the code + context + modules over the structured-clone message channel, and awaits the structured reply. Intrinsics are NOT shared with the host realm — the worker has its own V8 isolate, its own heap, and its own event loop. Timeouts are enforced by calling `worker.terminate()`, which is a hard stop the running script cannot intercept.
Use `WorkerEngine` when the code being sandboxed may be adversarial: a true OS-thread boundary defeats the prototype-chain escapes that compromise `vm-engine`. The cost is per-call worker startup, which is non-trivial; pair with `SandboxPool` when latency matters.
## Features
- Real `node:worker_threads` execution — OS-thread isolation.
- Worker spawned per call, terminated after every result.
- `worker.terminate()` enforces timeout — script cannot intercept.
- Structured-clone passes context + modules.
- Output-size cap applied inside the worker before postMessage.
- OTel `recordExecution` + `recordDuration` per call.
## Usage: Basic
```ts
import { WorkerEngine } from "@suaveplan/sandbox/worker-engine";
const e = new WorkerEngine({
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
});
const r = await e.execute("2 + 2");
e.destroy();
```
## Usage: Advanced
Wrap the engine in a pool to amortise startup:
```ts
import { SandboxPool } from "@suaveplan/sandbox/pool";
import { WorkerEngine } from "@suaveplan/sandbox/worker-engine";
const pool = new SandboxPool({
minSize: 2,
maxSize: 8,
engineConfig: { timeout: 5000, maxOutputSize: 1024, memoryLimit: 5e7 },
factory: (cfg) => new WorkerEngine(cfg),
});
```
## API Reference
- `new WorkerEngine(config: SandboxEngineConfig)`.
- `execute(code, context?, options?): Promise<SandboxResult>`.
- `registerModule(mod): void`.
- `unregisterModule(name): boolean`.
- `reset(): void`.
- `destroy(): void`.
## Implementation Notes
The inline worker source is a single string literal. The worker uses a
runtime `Reflect.construct(Function, [...keys, source])` to wrap the user
code in an isolated function scope — this is acceptable inside the worker
because the worker IS the security boundary. Outside the worker, in the host,
no dynamic code construction happens. The post-message channel uses
structured clone, which copies primitives + plain objects + typed arrays but
not functions or symbols — module exports must therefore be data, not host
references; for host bridges use `spawn-engine` plus an IPC channel.

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WorkerEngine } from "./worker-engine.js";
const baseConfig = {
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
};
describe("WorkerEngine", () => {
it("executes 2+2", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute("2 + 2");
expect(r.success).toBe(true);
expect(r.result).toBe(4);
expect(r.duration).toBeGreaterThanOrEqual(0);
e.destroy();
});
it("captures console output", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute(
'function(){ console.log("hi"); return 1; }()'
);
expect(r.logs).toContain("hi");
e.destroy();
});
it("captures context variables", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute("a + b", { a: 3, b: 4 });
expect(r.result).toBe(7);
e.destroy();
});
it("propagates thrown errors", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute('function(){ throw new Error("boom"); }()');
expect(r.success).toBe(false);
expect(r.error).toContain("boom");
e.destroy();
});
it("terminates on timeout", { timeout: 15000 }, async () => {
const e = new WorkerEngine({ ...baseConfig, timeout: 200 });
const r = await e.execute("function(){ while(true){} }()");
expect(r.success).toBe(false);
expect(r.error).toContain("timed out");
e.destroy();
});
it("registerModule injects exports into the worker", {
timeout: 15000,
}, async () => {
const e = new WorkerEngine(baseConfig);
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 WorkerEngine(baseConfig);
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 WorkerEngine(baseConfig);
e.registerModule({ name: "cfg", exports: {} });
expect(e.unregisterModule("cfg")).toBe(true);
expect(e.unregisterModule("cfg")).toBe(false);
e.destroy();
});
it("rejects executions after destroy", { timeout: 5000 }, async () => {
const e = new WorkerEngine(baseConfig);
e.destroy();
const r = await e.execute("1");
expect(r.success).toBe(false);
expect(r.error).toContain("disposed");
});
it("reset restores initial modules", { timeout: 15000 }, async () => {
const e = new WorkerEngine({
...baseConfig,
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("per-execution timeout override fires", {
timeout: 15000,
}, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute(
"function(){ while(true){} }()",
{},
{ timeout: 150 }
);
expect(r.success).toBe(false);
e.destroy();
});
it("per-execution maxOutputSize override truncates logs", {
timeout: 15000,
}, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute(
'function(){ console.log("aaaaaaaaaa"); return 1; }()',
{},
{ maxOutputSize: 2 }
);
expect(r.logs.length).toBe(0);
e.destroy();
});
it("name is 'worker'", { timeout: 5000 }, () => {
const e = new WorkerEngine(baseConfig);
expect(e.name).toBe("worker");
e.destroy();
});
it("surfaces worker syntax errors", { timeout: 15000 }, async () => {
const e = new WorkerEngine(baseConfig);
const r = await e.execute("}{not valid(");
expect(r.success).toBe(false);
expect(r.error).toBeDefined();
e.destroy();
});
it("execute uses default context+options", {
timeout: 15000,
}, async () => {
const e = new WorkerEngine(baseConfig);
// Call execute(code) with no context/options at all so the default
// parameter values are exercised.
const r = await e.execute("3 * 3");
expect(r.result).toBe(9);
e.destroy();
});
it("reset works when no initial modules", { timeout: 5000 }, () => {
const e = new WorkerEngine(baseConfig);
e.reset();
e.destroy();
});
});

View File

@@ -0,0 +1,196 @@
import { Worker } from "node:worker_threads";
import { recordDuration, recordExecution } from "../telemetry/telemetry.js";
import type {
ExecutionContext,
SandboxEngine,
SandboxEngineConfig,
SandboxModule,
SandboxOptions,
SandboxResult,
} from "../types/sandbox.types.js";
interface WorkerPayload {
readonly code: string;
readonly context: ExecutionContext;
readonly modules: readonly SandboxModule[];
readonly maxOutputSize: number;
}
interface WorkerReply {
readonly success: boolean;
readonly result: unknown;
readonly error: string | undefined;
readonly logs: readonly string[];
readonly memoryUsed: number;
}
const WORKER_SOURCE = `
const { parentPort } = require('worker_threads');
parentPort.once('message', (payload) => {
const logs = [];
let totalLogSize = 0;
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 append = (line) => {
totalLogSize += line.length;
if (totalLogSize <= payload.maxOutputSize) logs.push(line);
};
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, ...payload.context };
for (const m of payload.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 factory = Reflect.construct(Function, [...keys, '"use strict";\\nreturn (' + payload.code + ')']);
const result = factory(...vals);
const memAfter = process.memoryUsage().heapUsed;
parentPort.postMessage({
success: true, result, error: undefined,
logs, memoryUsed: Math.max(0, memAfter - memBefore),
});
} catch (err) {
const memAfter = process.memoryUsage().heapUsed;
parentPort.postMessage({
success: false, result: undefined,
error: err instanceof Error ? err.message : String(err),
logs, memoryUsed: Math.max(0, memAfter - memBefore),
});
}
});
`;
/**
* Real `node:worker_threads`-backed sandbox engine. Each `execute` call spawns
* a fresh worker, posts the code + context + modules over the message channel,
* receives the structured reply, and enforces the timeout via
* `worker.terminate()`. The worker boundary is a true OS thread; intrinsics
* are NOT shared with the host realm.
*/
export class WorkerEngine implements SandboxEngine {
public readonly name = "worker";
private readonly config: SandboxEngineConfig;
private modules: SandboxModule[];
private disposed = false;
public constructor(config: SandboxEngineConfig) {
this.config = config;
this.modules = [...(config.modules ?? [])];
}
public async execute<T = unknown>(
code: string,
context: ExecutionContext = {},
options: SandboxOptions = {}
): Promise<SandboxResult<T>> {
if (this.disposed) {
recordExecution("worker", "failure");
return failure<T>("Engine has been disposed", 0);
}
const timeout = options.timeout ?? this.config.timeout;
const maxOutputSize =
options.maxOutputSize ?? this.config.maxOutputSize;
const payload: WorkerPayload = {
code,
context,
modules: this.modules,
maxOutputSize,
};
const start = performance.now();
const worker = new Worker(WORKER_SOURCE, { eval: true });
try {
const reply = await raceWorker<WorkerReply>(
worker,
payload,
timeout
);
const duration = performance.now() - start;
if (reply === "timeout") {
recordExecution("worker", "failure");
recordDuration("worker", "failure", duration);
return failure<T>("Worker execution timed out", duration);
}
recordExecution("worker", reply.success ? "success" : "failure");
recordDuration(
"worker",
reply.success ? "success" : "failure",
duration
);
return {
success: reply.success,
result: reply.result as T,
error: reply.error,
duration,
memoryUsed: reply.memoryUsed,
logs: reply.logs,
};
} finally {
await worker.terminate();
}
}
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;
}
public reset(): void {
this.modules = [...(this.config.modules ?? [])];
}
public destroy(): void {
this.disposed = true;
this.modules = [];
}
}
function raceWorker<T>(
worker: Worker,
payload: WorkerPayload,
timeout: number
): Promise<T | "timeout"> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
cleanup();
resolve("timeout");
}, timeout);
const onMessage = (msg: unknown): void => {
cleanup();
resolve(msg as T);
};
const cleanup = (): void => {
clearTimeout(timer);
worker.off("message", onMessage);
};
worker.on("message", onMessage);
// Worker eval boundary surfaces all sub-evaluation errors through
// the structured reply payload — see WORKER_SOURCE's try/catch.
worker.postMessage(payload);
});
}
function failure<T>(error: string, duration: number): SandboxResult<T> {
return {
success: false,
result: undefined,
error,
duration,
memoryUsed: 0,
logs: [],
};
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,59 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/devtools-tooling-sandbox",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"types/index": "src/types/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"schemas/index": "src/schemas/index.ts",
"telemetry/index": "src/telemetry/index.ts",
"audit-logger/index": "src/audit-logger/index.ts",
"resource-limiter/index": "src/resource-limiter/index.ts",
"module-resolver/index": "src/module-resolver/index.ts",
"code-transformer/index": "src/code-transformer/index.ts",
"vm-engine/index": "src/vm-engine/index.ts",
"worker-engine/index": "src/worker-engine/index.ts",
"spawn-engine/index": "src/spawn-engine/index.ts",
"spawn-engine/runner": "src/spawn-engine/runner.ts",
"spawn-engine/runner-bin": "src/spawn-engine/runner-bin.ts",
"isolate-engine/index": "src/isolate-engine/index.ts",
"pool/index": "src/pool/index.ts",
"result-streamer/index": "src/result-streamer/index.ts",
"sandbox/index": "src/sandbox/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/types/**",
"src/spawn-engine/runner-bin.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
hookTimeout: 10000,
},
});