Files
suaveplan/openspec/archive/2026-05-25-enterprise-sandbox/design.md
2026-05-27 23:29:53 +01:00

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:

  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.