7.0 KiB
Design — @suaveplan/sandbox
File layout
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:
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:
vm.createContext({})withcodeGeneration: { strings: false, wasm: false }to preventeval/Functionconstructor inside the script.Object.freezeon the context'sObject.prototype,Function.prototype,Array.prototype, and the context'sObjectconstructor itself.- Injecting
consoleas a frozen proxy that delegates only to the captured log array. 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
ResourceLimiterandSandboxPoolaccept aClock(() => number) defaulting toDate.now. Tests inject aFakeClockto assert sliding-window- idle-timeout behaviour without
setTimeoutflake.
- idle-timeout behaviour without
SpawnEngineaccepts aProcessSpawnerdefaulting tochild_process.spawn. Tests use the real spawner against a temp-filerunner.tsfixture so the whole pipeline (stdin write, stdout read, timeout kill, JSON parse) is exercised end-to-end.WorkerEngineuses realnode:worker_threads. No stubbing — workers run inline strings through real V8.IsolateEngineconsumes a structuralIsolateFactoryso tests can wire a hand-written fake that implements the same contract a realisolated-vminstance 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 aSandboxModule.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—CodeTransformerblocked execution.CODE_TOO_LONG— code exceededmaxCodeLength.ENGINE_DISPOSED— caller invoked execute afterdestroy().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 atmaxSizewith no available engines.STREAM_CLOSED—pushChunkaftercomplete().
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.