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 andsandbox.execute("2 + 3")resolves - THEN the resolved value has
success === true,result === 5,duration >= 0,logsis an array
Scenario: executeFunction wraps function source
- WHEN
sandbox.executeFunction((a, b) => a * b, [6, 7])resolves - THEN the resolved value has
success === trueandresult === 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
SandboxErrorwithcode === SANDBOX_CODES.BLOCKED_MODULE
Scenario: code transformer block
- WHEN
sandbox.execute("process.exit(1)")resolves - THEN the resolved value has
success === false,errorcontains"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
SandboxErrorwithcode === 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,recordDurationare 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: 2and the clock advances pastrateLimitWindow - 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
maxOutputSizebytes 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
SandboxErrorwithcode === SANDBOX_CODES.BLOCKED_MODULE
Scenario: builtin Math is auto-loaded when allowed
- WHEN
new ModuleResolver(["Math"])is constructed - THEN
getModules()returns a module namedMathwhose exports includeMath.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 andengine.execute("1 + 1")is awaited - THEN the result has
success === trueandresult === 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,erroris non-empty (containstimed outorScript execution timed out)
Scenario: vm engine console capture
- WHEN
engine.execute("console.log('hello'); 1")is awaited - THEN the result has
logscontaining"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,errorreferences 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(orHOME/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,errormentions 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
IsolateEngineis destroyed andgetHeapStatistics()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: 2has both engines acquired and a third caller invokes execute on a non-releasing engine - THEN the third result has
success === falseanderrorreferencesPOOL_EXHAUSTED
Scenario: idle cleanup reclaims excess engines
- WHEN a pool grows above
minSizeand the clock advances pastidleTimeoutMs - THEN running the cleanup tick reduces
pool.size()tominSize
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 andpushLog("late")is called - THEN the call throws
SandboxErrorwithcode === SANDBOX_CODES.STREAM_CLOSED
Scenario: subscribe receives buffered chunks on flush
- WHEN a listener subscribes,
pushLog("a")thenflush()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
exportsmap 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"