Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
# Design — @suaveplan/sandbox
## File layout
```text
packages/devtools/tooling/sandbox/
package.json # 0.1.0, server-tier, sideEffects: false
vite.config.ts # multi-entry lib build, preserveModules
vitest.config.ts # forks pool, istanbul, 100/100/100/100
tsconfig.lib.json # rootDir=./src, lib=ES2022, types=[node]
README.md
src/
index.ts # explicit named re-exports
types/
sandbox.types.ts # SandboxConfig, SandboxResult, ...
index.ts
error-codes/
error-codes.ts # defineCodes("SANDBOX", [...])
error-codes.md
error-codes.test.ts
index.ts
schemas/
schemas.ts # SandboxConfigSchema, ...
schemas.md
schemas.test.ts
index.ts
telemetry/
telemetry.ts # counters + histogram + record fns
telemetry.md
telemetry.test.ts
index.ts
audit-logger/
audit-logger.ts # AuditLogger class
audit-logger.md
audit-logger.test.ts
index.ts
resource-limiter/
resource-limiter.ts # ResourceLimiter with injectable Clock
resource-limiter.md
resource-limiter.test.ts
index.ts
module-resolver/
module-resolver.ts # ModuleResolver + BLOCKED + BUILTINS
module-resolver.md
module-resolver.test.ts
index.ts
code-transformer/
code-transformer.ts # CodeTransformer + BUILTIN_RULES
code-transformer.md
code-transformer.test.ts
index.ts
vm-engine/
vm-engine.ts # VmEngine (default; node:vm)
vm-engine.md
vm-engine.test.ts
index.ts
worker-engine/
worker-engine.ts # WorkerEngine (node:worker_threads)
worker-engine.md
worker-engine.test.ts
index.ts
spawn-engine/
spawn-engine.ts # SpawnEngine (node:child_process)
spawn-engine.md
spawn-engine.test.ts
runner.ts # child entry point
runner.md
index.ts
isolate-engine/
isolate-engine.ts # IsolateEngine (structural ivm)
isolate-engine.md
isolate-engine.test.ts
index.ts
pool/
pool.ts # SandboxPool with injectable Clock
pool.md
pool.test.ts
index.ts
result-streamer/
result-streamer.ts # ResultStreamer
result-streamer.md
result-streamer.test.ts
index.ts
sandbox/
sandbox.ts # createSandbox facade
sandbox.md
sandbox.test.ts
index.ts
```
## Engine selection
`createSandbox({ engine })` accepts `"vm" | "worker" | "spawn" | "isolate"` plus
a `customEngine` factory escape hatch. Default is `"vm"`. Engines all conform to
the `SandboxEngine` contract:
```ts
interface SandboxEngine {
readonly name: string;
execute<T>(code, context, options): Promise<SandboxResult<T>> | SandboxResult<T>;
registerModule(mod): void;
unregisterModule(name): boolean;
reset(): void;
destroy(): void;
}
```
`vm-engine` and `isolate-engine` execute synchronously and wrap the result in
`Promise.resolve` at the facade level. `worker-engine` and `spawn-engine`
execute asynchronously. The facade always returns `Promise<SandboxResult<T>>`.
## Frozen-Realm pattern (vm-engine)
`node:vm` shares the host realm but `createContext` lets us hand the script a
context object whose intrinsics (`Object`, `Array`, etc.) are the host's. We
mitigate prototype-chain escapes by:
1. `vm.createContext({})` with `codeGeneration: { strings: false, wasm: false }`
to prevent `eval`/`Function` constructor inside the script.
2. `Object.freeze` on the context's `Object.prototype`, `Function.prototype`,
`Array.prototype`, and the context's `Object` constructor itself.
3. Injecting `console` as a frozen proxy that delegates only to the captured
log array.
4. `runInContext(wrappedCode, { timeout, breakOnSigint: true })` — synchronous
timeout enforcement.
The package README documents that `vm-engine` is NOT a security boundary against
adversarial code; for that, use `worker-engine` (true OS-thread isolation) or
`spawn-engine` (true process isolation).
## Test seams
- `ResourceLimiter` and `SandboxPool` accept a `Clock` (`() => number`)
defaulting to `Date.now`. Tests inject a `FakeClock` to assert sliding-window
+ idle-timeout behaviour without `setTimeout` flake.
- `SpawnEngine` accepts a `ProcessSpawner` defaulting to `child_process.spawn`.
Tests use the real spawner against a temp-file `runner.ts` fixture so the
whole pipeline (stdin write, stdout read, timeout kill, JSON parse) is
exercised end-to-end.
- `WorkerEngine` uses real `node:worker_threads`. No stubbing — workers run
inline strings through real V8.
- `IsolateEngine` consumes a structural `IsolateFactory` so tests can wire a
hand-written fake that implements the same contract a real `isolated-vm`
instance would. The structural type means the optional peer dep stays
optional and the tests stay deterministic.
## Error codes (SANDBOX.*)
`defineCodes("SANDBOX", [...])` yields fifteen codes:
- `INVALID_CONFIG` — Zod validation failed on config.
- `INVALID_OPTIONS` — Zod validation failed on per-execution options.
- `INVALID_MODULE` — Zod validation failed on a `SandboxModule`.
- `BLOCKED_MODULE` — caller tried to register a denylisted module name.
- `RATE_LIMITED` — sliding-window rate limit tripped.
- `TIMEOUT` — execution exceeded the configured timeout.
- `MEMORY_EXCEEDED` — V8 heap statistics reported usage above the limit.
- `CODE_BLOCKED` — `CodeTransformer` blocked execution.
- `CODE_TOO_LONG` — code exceeded `maxCodeLength`.
- `ENGINE_DISPOSED` — caller invoked execute after `destroy()`.
- `ENGINE_FAILED` — engine raised before producing a result envelope.
- `WORKER_FAILED` — worker thread exited or errored before responding.
- `SPAWN_FAILED` — child process exited non-zero without producing JSON.
- `POOL_EXHAUSTED` — pool is at `maxSize` with no available engines.
- `STREAM_CLOSED` — `pushChunk` after `complete()`.
Every code is reachable from a call site exercised by a test.
## OTel instruments
- 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 }`.
## Zod boundaries
`createSandbox(config?)` parses `SandboxConfigPartialSchema` then merges with
defaults. `execute(code, context?, options?)` parses the options object via
`SandboxOptionsSchema`. `registerModule(mod)` parses `SandboxModuleSchema`.
Failures raise `SandboxError` with `INVALID_CONFIG` / `INVALID_OPTIONS` /
`INVALID_MODULE`.
## No dependents
Net-new package on genesis — pure addition.

View File

@@ -0,0 +1,132 @@
# Proposal — Migrate @suaveplan/sandbox to genesis
- **Design**: ./design.md
- **Spec delta**: ./specs/sandbox/spec.md
## Why
Upstream `@suaveplan/sandbox` (`/mnt/s/web/suaveplan/packages/server/sandbox/`) ships a
working server-tier sandboxed execution environment built around `Bun.spawn` for
process-isolated execution, an `IsolateEngine` contract for isolated-vm, a
pre-warmed `SandboxPool`, an async `ResultStreamer`, a pattern-based
`CodeTransformer`, an allowlist-based `ModuleResolver`, a sliding-window
`ResourceLimiter`, and a SHA-256 `AuditLogger`. The genesis `enterprise/sandbox.md`
audit calls out two cross-cutting gaps shared with every other migration: (a) no
error-code registry — every failure path raises plain `Error` instances so
consumers cannot discriminate programmatically, and (b) no OpenTelemetry surface
even though sandbox execution is a load-bearing primitive in any agent or
plugin host. The upstream also hard-codes a single transport (Bun.spawn pinned
to `process.argv[0]`) with no seam for testing without an actual child process,
and it lacks Zod-validated configuration on the public boundary.
Genesis cleans up the contract: every failure raises a typed `SandboxError`
with a stable `SANDBOX.*` code, the engine surface gains a runner-tier abstraction
that defaults to real `node:vm` (universally available, no Bun dependency) and
real `node:worker_threads` for true isolation when requested, `Bun.spawn` is
retained behind the same `SandboxEngine` contract for environments that prefer
process boundaries, Zod schemas guard the public surface, and OTel counters +
histograms tag every execution by engine, outcome, and phase.
## What
Land `@suaveplan/sandbox@0.1.0` at `packages/devtools/tooling/sandbox/` with these
feature modules (one folder per feature, sibling `.md` per implementation file):
- `types/` — shared `SandboxConfig`, `SandboxResult`, `SandboxOptions`,
`SandboxModule`, `ExecutionContext`, `AuditLogEntry`, `AuditStatistics`,
`Clock`, `ProcessSpawner`, `CommandResult` types.
- `error-codes/` — `SANDBOX_CODES` via `defineCodes("SANDBOX", [...])` +
`SandboxError` class.
- `schemas/` — Zod schemas for `SandboxConfig`, `SandboxOptions`,
`SandboxModule` boundaries.
- `telemetry/` — OTel counters (`sandbox_executions_total`,
`sandbox_rule_blocks_total`, `sandbox_pool_acquisitions_total`) and a
histogram (`sandbox_execution_duration_ms`) tagged by engine + outcome.
- `audit-logger/` — `AuditLogger` with SHA-256 code hashing, search filters,
statistics aggregation, and `onAuditLog` callback hook.
- `resource-limiter/` — `ResourceLimiter` with sliding-window rate limiting,
output-size truncation, injectable `Clock` for deterministic tests.
- `module-resolver/` — `ModuleResolver` with `BLOCKED_MODULES` denylist,
`BUILTIN_SAFE_MODULES` (`Math`/`JSON`/`Date`), custom module registry.
- `code-transformer/` — `CodeTransformer` with the eleven `BUILTIN_RULES`
(block-process, block-require, block-import-dynamic, block-eval,
block-function-constructor, block-global-this, block-proto-access,
block-constructor-access, warn-while-true, warn-infinite-for,
max-code-length) plus an injectable custom-rule slot.
- `vm-engine/` — Real-`node:vm`-backed `SandboxEngine` (default). Frozen-Realm
pattern via `vm.createContext(...)`, `vm.Script` compile +
`runInContext({ timeout })`, captured console with output-size limit,
module + context injection, heap snapshotting via `v8.getHeapStatistics()`.
- `worker-engine/` — Real-`node:worker_threads`-backed `SandboxEngine`. Spawns
an inline `Worker` (`eval: true`) with code + context + modules sent via
`postMessage`, captures result via the message channel, enforces timeout by
terminating the worker.
- `spawn-engine/` — Real-`node:child_process`-backed runner equivalent to
upstream's `bun-spawn-runner` but using `node:child_process.spawn` so it
works under both Bun and Node. Pinned to current `process.execPath`,
sanitised env (`SECRET`/`KEY`/`TOKEN`/`PASSWORD`/`CREDENTIAL`/`AUTH`
patterns + `HOME`/`USER`/`SHELL`/`LOGNAME` stripped), structured JSON IPC
via stdin/stdout, timeout via `Promise.race`.
- `runner/` — Child-process entry point invoked by `spawn-engine`. Reads JSON
from stdin, evaluates code via a strict-mode dynamic wrapper, writes JSON
result to stdout. Ships as its own subpath export.
- `isolate-engine/` — Pluggable `IsolateEngine` over the upstream's
isolated-vm contract (kept as a structural interface so the optional peer
dep stays optional).
- `pool/` — `SandboxPool` of pre-warmed engines, idle reclamation,
recycle-on-N-executions, statistics surface.
- `result-streamer/` — `ResultStreamer` with subscribe/unsubscribe, push
chunks (`log`/`result`/`error`/`complete`), buffered + interval-flushed
delivery, completion gating.
- `sandbox/` — `createSandbox(config?)` facade composing module resolver,
resource limiter, audit logger, code transformer, and an engine selection.
`Sandbox.execute` / `executeFunction` returns the structured `SandboxResult`.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `createSandbox(config)` facade | ✅ | ✅ | Same surface, Zod-validated config. |
| `Sandbox.execute(code, context, opts)` | ✅ | ✅ | |
| `Sandbox.executeFunction(fn, args, opts)` | ✅ | ✅ | |
| `Sandbox.registerModule` / `unregisterModule` | ✅ | ✅ | |
| `Sandbox.getAuditLog` / `getStatistics` / `reset` | ✅ | ✅ | |
| `Bun.spawn` process engine | ✅ | ⬆️ | Replaced with portable `node:child_process` `spawn` (works under Bun + Node). Same isolation guarantees. |
| Frozen-Realm VM engine | 🚫 | ⬆️ | New default — `node:vm` `createContext` + `runInContext({ timeout })`. |
| `node:worker_threads` engine | 🚫 | ⬆️ | New — true OS-thread isolation for stronger CPU caps. |
| `IsolateEngine` (isolated-vm contract) | ✅ | ✅ | Preserved as structural interface; tests use a real fake factory. |
| `SandboxPool` (warm + idle reclaim + recycle) | ✅ | ✅ | |
| `ResultStreamer` (subscribe/buffer/flush) | ✅ | ✅ | |
| `CodeTransformer` (10 built-in rules + max-code-length) | ✅ | ✅ | |
| `ModuleResolver` (blocked/allowed + builtins) | ✅ | ✅ | |
| `ResourceLimiter` (sliding-window rate limit + output trunc) | ✅ | ⬆️ | Injectable `Clock` for deterministic tests. |
| `AuditLogger` (SHA-256 hash + filter + stats) | ✅ | ✅ | |
| `BUILTIN_RULES` export | ✅ | ✅ | |
| `DEFAULT_CONFIG` export | ✅ | ✅ | |
| Error-code registry | 🚫 | ⬆️ | Added — `SANDBOX.*` codes via `defineCodes`. |
| OTel self-telemetry | 🚫 | ⬆️ | Added — counters + histograms. |
| Zod-validated config | 🚫 | ⬆️ | Added — `SandboxConfigSchema`, etc. |
| Subpath exports per feature | 🚫 | ⬆️ | Added — every feature module is its own subpath. |
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/sandbox` to enumerate. Net-new on
genesis — no current dependents.
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/sandbox`).
## Risks
- **`node:vm` is not a security boundary.** `vm.runInContext` shares the host
realm; a determined attacker with code-injection can escape via prototype
chain tricks. The package documents this explicitly: `vm-engine` is for
*isolation against accidents*, `worker-engine` and `spawn-engine` are for
isolation against adversaries.
- **Timeouts under `vm.runInContext` only cover synchronous code.** A microtask
scheduled inside the context outlives the timeout. The package documents
this and recommends `worker-engine` for code that awaits.
- **`node:worker_threads` `eval: true` accepts a string.** This is the
worker's documented contract for running ad-hoc code. The boundary is the
worker thread, not the string parser.

View File

@@ -0,0 +1,278 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: sandbox (sandbox)
**Package**: `@suaveplan/sandbox`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Public facade composes every subsystem
The package SHALL export a `createSandbox(config?: Partial<SandboxConfig>)` factory returning a `Sandbox` whose `execute` / `executeFunction` / `registerModule` / `unregisterModule` / `getAuditLog` / `getStatistics` / `reset` methods compose the configured engine, `ModuleResolver`, `ResourceLimiter`, `CodeTransformer`, and `AuditLogger`. Default `engine` is `"vm"`.
#### Scenario: execute returns structured result
- **WHEN** `createSandbox()` is called and `sandbox.execute("2 + 3")` resolves
- **THEN** the resolved value has `success === true`, `result === 5`, `duration >= 0`, `logs` is an array
#### Scenario: executeFunction wraps function source
- **WHEN** `sandbox.executeFunction((a, b) => a * b, [6, 7])` resolves
- **THEN** the resolved value has `success === true` and `result === 42`
**Verified by:** `src/sandbox/sandbox.test.ts::"executes a simple expression"` AND `src/sandbox/sandbox.test.ts::"executeFunction multiplies arguments"`
---
### Requirement: SANDBOX error registry
The package SHALL declare `SANDBOX_CODES` via `defineCodes("SANDBOX", [...])` covering `INVALID_CONFIG`, `INVALID_OPTIONS`, `INVALID_MODULE`, `BLOCKED_MODULE`, `RATE_LIMITED`, `TIMEOUT`, `MEMORY_EXCEEDED`, `CODE_BLOCKED`, `CODE_TOO_LONG`, `ENGINE_DISPOSED`, `ENGINE_FAILED`, `WORKER_FAILED`, `SPAWN_FAILED`, `POOL_EXHAUSTED`, `STREAM_CLOSED`, and a `SandboxError` class extending `SuaveplanError`. Every code MUST be reachable from a real call site exercised by a test.
#### Scenario: invalid module name is denylisted
- **WHEN** `sandbox.registerModule({ name: "fs", exports: {} })` is called
- **THEN** the call throws `SandboxError` with `code === SANDBOX_CODES.BLOCKED_MODULE`
#### Scenario: code transformer block
- **WHEN** `sandbox.execute("process.exit(1)")` resolves
- **THEN** the resolved value has `success === false`, `error` contains `"block-process"`
**Verified by:** `src/error-codes/error-codes.test.ts::"every SANDBOX code is registered"` AND `src/module-resolver/module-resolver.test.ts::"register denylisted module throws BLOCKED_MODULE"` AND `src/sandbox/sandbox.test.ts::"transformer blocks process.exit"`
---
### Requirement: Zod-validated public surface
The package SHALL guard `createSandbox`, `execute`, and `registerModule` with Zod schemas (`SandboxConfigPartialSchema`, `SandboxOptionsSchema`, `SandboxModuleSchema`). Parse failures raise `SandboxError` with `INVALID_CONFIG`, `INVALID_OPTIONS`, or `INVALID_MODULE` respectively.
#### Scenario: negative timeout rejected
- **WHEN** `createSandbox({ timeout: -1 })` is called
- **THEN** the call throws `SandboxError` with `code === SANDBOX_CODES.INVALID_CONFIG`
**Verified by:** `src/schemas/schemas.test.ts::"rejects negative timeout"` AND `src/sandbox/sandbox.test.ts::"rejects invalid config"`
---
### Requirement: OTel self-telemetry
The package SHALL register four OpenTelemetry instruments on the `@suaveplan/sandbox` meter: counters `sandbox_executions_total` (tagged `engine`/`outcome`), `sandbox_rule_blocks_total` (tagged `rule`), `sandbox_pool_acquisitions_total` (tagged `result`), and a histogram `sandbox_execution_duration_ms` (tagged `engine`/`outcome`). Recording helpers (`recordExecution`, `recordRuleBlock`, `recordPoolAcquisition`, `recordDuration`) MUST be called from real call sites.
#### Scenario: telemetry helpers are exposed
- **WHEN** the telemetry barrel is imported
- **THEN** `sandboxExecutionsCounter`, `sandboxRuleBlocksCounter`, `sandboxPoolAcquisitionsCounter`, `sandboxExecutionDurationHistogram`, `recordExecution`, `recordRuleBlock`, `recordPoolAcquisition`, `recordDuration` are all defined
**Verified by:** `src/telemetry/telemetry.test.ts::"exports four instruments and four recorders"`
---
### Requirement: AuditLogger
The package SHALL provide an `AuditLogger` class that records each execution with `timestamp` (ISO 8601), `codeHash` (first 16 chars of SHA-256 hex), `duration`, `success`, `memoryUsed`, and optional `error`. The logger MUST support `getEntries`, `search({ success?, after?, before?, limit? })`, `getStatistics`, `clear`, and a constructor-provided `onLog` callback invoked synchronously per entry.
#### Scenario: search filters by success
- **WHEN** an audit logger has logged one success and one failure, and `search({ success: false })` is called
- **THEN** exactly the failure entry is returned
#### Scenario: statistics aggregate
- **WHEN** the logger has two success and one failure entries with durations [10, 20, 30]
- **THEN** `getStatistics()` returns `{ totalRuns: 3, successCount: 2, failureCount: 1, successRate: 2/3, avgDuration: 20, totalMemoryUsed: <sum> }`
**Verified by:** `src/audit-logger/audit-logger.test.ts::"search filters by success"` AND `src/audit-logger/audit-logger.test.ts::"statistics aggregate across entries"`
---
### Requirement: ResourceLimiter
The package SHALL provide a `ResourceLimiter` class supporting `maxExecutionsPerWindow` over a sliding `rateLimitWindow` (default 60_000 ms). `checkRateLimit()` MUST throw `SandboxError` with `RATE_LIMITED` when the window count is at or above the limit. The limiter MUST accept an injectable `Clock` (defaulting to `Date.now`) and provide `truncateOutput`, `recordExecution`, `getTotalExecutions`, `getWindowExecutions`, `reset`.
#### Scenario: sliding window allows execution after window expires
- **WHEN** the limiter is configured with `maxExecutionsPerWindow: 2` and the clock advances past `rateLimitWindow`
- **THEN** a third call to `checkRateLimit()` succeeds
#### Scenario: output truncation appends sentinel
- **WHEN** `truncateOutput(string-longer-than-max)` is called
- **THEN** the result is truncated to `maxOutputSize` bytes with the `[output truncated at N bytes]` sentinel appended
**Verified by:** `src/resource-limiter/resource-limiter.test.ts::"sliding window resets after expiry"` AND `src/resource-limiter/resource-limiter.test.ts::"truncates oversize output"`
---
### Requirement: ModuleResolver
The package SHALL provide a `ModuleResolver` class with a denylist (`fs`, `net`, `child_process`, `cluster`, `dgram`, `dns`, `http`, `https`, `http2`, `tls`, `worker_threads`, `process`, `os`, `path`, `readline`, `repl`, `vm`) and a built-in safe-module registry (`Math`, `JSON`, `Date`). `register` MUST throw `SandboxError` with `BLOCKED_MODULE` for denylisted names. `unregister`, `getModules`, `isBlocked`, `isAllowed`, `getBlockedModules` SHALL all be public.
#### Scenario: register denylisted module is rejected
- **WHEN** `resolver.register({ name: "fs", exports: {} })`
- **THEN** throws `SandboxError` with `code === SANDBOX_CODES.BLOCKED_MODULE`
#### Scenario: builtin Math is auto-loaded when allowed
- **WHEN** `new ModuleResolver(["Math"])` is constructed
- **THEN** `getModules()` returns a module named `Math` whose exports include `Math.PI`
**Verified by:** `src/module-resolver/module-resolver.test.ts::"register denylisted module throws BLOCKED_MODULE"` AND `src/module-resolver/module-resolver.test.ts::"builtin Math is auto-loaded"`
---
### Requirement: CodeTransformer
The package SHALL provide a `CodeTransformer` class with eleven built-in rules: `block-process`, `block-require`, `block-import-dynamic`, `block-eval`, `block-function-constructor`, `block-global-this`, `block-proto-access`, `block-constructor-access`, `warn-while-true`, `warn-infinite-for`, plus a `max-code-length` guard (default 100000). `transform` MUST return a `TransformResult` (`code`, `blocked`, `blockedBy`, `applied`, `warnings`); `wouldBlock` MUST be a pure check. Custom rules SHALL be additive.
#### Scenario: block-process trips on process.exit
- **WHEN** `transformer.transform("process.exit(1)")`
- **THEN** result has `blocked === true`, `blockedBy === "block-process"`
#### Scenario: code over maxCodeLength is blocked
- **WHEN** `transformer.transform("x".repeat(maxCodeLength + 1))`
- **THEN** result has `blocked === true`, `blockedBy === "max-code-length"`
**Verified by:** `src/code-transformer/code-transformer.test.ts::"blocks process.exit via block-process rule"` AND `src/code-transformer/code-transformer.test.ts::"blocks oversize code"`
---
### Requirement: VmEngine — node:vm execution
The package SHALL provide a `VmEngine` class implementing `SandboxEngine` backed by real `node:vm`. It MUST compile the code with `new vm.Script(...)`, run via `script.runInContext(ctx, { timeout })`, capture console output up to `maxOutputSize`, surface thrown errors as `success: false` results, and snapshot V8 heap usage via `v8.getHeapStatistics()`.
#### Scenario: vm engine executes 1+1
- **WHEN** `new VmEngine({ timeout: 5000, maxOutputSize: 1024, memoryLimit: 50_000_000 })` is created and `engine.execute("1 + 1")` is awaited
- **THEN** the result has `success === true` and `result === 2`
#### Scenario: vm engine throws TIMEOUT-class for infinite loop
- **WHEN** `engine.execute("while(true){}", {}, { timeout: 100 })` is awaited
- **THEN** the result has `success === false`, `error` is non-empty (contains `timed out` or `Script execution timed out`)
#### Scenario: vm engine console capture
- **WHEN** `engine.execute("console.log('hello'); 1")` is awaited
- **THEN** the result has `logs` containing `"hello"`
**Verified by:** `src/vm-engine/vm-engine.test.ts::"executes a simple expression"` AND `src/vm-engine/vm-engine.test.ts::"enforces sync timeout"` AND `src/vm-engine/vm-engine.test.ts::"captures console output"`
---
### Requirement: WorkerEngine — node:worker_threads execution
The package SHALL provide a `WorkerEngine` class implementing `SandboxEngine` backed by real `node:worker_threads`. Each `execute` call spawns a fresh `Worker` (`eval: true`), posts the code + context + modules + maxOutputSize via the message channel, receives the structured result on `message`, and enforces the timeout by calling `worker.terminate()`.
#### Scenario: worker engine executes 2+2
- **WHEN** `engine.execute("2 + 2")` resolves
- **THEN** the result has `success === true`, `result === 4`
#### Scenario: worker engine times out via terminate
- **WHEN** `engine.execute("while(true){}", {}, { timeout: 200 })` resolves
- **THEN** the result has `success === false`, `error` references the timeout
**Verified by:** `src/worker-engine/worker-engine.test.ts::"executes 2+2"` AND `src/worker-engine/worker-engine.test.ts::"terminates on timeout"`
---
### Requirement: SpawnEngine — node:child_process execution
The package SHALL provide a `SpawnEngine` class implementing the async `SandboxEngine` contract backed by real `node:child_process.spawn`. Each `execute` call spawns a child process (defaulting to `process.execPath`), writes a JSON envelope (code + context + modules + maxOutputSize) to stdin, reads JSON from stdout, sanitises the environment by stripping keys that match `SECRET`/`KEY`/`TOKEN`/`PASSWORD`/`CREDENTIAL`/`AUTH` (case-insensitive contains) and dropping `HOME`/`USER`/`SHELL`/`LOGNAME`, and enforces the timeout via `Promise.race` + `child.kill("SIGKILL")`.
#### Scenario: spawn engine sanitises env
- **WHEN** the engine sanitises its env with `process.env.SECRET_X = "..."` set
- **THEN** the sanitised env does NOT contain `SECRET_X` (or `HOME`/`USER`/`SHELL`/`LOGNAME`)
#### Scenario: spawn engine times out by killing the child
- **WHEN** `engine.execute("while(true){}", {}, { timeout: 200 })` resolves
- **THEN** the result has `success === false`, `error` mentions the timeout
**Verified by:** `src/spawn-engine/spawn-engine.test.ts::"strips sensitive env keys"` AND `src/spawn-engine/spawn-engine.test.ts::"kills child on timeout"`
---
### Requirement: Runner script (spawn-engine child entry)
The package SHALL ship a child-process entry point (`runner.ts`) on a `./runner` subpath export. The runner reads JSON from stdin, evaluates the code in a strict-mode function scope (with `console` and modules injected), and writes a JSON `RunnerOutput` to stdout. It MUST capture `memoryUsed` via `process.memoryUsage().heapUsed` deltas and produce a usable `error` field for both `Error` and non-`Error` throws.
#### Scenario: runner handles a simple expression
- **WHEN** the runner is invoked over a pipe with `{ code: "1 + 1", context: {}, modules: [], maxOutputSize: 1024 }`
- **THEN** stdout contains a JSON envelope with `success: true`, `result: 2`
**Verified by:** `src/spawn-engine/spawn-engine.test.ts::"end-to-end runner pipeline returns 1+1"`
---
### Requirement: IsolateEngine (isolated-vm contract)
The package SHALL provide an `IsolateEngine` class implementing `SandboxEngine` over a structural `IsolateFactory` contract that mirrors the `isolated-vm` API (`createIsolate`, `createContextSync`, `compileScriptSync`, `runSync`, `getHeapStatisticsSync`, `dispose`). The structural type keeps the real `isolated-vm` peer dep optional. The engine MUST surface heap statistics via `getHeapStatistics()` and `null` after `destroy()`.
#### Scenario: returns null heap after destroy
- **WHEN** an `IsolateEngine` is destroyed and `getHeapStatistics()` is called
- **THEN** the return value is `null`
**Verified by:** `src/isolate-engine/isolate-engine.test.ts::"returns null heap after destroy"`
---
### Requirement: SandboxPool
The package SHALL provide a `SandboxPool` class that pre-warms up to `minSize` engines, lazily grows up to `maxSize`, recycles engines that exceed `maxExecutionsPerEngine`, and reclaims idle engines older than `idleTimeoutMs` down to `minSize` via a periodic cleanup. `execute` MUST surface `POOL_EXHAUSTED` via `success: false` when the pool cannot acquire. The pool MUST accept an injectable `Clock`.
#### Scenario: pool grows to maxSize then reports exhaustion
- **WHEN** a pool of `minSize: 1, maxSize: 2` has both engines acquired and a third caller invokes execute on a non-releasing engine
- **THEN** the third result has `success === false` and `error` references `POOL_EXHAUSTED`
#### Scenario: idle cleanup reclaims excess engines
- **WHEN** a pool grows above `minSize` and the clock advances past `idleTimeoutMs`
- **THEN** running the cleanup tick reduces `pool.size()` to `minSize`
**Verified by:** `src/pool/pool.test.ts::"reports POOL_EXHAUSTED when saturated"` AND `src/pool/pool.test.ts::"cleanup reclaims idle engines to minSize"`
---
### Requirement: ResultStreamer
The package SHALL provide a `ResultStreamer` class supporting `subscribe(listener) -> unsubscribe`, `pushLog`, `pushError`, `pushResult`, `complete(result)`, `flush`, `stop`, `destroy`. Chunks MUST be buffered up to `maxBufferSize` then flushed; an interval flush MUST run every `flushIntervalMs`. Calls to `pushChunk` after `complete()` MUST raise `SandboxError` with `STREAM_CLOSED`.
#### Scenario: push after complete throws STREAM_CLOSED
- **WHEN** a streamer is `complete()`d and `pushLog("late")` is called
- **THEN** the call throws `SandboxError` with `code === SANDBOX_CODES.STREAM_CLOSED`
#### Scenario: subscribe receives buffered chunks on flush
- **WHEN** a listener subscribes, `pushLog("a")` then `flush()` is called
- **THEN** the listener has received exactly one `{ type: "log", data: "a" }` chunk
**Verified by:** `src/result-streamer/result-streamer.test.ts::"raises STREAM_CLOSED after complete"` AND `src/result-streamer/result-streamer.test.ts::"flush emits buffered chunks to subscribers"`
---
### Requirement: Subpath exports per feature
The `package.json` `exports` map SHALL expose `.`, `./package.json`, `./types`, `./error-codes`, `./schemas`, `./telemetry`, `./audit-logger`, `./resource-limiter`, `./module-resolver`, `./code-transformer`, `./vm-engine`, `./worker-engine`, `./spawn-engine`, `./runner`, `./isolate-engine`, `./pool`, `./result-streamer`, `./sandbox`. Every subpath SHALL point at the feature's barrel.
#### Scenario: subpath count matches feature module count
- **WHEN** the `exports` map is enumerated (excluding `./package.json`)
- **THEN** the resulting key set contains every feature subpath listed above
**Verified by:** `src/sandbox/sandbox.test.ts::"exports every documented subpath"`
---

View File

@@ -0,0 +1,47 @@
# Tasks — enterprise-sandbox
1. Scaffold package skeleton (`package.json`, `vite.config.ts`,
`vitest.config.ts`, `tsconfig.lib.json`, `README.md`, `src/index.ts`).
2. Author `types/sandbox.types.ts` with `SandboxConfig`, `SandboxResult`,
`SandboxOptions`, `SandboxModule`, `ExecutionContext`, `AuditLogEntry`,
`AuditStatistics`, `Clock`, `ProcessSpawner`, `CommandResult`,
`DEFAULT_CONFIG`.
3. Author `error-codes/error-codes.ts` — `SANDBOX_CODES` via `defineCodes` +
`SandboxError` class. Sibling docs + tests.
4. Author `schemas/schemas.ts` — Zod schemas for config, options, module.
Sibling docs + tests.
5. Author `telemetry/telemetry.ts` — OTel counters + histogram + record fns.
Sibling docs + tests.
6. Author `audit-logger/audit-logger.ts` — SHA-256 hash, filter, statistics,
`onAuditLog`. Sibling docs + tests.
7. Author `resource-limiter/resource-limiter.ts` — sliding-window limiter with
injectable `Clock`, output truncation. Sibling docs + tests.
8. Author `module-resolver/module-resolver.ts` — denylist + allowlist + builtin
resolver. Sibling docs + tests.
9. Author `code-transformer/code-transformer.ts` — eleven built-in rules,
custom-rule slot, `wouldBlock`. Sibling docs + tests.
10. Author `vm-engine/vm-engine.ts` — real `node:vm` sandbox with frozen
intrinsics, captured console, heap stats. Sibling docs + tests.
11. Author `worker-engine/worker-engine.ts` — real `node:worker_threads`
sandbox, postMessage IPC, timeout via `worker.terminate()`. Sibling docs
+ tests.
12. Author `spawn-engine/spawn-engine.ts` + `runner.ts` — real
`node:child_process` runner with sanitised env, JSON IPC, timeout race.
Sibling docs + tests.
13. Author `isolate-engine/isolate-engine.ts` — structural `isolated-vm`
contract with `IsolateFactory` seam. Sibling docs + tests.
14. Author `pool/pool.ts` — pre-warmed pool with injectable `Clock`,
cleanup, recycle-on-N. Sibling docs + tests.
15. Author `result-streamer/result-streamer.ts` — subscribe / push / flush /
complete / destroy. Sibling docs + tests.
16. Author `sandbox/sandbox.ts` — `createSandbox` facade composing every
feature, engine selection, audit-log integration. Sibling docs + tests.
17. Wire `src/index.ts` barrel + every subpath export in `package.json`.
18. Run pre-publish gate: `bunx turbo lint typecheck test build --filter=@suaveplan/sandbox`
+ `bunx turbo test:coverage --filter=@suaveplan/sandbox` — all green at
100/100/100/100.
19. Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`.
20. Flip `@suaveplan/sandbox` row in `STATUS.md` from `–` to `✅`.
21. Fold `specs/sandbox/spec.md` into `openspec/specs/sandbox/spec.md`.
22. Archive `openspec/changes/enterprise-sandbox/` to
`openspec/archive/2026-05-25-enterprise-sandbox/`.