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

133 lines
7.7 KiB
Markdown

# Proposal — Migrate @suaveplan/sandbox to genesis
- **Design**: ./design.md
- **Spec delta**: ./specs/sandbox/spec.md
## Why
Upstream `@suaveplan/sandbox` (`/mnt/s/web/suaveplan/packages/server/sandbox/`) ships a
working server-tier sandboxed execution environment built around `Bun.spawn` for
process-isolated execution, an `IsolateEngine` contract for isolated-vm, a
pre-warmed `SandboxPool`, an async `ResultStreamer`, a pattern-based
`CodeTransformer`, an allowlist-based `ModuleResolver`, a sliding-window
`ResourceLimiter`, and a SHA-256 `AuditLogger`. The genesis `enterprise/sandbox.md`
audit calls out two cross-cutting gaps shared with every other migration: (a) no
error-code registry — every failure path raises plain `Error` instances so
consumers cannot discriminate programmatically, and (b) no OpenTelemetry surface
even though sandbox execution is a load-bearing primitive in any agent or
plugin host. The upstream also hard-codes a single transport (Bun.spawn pinned
to `process.argv[0]`) with no seam for testing without an actual child process,
and it lacks Zod-validated configuration on the public boundary.
Genesis cleans up the contract: every failure raises a typed `SandboxError`
with a stable `SANDBOX.*` code, the engine surface gains a runner-tier abstraction
that defaults to real `node:vm` (universally available, no Bun dependency) and
real `node:worker_threads` for true isolation when requested, `Bun.spawn` is
retained behind the same `SandboxEngine` contract for environments that prefer
process boundaries, Zod schemas guard the public surface, and OTel counters +
histograms tag every execution by engine, outcome, and phase.
## What
Land `@suaveplan/sandbox@0.1.0` at `packages/devtools/tooling/sandbox/` with these
feature modules (one folder per feature, sibling `.md` per implementation file):
- `types/` — shared `SandboxConfig`, `SandboxResult`, `SandboxOptions`,
`SandboxModule`, `ExecutionContext`, `AuditLogEntry`, `AuditStatistics`,
`Clock`, `ProcessSpawner`, `CommandResult` types.
- `error-codes/` — `SANDBOX_CODES` via `defineCodes("SANDBOX", [...])` +
`SandboxError` class.
- `schemas/` — Zod schemas for `SandboxConfig`, `SandboxOptions`,
`SandboxModule` boundaries.
- `telemetry/` — OTel counters (`sandbox_executions_total`,
`sandbox_rule_blocks_total`, `sandbox_pool_acquisitions_total`) and a
histogram (`sandbox_execution_duration_ms`) tagged by engine + outcome.
- `audit-logger/` — `AuditLogger` with SHA-256 code hashing, search filters,
statistics aggregation, and `onAuditLog` callback hook.
- `resource-limiter/` — `ResourceLimiter` with sliding-window rate limiting,
output-size truncation, injectable `Clock` for deterministic tests.
- `module-resolver/` — `ModuleResolver` with `BLOCKED_MODULES` denylist,
`BUILTIN_SAFE_MODULES` (`Math`/`JSON`/`Date`), custom module registry.
- `code-transformer/` — `CodeTransformer` with the eleven `BUILTIN_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,
max-code-length) plus an injectable custom-rule slot.
- `vm-engine/` — Real-`node:vm`-backed `SandboxEngine` (default). Frozen-Realm
pattern via `vm.createContext(...)`, `vm.Script` compile +
`runInContext({ timeout })`, captured console with output-size limit,
module + context injection, heap snapshotting via `v8.getHeapStatistics()`.
- `worker-engine/` — Real-`node:worker_threads`-backed `SandboxEngine`. Spawns
an inline `Worker` (`eval: true`) with code + context + modules sent via
`postMessage`, captures result via the message channel, enforces timeout by
terminating the worker.
- `spawn-engine/` — Real-`node:child_process`-backed runner equivalent to
upstream's `bun-spawn-runner` but using `node:child_process.spawn` so it
works under both Bun and Node. Pinned to current `process.execPath`,
sanitised env (`SECRET`/`KEY`/`TOKEN`/`PASSWORD`/`CREDENTIAL`/`AUTH`
patterns + `HOME`/`USER`/`SHELL`/`LOGNAME` stripped), structured JSON IPC
via stdin/stdout, timeout via `Promise.race`.
- `runner/` — Child-process entry point invoked by `spawn-engine`. Reads JSON
from stdin, evaluates code via a strict-mode dynamic wrapper, writes JSON
result to stdout. Ships as its own subpath export.
- `isolate-engine/` — Pluggable `IsolateEngine` over the upstream's
isolated-vm contract (kept as a structural interface so the optional peer
dep stays optional).
- `pool/` — `SandboxPool` of pre-warmed engines, idle reclamation,
recycle-on-N-executions, statistics surface.
- `result-streamer/` — `ResultStreamer` with subscribe/unsubscribe, push
chunks (`log`/`result`/`error`/`complete`), buffered + interval-flushed
delivery, completion gating.
- `sandbox/` — `createSandbox(config?)` facade composing module resolver,
resource limiter, audit logger, code transformer, and an engine selection.
`Sandbox.execute` / `executeFunction` returns the structured `SandboxResult`.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `createSandbox(config)` facade | ✅ | ✅ | Same surface, Zod-validated config. |
| `Sandbox.execute(code, context, opts)` | ✅ | ✅ | |
| `Sandbox.executeFunction(fn, args, opts)` | ✅ | ✅ | |
| `Sandbox.registerModule` / `unregisterModule` | ✅ | ✅ | |
| `Sandbox.getAuditLog` / `getStatistics` / `reset` | ✅ | ✅ | |
| `Bun.spawn` process engine | ✅ | ⬆️ | Replaced with portable `node:child_process` `spawn` (works under Bun + Node). Same isolation guarantees. |
| Frozen-Realm VM engine | 🚫 | ⬆️ | New default — `node:vm` `createContext` + `runInContext({ timeout })`. |
| `node:worker_threads` engine | 🚫 | ⬆️ | New — true OS-thread isolation for stronger CPU caps. |
| `IsolateEngine` (isolated-vm contract) | ✅ | ✅ | Preserved as structural interface; tests use a real fake factory. |
| `SandboxPool` (warm + idle reclaim + recycle) | ✅ | ✅ | |
| `ResultStreamer` (subscribe/buffer/flush) | ✅ | ✅ | |
| `CodeTransformer` (10 built-in rules + max-code-length) | ✅ | ✅ | |
| `ModuleResolver` (blocked/allowed + builtins) | ✅ | ✅ | |
| `ResourceLimiter` (sliding-window rate limit + output trunc) | ✅ | ⬆️ | Injectable `Clock` for deterministic tests. |
| `AuditLogger` (SHA-256 hash + filter + stats) | ✅ | ✅ | |
| `BUILTIN_RULES` export | ✅ | ✅ | |
| `DEFAULT_CONFIG` export | ✅ | ✅ | |
| Error-code registry | 🚫 | ⬆️ | Added — `SANDBOX.*` codes via `defineCodes`. |
| OTel self-telemetry | 🚫 | ⬆️ | Added — counters + histograms. |
| Zod-validated config | 🚫 | ⬆️ | Added — `SandboxConfigSchema`, etc. |
| Subpath exports per feature | 🚫 | ⬆️ | Added — every feature module is its own subpath. |
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/sandbox` to enumerate. Net-new on
genesis — no current dependents.
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/sandbox`).
## Risks
- **`node:vm` is not a security boundary.** `vm.runInContext` shares the host
realm; a determined attacker with code-injection can escape via prototype
chain tricks. The package documents this explicitly: `vm-engine` is for
*isolation against accidents*, `worker-engine` and `spawn-engine` are for
isolation against adversaries.
- **Timeouts under `vm.runInContext` only cover synchronous code.** A microtask
scheduled inside the context outlives the timeout. The package documents
this and recommends `worker-engine` for code that awaits.
- **`node:worker_threads` `eval: true` accepts a string.** This is the
worker's documented contract for running ad-hoc code. The boundary is the
worker thread, not the string parser.