Files
2026-05-27 23:29:53 +01:00

16 KiB

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"