Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,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,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,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,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,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.
|
||||
@@ -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,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,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.
|
||||
@@ -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,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.
|
||||
@@ -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,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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user