Initial Commit
This commit is contained in:
187
openspec/archive/2026-05-25-enterprise-sandbox/design.md
Normal file
187
openspec/archive/2026-05-25-enterprise-sandbox/design.md
Normal 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.
|
||||
Reference in New Issue
Block a user