Files
2026-05-27 23:29:53 +01:00

52 lines
3.9 KiB
Markdown

# `@suaveplan/api-sandbox-vm` — `vm.Context`-based sandbox host
**Package**: `@suaveplan/api-sandbox-vm`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 8 — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Sandboxed-tier plugins that need a JavaScript runtime (no WASM build pipeline) require an in-process execution surface that does NOT give them ambient access to the host's globals. Node's `vm.Context` with frozen globals — plus the worker-thread bulkhead — is the path of least resistance and is well-trodden for trusted-but-isolated JS plugins. This package owns that runtime; it does not own the permission model (that's `api-sandbox-permissions`) and does not own worker spawning (that's `api-worker-host`). It composes both.
Per design doc §2.2: sandboxed tier = worker thread + `vm.Context` guard + permission-gated fs/fetch proxies + CPU/memory/wall-clock budgets. `api-sandbox-vm` is the `vm.Context` half.
## What changes
- ADDED: `createVmSandbox({ pluginModulePath, policy, proxies })` — builds a `vm.Context` with frozen globals (no dynamic-code constructors, no `eval`, no `globalThis.process`, no `import.meta` to outside the sandbox), loads the plugin module via `vm.SourceTextModule`, returns a `VmSandboxHandle` exposing `start()`, `dispose()`, and the capability-proxy receiver.
- ADDED: `FrozenGlobals` — the curated allowlist of globals the sandbox sees (`URL`, `URLSearchParams`, `TextEncoder`, `TextDecoder`, `structuredClone`, `console` (logger-routed), `crypto` (subset)). Everything else absent.
- ADDED: `installCapabilityProxies(sandbox, proxies)` — wires the capability proxy surface into the sandbox using `vm.Context`-safe stub objects. Every call routed through `api-sandbox-permissions` `PermissionChecker` before reaching the proxy.
- ADDED: `installFsProxy(sandbox, policy)` and `installFetchProxy(sandbox, policy)` — host-proxied filesystem and outbound HTTP. Denials raise `PERMISSION_DENIED` inside the sandbox.
- ADDED: `runVmSandboxConformance(label, makeSandbox)` (subpath `./testing`) — the conformance suite every sandbox host must pass; in this package it pins the contract for vm-tier specifics (frozen globals, dynamic-code-constructor rejection, indirect-eval rejection).
- ADDED: Error codes (`SAND_VM_*`): `EVAL_REJECTED`, `INDIRECT_EVAL_REJECTED`, `MODULE_IMPORT_FORBIDDEN`, `SOURCE_TEXT_MODULE_FAILED`.
## Out of scope
- Permission policy resolution (that's `api-sandbox-permissions`).
- Worker spawning and lifecycle (that's `api-worker-host`).
- WASM runtimes (that's `api-sandbox-wasm`).
- Capability adapters themselves; this package only routes proxy traffic. The proxies are constructed by the host.
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| `vm.Context` with frozen globals | §2.2 |
| No dynamic-code constructors | §2.2 |
| fs/fetch via host proxies only | §2.2, §5.2b |
| Permission checks before proxy call | §5.2b |
## Verification approach
- `src/sandbox/sandbox.test.ts` — builds a sandbox, confirms frozen globals enumerable list, confirms dynamic-code constructors and indirect-eval (`(0, eval)("...")`) all throw `SAND_VM_*` errors.
- `src/sandbox/sandbox.spec-conformance.test.ts` — pins observability: every denial emits `plugin.permission_denied_total` (via `api-sandbox-permissions`) AND surfaces as `PERMISSION_DENIED` inside the sandbox.
- `src/proxies/fs-proxy.test.ts`, `src/proxies/fetch-proxy.test.ts` — allowed-path / denied-path matrices using a real `PermissionPolicy`.
- `src/testing/run-vm-conformance.test.ts` — self-test of the exported harness against the in-package sandbox.
- `tests/dep-graph.test.ts` — pins runtime deps (`api-types`, `api-sandbox-permissions`, `error`, `logger`).
## Migration impact
None — additive only. `api-host` registers this as the default sandbox implementation when `host.json` `defaultSandbox = "vm"`.