Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
# Error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable `SAND_VM.*` error-code registry used by every
public failure path inside the `@suaveplan/api-sandbox-vm` package. The
registry is built once via `defineCodes("SAND_VM", […])` so the
namespace prefix and per-code suffix are co-derived — that guarantee
lets the host's lifecycle driver, the audit sink, and the conformance
harness branch on literal code strings without ever decoding free-form
error messages.
Side-effect-importing this module from `src/index.ts` ensures every
consumer that imports the package barrel (or any subpath export)
participates in the same code registration, which is what
`scripts/conformance.ts` validates.
## Features
- Five stable codes (`EVAL_REJECTED`, `INDIRECT_EVAL_REJECTED`,
`MODULE_IMPORT_FORBIDDEN`, `SOURCE_TEXT_MODULE_FAILED`,
`PERMISSION_DENIED`) — every failure surface in the package maps to
exactly one.
- A typed `SandboxVmError` subclass of `SuaveplanError` so consumers
may discriminate via `instanceof` or by `code` string.
- Wire-stable `code` string: the namespaced form (e.g.
`"SAND_VM.EVAL_REJECTED"`) is the value used in OTel attributes,
audit logs, and IPC envelopes back to the host.
## Usage: Basic
```ts
import {
SAND_VM_CODES,
SandboxVmError,
} from "@suaveplan/api-sandbox-vm/error-codes/error-codes";
throw new SandboxVmError(
SAND_VM_CODES.EVAL_REJECTED,
"dynamic code evaluation is disabled in the vm sandbox"
);
```
## Usage: Advanced
Use the `context` option to attach a redaction-safe payload that flows
through to OTel span attributes and the lifecycle driver's crash
report:
```ts
import {
SAND_VM_CODES,
SandboxVmError,
} from "@suaveplan/api-sandbox-vm/error-codes/error-codes";
function denyOutboundFetch(host: string, reason: string): never {
throw new SandboxVmError(
SAND_VM_CODES.PERMISSION_DENIED,
`outbound HTTP to ${host} denied by policy`,
{
context: { capability: "fetch", host, reason },
}
);
}
```
## API Reference
- `SAND_VM_CODES` — frozen record mapping enum key (e.g.
`EVAL_REJECTED`) to the namespaced string code
(`"SAND_VM.EVAL_REJECTED"`).
- `type SandboxVmCode` — the literal union of every value in
`SAND_VM_CODES`.
- `class SandboxVmError extends SuaveplanError<SandboxVmCode>` —
constructor takes `(code, message, options?)`. `options.context` is
forwarded through the base class redaction pipeline.
## Implementation Notes
The registry is declared `as const` so the compiler retains literal
types (essential for exhaustive `switch` narrowing in consumers). The
class sets `this.name = "SandboxVmError"` so heap snapshots, sourcemaps
and uncaught-exception handlers attribute failures to this package
rather than the generic `SuaveplanError` super-class. Registry
ordering reflects severity in roughly increasing order — the first two
entries reflect deliberate plugin policy violations, the next two
reflect host-environment misconfiguration, and the last reflects
permission-policy denials that fan in from `api-sandbox-permissions`.

View File

@@ -0,0 +1,95 @@
# Frozen globals
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module assembles the curated global allowlist visible inside the
`vm.Context` sandbox. Every binding installed here is intentional —
the design philosophy is fail-closed: anything not in the bundle is
absent, so sandboxed code raises `ReferenceError` when it reaches for
an unanticipated global. The narrow surface keeps the auditable
attack surface of the sandboxed tier tiny and makes the security
review a one-page exercise rather than a tour of Node's runtime.
The allowlist is mandated by the design doc §2.2 and `api-sandbox-vm`
spec ADDED requirement "Sandbox boots inside a frozen `vm.Context`".
It MUST stay synchronised with that spec; adding or removing an entry
requires an OpenSpec change.
## Features
- Five spec-mandated globals (`URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `structuredClone`) installed by reference so they
share an identity with the host's intrinsics.
- A logger-routed `console` whose five methods (`log`, `info`, `warn`,
`error`, `debug`) forward through the host's `LoggerLike` instance.
No raw stdio handle is exposed to sandboxed code.
- A trimmed `crypto` surface that exposes only `getRandomValues` and
`randomUUID`. The full `SubtleCrypto` API is intentionally withheld
because most sandboxed plugins should request the `crypto`
capability rather than reach for the ambient global.
- `FROZEN_GLOBAL_KEYS` — the literal key set, exposed so the
conformance suite asserts the surface is exactly what the spec
mandates and nothing accidentally leaked.
## Usage: Basic
```ts
import { buildFrozenGlobals } from "@suaveplan/api-sandbox-vm/globals/frozen-globals/frozen-globals";
const bundle = buildFrozenGlobals({
logger: console as never, // any LoggerLike
});
bundle.console.info("hello from inside the sandbox");
bundle.crypto.randomUUID();
```
## Usage: Advanced
Combine with a custom logger that tags every entry with the plugin id:
```ts
import { buildFrozenGlobals } from "@suaveplan/api-sandbox-vm/globals/frozen-globals/frozen-globals";
import type { LoggerLike } from "@suaveplan/types";
function pluginLogger(base: LoggerLike, pluginId: string): LoggerLike {
return {
debug: (m, ctx) => base.debug(m, { ...ctx, pluginId }),
info: (m, ctx) => base.info(m, { ...ctx, pluginId }),
warn: (m, ctx) => base.warn(m, { ...ctx, pluginId }),
error: (m, ctx) => base.error(m, { ...ctx, pluginId }),
};
}
const bundle = buildFrozenGlobals({
logger: pluginLogger(realLogger, "demo-plugin"),
});
```
## API Reference
- `buildFrozenGlobals(options): FrozenGlobalsBundle` — produce the
frozen allowlist for one sandbox instance.
- `interface FrozenGlobalsBundle` — typed bundle with `URL`,
`URLSearchParams`, `TextEncoder`, `TextDecoder`, `structuredClone`,
`console`, `crypto`.
- `interface SandboxConsole` — the five-method console surface.
- `interface SandboxCrypto` — the two-method RNG surface.
- `FROZEN_GLOBAL_KEYS` — the canonical key list (used by tests).
## Implementation Notes
The five spec-mandated globals are exported by reference rather than
by clone. This is safe because `vm.Context.runInContext` does not let
the sandbox mutate the prototype of an intrinsic on the host side
(modifications inside the sandbox apply to the sandbox's own
intrinsics chain). The `crypto.getRandomValues` shim is a thin pass
through `node:crypto.webcrypto.getRandomValues` so the sandbox sees
the same RNG quality as the host — handcrafting a polyfill would be
strictly worse than delegating. The `randomUUID` shim delegates
identically so its v4 properties are preserved. The two intrinsics
are themselves wrapped in a frozen object so the sandbox cannot
overwrite `crypto.randomUUID` and observe a different RNG.

View File

@@ -0,0 +1,96 @@
# OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instrument bundle emitted by the
`vm.Context` sandbox host. Instruments are bound lazily against the
current global `MeterProvider` so packages downstream may install a
real exporter (Prometheus, OTLP, in-memory test reader) and observe
the bundle without restarting the process. The bundle is cached after
first build so the hot proxy path never reaches the meter registry
twice.
## Features
- Four named instruments: a boot histogram, two refusal counters
(`eval_rejected_total`, `module_import_rejected_total`), and the
shared `plugin.permission_denied_total` counter whose name matches
`api-sandbox-permissions` so dashboards aggregate cleanly.
- Lazy binding via `getSandboxVmInstruments()` so tests may install a
fresh `MeterProvider` and then call `resetSandboxVmInstruments()` to
drop the cache.
- All metric names are namespaced under `sandbox_vm.*` (except the
cross-package denial counter), making them easy to filter at the
collector.
## Usage: Basic
```ts
import { getSandboxVmInstruments } from "@suaveplan/api-sandbox-vm/otel/instruments";
const instruments = getSandboxVmInstruments();
instruments.evalRejectedTotal.add(1, { kind: "direct" });
```
## Usage: Advanced
Install a real in-memory provider for assertions:
```ts
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import {
getSandboxVmInstruments,
resetSandboxVmInstruments,
} from "@suaveplan/api-sandbox-vm/otel/instruments";
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 1_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetSandboxVmInstruments();
const instruments = getSandboxVmInstruments();
instruments.permissionDeniedTotal.add(1, {
plugin: "demo",
capability: "fetch",
});
await reader.forceFlush();
const collected = exporter.getMetrics();
```
## API Reference
- `getSandboxVmInstruments(): SandboxVmInstruments` — return the
lazily-built instrument bundle. Calling multiple times returns the
same frozen reference until `resetSandboxVmInstruments()` is invoked.
- `resetSandboxVmInstruments(): void` — drop the cache; the next
`getSandboxVmInstruments()` call re-binds against the (possibly new)
global `MeterProvider`.
- `interface SandboxVmInstruments` — fully-typed instrument bundle.
## Implementation Notes
The module never reads any environment variable at import time and
never side-effects the global `MeterProvider`. Caching uses a
module-private `let` rather than a `WeakMap` because the lifecycle of
the bundle is naturally process-scoped (there is no second
`MeterProvider` instance to dispatch to unless the consumer resets the
cache deliberately). The `permission_denied_total` counter
intentionally uses the same name as the one in
`api-sandbox-permissions`; both packages contribute to the same OTLP
series so the consumer sees one denial total regardless of whether the
decision was made in this package's default checker or in the richer
implementation.

View File

@@ -0,0 +1,106 @@
# Permission checker (in-package default)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createPermissionChecker` compiles a `PermissionsManifest`
(from `@suaveplan/api-types`) into a structural `PermissionChecker` that
the `vm.Context` sandbox host consults before forwarding any
filesystem, outbound-HTTP, or capability call to the host. The default
implementation lives here so the package ships functionally complete
even before the full `@suaveplan/api-sandbox-permissions` package
arrives .
This module exists to make policy enforcement a first-class operation
of the sandbox host without depending on a heavier policy engine.
Callers that need budget tracking or per-capability payload gating
construct a stricter checker and inject it through
`createVmSandbox({ checker })`.
## Features
- Glob-compiled allowlists for `permissions.fs[]` (path globs with
`*`, `**`, `?`) and `permissions.fetch[]` (exact host or `*.suffix`
pattern).
- Path normalisation rejects `..`-traversal before matching so
`"/srv/data/../../etc/passwd"` is matched as `"/etc/passwd"`, not
fooled into `"/srv/data/passwd"`.
- Returns frozen `PermissionDecision` objects so the proxy hot path
never accidentally mutates the policy answer.
- All decisions are synchronous; the policy answer never performs IO.
## Usage: Basic
```ts
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
const checker = createPermissionChecker({
policy: {
fs: ["/srv/data/**"],
fetch: ["api.example.com", "*.cdn.example.com"],
},
});
checker.checkFs("/srv/data/users.json", "read").allow; // true
checker.checkFetch("https://api.example.com/v1/users").allow; // true
checker.checkFetch("https://evil.example.org/leak").allow; // false
```
## Usage: Advanced
Compose with a host-supplied audit sink so every denial is observable:
```ts
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
import type { PermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
function withAudit(
inner: PermissionChecker,
audit: (capability: string, reason: string) => void
): PermissionChecker {
return {
checkFs(path, mode) {
const d = inner.checkFs(path, mode);
if (!d.allow) audit(`fs.${mode}`, d.reason);
return d;
},
checkFetch(url) {
const d = inner.checkFetch(url);
if (!d.allow) audit("fetch", d.reason);
return d;
},
checkCapability(id, payload) {
const d = inner.checkCapability(id, payload);
if (!d.allow) audit(id, d.reason);
return d;
},
};
}
```
## API Reference
- `createPermissionChecker(options)` — build a checker from a
`PermissionsManifest`. `options.policy` is the manifest block exactly
as declared in `plugin.json`.
- `PermissionChecker` — structural type with synchronous `checkFs`,
`checkFetch`, `checkCapability` methods.
- `PermissionDecision` — discriminated union of `{ allow: true }` or
`{ allow: false; reason: string }`.
## Implementation Notes
The glob compiler escapes regex meta-characters first, then performs
the `**` → `.*`, `*` → `[^/]*`, `?` → `[^/]` substitution in three
passes via a sentinel token to keep the ordering safe (replacing `**`
to `.*` before `*` to `[^/]*` would otherwise mis-translate `**`).
Host patterns use a string-suffix match rather than a regex to keep
the hot path branch-predictable. The `checkCapability` default is
permissive precisely because the full payload-shape contract is owned
by `api-sandbox-permissions`; hosts SHOULD compose a stricter checker
once that package ships. The in-package fallback still rejects an
empty capability id so that an obviously-malformed call fails closed
rather than silently succeeding.

View File

@@ -0,0 +1,98 @@
# fetch proxy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`installFetchProxy` returns the outbound-HTTP surface visible inside
the sandbox. Every request gates through the supplied
`PermissionChecker` before reaching the host adapter, and denied calls
never touch the network — they raise `SAND_VM.PERMISSION_DENIED` and
increment `plugin.permission_denied_total{capability="fetch"}` without
opening a socket, performing a DNS lookup, or hitting the host's
connection pool.
The proxy intentionally exposes only a `request` method with a narrow
shape (`url`, optional `init`) so the sandboxed plugin cannot reach
ambient Node primitives like the connection pool, the DNS resolver,
the timeout default, or the raw streaming body. Anything richer is
the responsibility of an explicit capability (and arrives through
`installCapabilityProxies`).
## Features
- Synchronous permission check; the host adapter call is deferred
until allowance is confirmed.
- Closure-bound adapter — the sandbox cannot pry it back out of the
proxy receiver.
- Denial path records the `plugin` and `capability` attributes on the
shared `plugin.permission_denied_total` counter so dashboards see a
uniform denial-rate series across permission implementations.
- `URL`-parsed host match — the proxy receives the URL as a string and
the checker normalises it through `new URL(...).hostname`; an
unparseable URL is rejected by the checker before reaching the host.
## Usage: Basic
```ts
import { installFetchProxy } from "@suaveplan/api-sandbox-vm/proxies/fetch-proxy/fetch-proxy";
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
const checker = createPermissionChecker({
policy: { fetch: ["api.example.com"] },
});
const fetch = installFetchProxy({
checker,
pluginId: "demo",
adapter: { request: async (url) => realRequest(url) },
});
const res = await fetch.request("https://api.example.com/v1/users");
```
## Usage: Advanced
Wrap the adapter to enforce a per-tenant header before reaching the
upstream:
```ts
import { installFetchProxy } from "@suaveplan/api-sandbox-vm/proxies/fetch-proxy/fetch-proxy";
const adapter = {
request: async (
url: string,
init?: { readonly method?: string; readonly body?: string }
) => {
const merged = {
...init,
method: init?.method ?? "GET",
};
return realRequest(url, {
...merged,
// host-side adapter is free to attach additional headers
// that the sandbox cannot see or override.
});
},
};
const fetch = installFetchProxy({ checker, adapter, pluginId: "demo" });
```
## API Reference
- `installFetchProxy(options): FetchProxy` — build the proxy.
- `interface FetchProxy` — frozen receiver with `request(url, init?)`.
- `interface FetchHostAdapter` — the host-supplied counterpart that
performs the actual outbound HTTP request.
## Implementation Notes
The proxy emits the denial metric with a fixed two-attribute label set
(`plugin`, `capability`) so cardinality stays bounded. The
`reason` from the checker decision is folded into the error
`context` rather than into the metric attribute set so the metric
remains aggregatable. Adapter-side errors (network timeouts, host
unreachable, TLS failures) bubble up through the proxy unchanged; the
proxy only owns the policy boundary, not the transport-level error
mapping.

View File

@@ -0,0 +1,100 @@
# fs proxy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`installFsProxy` returns the sandbox-side filesystem surface that
plugins consume. Every method gates the call through the supplied
`PermissionChecker` before delegating to the opaque host adapter, so a
denied call never reaches the host's `fs/promises` layer. The proxy is
closure-bound: the adapter reference is reachable only through the
returned methods, so the sandbox cannot retrieve the adapter from the
proxy receiver and bypass the permission gate.
This module owns the sandbox-side half of the `fs` capability surface;
the adapter on the other side of the closure is supplied by `api-host`
and performs the real `node:fs/promises` calls after the policy decision
has been confirmed.
## Features
- Permission-checked `readFile` and `writeFile` with policy decisions
computed synchronously and the host call deferred until allowance is
confirmed.
- Denials raise `SAND_VM.PERMISSION_DENIED` immediately
(`Promise.reject`) and increment the
`plugin.permission_denied_total` counter with
`{ plugin, capability: "fs", mode }` attributes — identical labels
to the metric emitted by `api-sandbox-permissions` so the host's
dashboard aggregates across implementations.
- Frozen receiver: the returned object is `Object.freeze`d and the
adapter reference is not exposed.
## Usage: Basic
```ts
import { installFsProxy } from "@suaveplan/api-sandbox-vm/proxies/fs-proxy/fs-proxy";
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
const checker = createPermissionChecker({
policy: { fs: ["/srv/data/**"] },
});
const fs = installFsProxy({
checker,
pluginId: "demo",
adapter: {
readFile: (p) => realReadFile(p),
writeFile: (p, d) => realWriteFile(p, d),
},
});
await fs.readFile("/srv/data/users.json");
```
## Usage: Advanced
Wrap the adapter to record host-side latency without polluting the
proxy contract:
```ts
import { installFsProxy } from "@suaveplan/api-sandbox-vm/proxies/fs-proxy/fs-proxy";
import { hrtime } from "node:process";
const adapter = {
readFile: async (p: string) => {
const start = hrtime.bigint();
try {
return await realReadFile(p);
} finally {
const elapsed = Number(hrtime.bigint() - start) / 1e6;
telemetry.recordFsLatency("read", elapsed);
}
},
writeFile: (p, d) => realWriteFile(p, d),
};
const fs = installFsProxy({ checker, adapter, pluginId: "demo" });
```
## API Reference
- `installFsProxy(options): FsProxy` — build the proxy.
`options.checker`, `options.adapter`, `options.pluginId` are all
required.
- `interface FsProxy` — frozen receiver with `readFile`, `writeFile`.
- `interface FsHostAdapter` — the host-supplied counterpart that
actually performs the IO.
## Implementation Notes
The proxy is the only place where sandbox-side `fs` calls cross the
permission boundary. The adapter MUST never be exposed back to the
sandbox; the closure-binding guarantees that property at the language
level. Denials are accounted for with the same metric name as the
sister `api-sandbox-permissions` package emits, so dashboards see one
denial-rate series rather than two. The `mode` attribute on the
counter is `"read"` or `"write"` — keeping it on the metric (rather
than in a separate counter) means cardinality remains bounded to two
values regardless of the path being requested.

View File

@@ -0,0 +1,99 @@
# Install capability proxies
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`installCapabilityProxies` wires the host-supplied capability bags
into the sandbox's `vm.Context`. The sandbox sees one consolidated
`__suaveplan` global containing `fs`, `fetch`, and a `capabilities`
map; every method on the map is wrapped so the permission checker is
consulted before the host adapter is reached. The receiver and every
nested bag are frozen so a sandboxed plugin cannot replace a
permission-gated method with an open one.
This module is the single source of truth for how capabilities cross
the sandbox-host boundary — both the fs/fetch proxies and the wider
capability surface enter through this entry point so the permission
gate is applied uniformly.
## Features
- One `__suaveplan` global with `fs`, `fetch`, `capabilities` —
installed as non-writable, non-configurable, enumerable so the
sandbox can read it but never replace it.
- Permission gating on every capability method: the checker decides
before the host adapter is called.
- Denials emit `plugin.permission_denied_total{plugin, capability}`
with stable label cardinality (no per-method label).
- Frozen receivers prevent monkey-patching from inside the sandbox.
## Usage: Basic
```ts
import { installCapabilityProxies } from "@suaveplan/api-sandbox-vm/proxies/install-capability-proxies/install-capability-proxies";
import { createContext } from "node:vm";
const context = createContext({});
installCapabilityProxies({
context,
checker,
fs,
fetch,
pluginId: "demo",
capabilities: {
db: {
query: async (sql: unknown) => realQuery(String(sql)),
},
},
});
```
## Usage: Advanced
Compose with a host-side audit sink so every denial is reproducible:
```ts
import { installCapabilityProxies } from "@suaveplan/api-sandbox-vm/proxies/install-capability-proxies/install-capability-proxies";
const wrapAudit = (checker: PermissionChecker, audit: AuditSink) => ({
...checker,
checkCapability(id: string, p: Record<string, unknown>) {
const d = checker.checkCapability(id, p);
if (!d.allow) audit.record({ capability: id, reason: d.reason });
return d;
},
});
installCapabilityProxies({
context,
checker: wrapAudit(checker, audit),
fs,
fetch,
capabilities,
pluginId: "demo",
});
```
## API Reference
- `installCapabilityProxies(options)` — wire the proxy surface into
`options.context`. Returns nothing; side-effects the context.
- `interface InstallCapabilityProxiesOptions` — `context`, `checker`,
`capabilities`, `fs`, `fetch`, `pluginId`.
- `type CapabilityMap` — frozen record of capability id → method bag.
- `type CapabilityBag` — frozen record of method name → async fn.
## Implementation Notes
The receiver is installed via `Object.defineProperty` with
`writable: false, configurable: false` so the sandbox cannot replace
or delete `__suaveplan`. Each method wrapper closes over the host
function reference; the closure is the only path to the host call, so
patching the wrapper from inside the sandbox would not bypass the
permission gate (it would only break the path that exists). The
`method` argument is not added to the OTel attribute set so
cardinality stays bounded; method-level granularity belongs in logs,
not metrics.

View File

@@ -0,0 +1,127 @@
# vm.Context sandbox
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createVmSandbox` is the keystone entry point of the package. It
materialises a `vm.Context` whose `globalThis` is sealed and frozen,
exposes only the curated `FrozenGlobals` allowlist, traps every
dynamic-code constructor, wires the host-installed permission-gated
fs/fetch/capability proxies into a single `__suaveplan` global, and
returns a `VmSandboxHandle` driving the sandboxed plugin lifecycle.
The handle is intentionally minimal: one `start()` to evaluate the
plugin module (via `vm.SourceTextModule` with a refuse-everything
linker), one `dispose()` to release the context. Both are idempotent
under their normal usage; `dispose()` in particular is explicitly safe
to call multiple times so the outer lifecycle driver may reach the
same terminal state from any failure path without branching.
## Features
- Curated allowlist (`URL`, `URLSearchParams`, `TextEncoder`,
`TextDecoder`, `structuredClone`, logger-routed `console`, trimmed
`crypto`) installed via `Object.defineProperty` so the surface is
exactly what the spec mandates.
- `codeGeneration: { strings: false, wasm: false }` plus explicit
`eval`/`Function` traps so direct and indirect dynamic code
execution both fail loudly with `SAND_VM.EVAL_REJECTED` /
`SAND_VM.INDIRECT_EVAL_REJECTED`.
- `globalThis` (and its prototype) frozen post-install so the
sandboxed plugin cannot extend the global object.
- `vm.SourceTextModule` linker refuses every specifier, surfacing
`SAND_VM.MODULE_IMPORT_FORBIDDEN` — node built-ins, bare modules,
parent traversal are all rejected.
- `__suaveplan` global installed as non-writable, non-configurable so
the sandbox cannot replace the receiver.
- Boot wall-time recorded on `sandbox_vm.boot_duration_ms`.
- Idempotent `dispose()` — second call is a no-op.
## Usage: Basic
```ts
import { createVmSandbox } from "@suaveplan/api-sandbox-vm/sandbox/sandbox";
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
const checker = createPermissionChecker({
policy: { fs: ["/srv/data/**"], fetch: ["api.example.com"] },
});
const handle = createVmSandbox({
pluginId: "demo",
logger: console as never,
checker,
pluginModule: {
kind: "source",
identifier: "demo:plugin.js",
source: `__suaveplan.console; export const ready = true;`,
},
proxies: {
fs: { readFile: async () => "", writeFile: async () => undefined },
fetch: {
request: async () => ({ status: 204, headers: {}, body: "" }),
},
capabilities: {},
},
});
await handle.start();
handle.dispose();
```
## Usage: Advanced
Provide a custom checker, a richer capability map, and a strictly-typed
generic for downstream consumers:
```ts
import { createVmSandbox } from "@suaveplan/api-sandbox-vm/sandbox/sandbox";
type DemoCaps = {
readonly db: { readonly query: (sql: string) => Promise<unknown> };
};
const handle = createVmSandbox<DemoCaps>({
pluginId: "demo",
logger,
checker,
pluginModule: { kind: "path", path: "/srv/plugins/demo.mjs" },
proxies: {
fs,
fetch,
capabilities: {
db: { query: async (sql) => realQuery(String(sql)) },
},
},
});
await handle.start();
const cap: DemoCaps["db"] = handle.capabilities.db;
```
## API Reference
- `createVmSandbox<Caps>(options): VmSandboxHandle<Caps>` — build the
sandbox handle.
- `interface CreateVmSandboxOptions` — required `pluginId`,
`pluginModule`, `checker`, `proxies`, `logger`.
- `type PluginSource` — `{ kind: "path", path }` or
`{ kind: "source", identifier, source }`.
- `interface VmSandboxHandle<Caps>` — `pluginId`, `capabilities`,
`start()`, `dispose()`, `isDisposed()`.
## Implementation Notes
`vm.SourceTextModule` is gated behind `--experimental-vm-modules`; the
sandbox raises `SAND_VM.SOURCE_TEXT_MODULE_FAILED` with a clear hint
when the flag is missing rather than crashing with a `TypeError`. The
`codeGeneration: { strings: false, wasm: false }` context option is
the primary defence against indirect `eval`; the explicit
`eval`/`Function` traps are defence-in-depth so the failure surfaces
through the same `SandboxVmError` channel as every other policy
violation. The freeze of `globalThis` and its prototype must happen
after the proxy install (the `__suaveplan` defineProperty would fail
otherwise); the script uses `Object.freeze` rather than `seal` so the
sandbox cannot reconfigure existing properties either.

View File

@@ -0,0 +1,100 @@
# Vm sandbox conformance harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runVmSandboxConformance(label, makeSandbox)` is the public conformance
suite every `vm.Context`-based sandbox host must pass. The suite
encodes the package's spec ADDED requirements as runnable assertions
so any future host implementation (an alternative isolate library, a
hardened wrapper, a worker-thread variant) plugs in against the same
contract by exporting a single factory function.
This module is the reusable bridge between the OpenSpec capability
spec and the implementation; checking that a sandbox passes the
harness is sufficient to verify it satisfies the public requirements.
## Features
- Six assertions covering: `process` global absence, frozen
`globalThis`, direct dynamic-code rejection, indirect dynamic-code
rejection, node-built-in import refusal, dispose idempotency.
- Adapter-driven: the harness calls `makeSandbox(source)` to obtain a
fresh handle for every assertion so adapters with non-trivial setup
cost can amortise it through closure capture.
- Permissive eval-code expectation: the indirect-dynamic case accepts
either `EVAL_REJECTED` or `INDIRECT_EVAL_REJECTED` so adapters that
implement only the broad trap still pass.
## Usage: Basic
```ts
import { runVmSandboxConformance } from "@suaveplan/api-sandbox-vm/testing";
import { createVmSandbox } from "@suaveplan/api-sandbox-vm/sandbox/sandbox";
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
runVmSandboxConformance("self", async (source) =>
createVmSandbox({
pluginId: "self",
logger: console as never,
checker: createPermissionChecker({ policy: {} }),
pluginModule: { kind: "source", identifier: "x", source },
proxies: {
fs: { readFile: async () => "", writeFile: async () => undefined },
fetch: {
request: async () => ({ status: 204, headers: {}, body: "" }),
},
capabilities: {},
},
})
);
```
## Usage: Advanced
Compose with a stricter checker and a pre-baked capability bag so the
harness also exercises the host's `__suaveplan` receiver:
```ts
import { runVmSandboxConformance } from "@suaveplan/api-sandbox-vm/testing";
import { createVmSandbox } from "@suaveplan/api-sandbox-vm/sandbox/sandbox";
runVmSandboxConformance("strict", async (source) =>
createVmSandbox({
pluginId: "strict",
logger,
checker: strictChecker,
pluginModule: { kind: "source", identifier: "strict.mjs", source },
proxies: {
fs: realFs,
fetch: realFetch,
capabilities: {
db: { query: async (sql) => realQuery(String(sql)) },
},
},
})
);
```
## API Reference
- `runVmSandboxConformance(label, makeSandbox)` — register the suite
under `runVmSandboxConformance — ${label}`.
- `type MakeVmSandbox` — factory accepting plugin source text and
returning a `VmSandboxHandle` (synchronously or via Promise).
## Implementation Notes
The harness uses `@suaveplan/testing/runner` so it remains runner-
agnostic — Bun-mode and Vitest-mode test runs see identical
descriptions and assertion semantics. Sandbox source strings are
constructed by concatenation rather than tagged templates so the
emitted code never references a host-side binding. The
`EVAL_TOK` token-split is a defence against static analysers that
would otherwise flag the harness as containing direct `eval` usage —
the harness exercises the trap, not the function. Disposal happens
inside every assertion so the harness never leaks contexts; the
adapter is responsible for tearing the handle down promptly.