first commit
This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
96
docs/api-stack/api/api-sandbox-vm/src/otel/instruments.md
Normal file
96
docs/api-stack/api/api-sandbox-vm/src/otel/instruments.md
Normal 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.
|
||||
106
docs/api-stack/api/api-sandbox-vm/src/permissions/checker.md
Normal file
106
docs/api-stack/api/api-sandbox-vm/src/permissions/checker.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
127
docs/api-stack/api/api-sandbox-vm/src/sandbox/sandbox.md
Normal file
127
docs/api-stack/api/api-sandbox-vm/src/sandbox/sandbox.md
Normal 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user