first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
# @suaveplan/api-sandbox-wasm - WASM-based sandbox host for Suaveplan sandboxed-tier API plugins. Instantiates a WebAssembly module against a WASI snapshot preview1 implementation built from the plugin manifest's PermissionPolicy: read-only fs preopens by default (rw: prefix opt-in), outbound socket allowlist from permissions.fetch[], and capability host imports gated by PermissionChecker. Denials raise SAND_WASM_* errors and increment plugin.permission_denied_total. Ships ./testing conformance harness so every sandbox host satisfies the same WASI-specific invariants.
Stage-8 WebAssembly sandbox host. Instantiates a sandboxed-tier plugin
WASM module against a WASI snapshot preview1 implementation derived from
the plugin's `PermissionPolicy`:
- **`permissions.fs[]`** maps to read-only WASI preopen directories.
Write access requires the `rw:` rule prefix.
- **`permissions.fetch[]`** maps to an outbound socket allowlist; the
`sock_connect` host import refuses non-matching destinations BEFORE
opening an OS socket.
- **Capability imports** (under `suaveplan_caps`) are permission-gated
per call; denials return a WASI errno to the guest AND increment
`plugin.permission_denied_total{plugin, capability, reason}`.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `SAND_WASM.*` error-code registry and the `SandWasmError` subclass thrown by every production path i…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable `SAND_WASM.*` error-code registry and the `SandWasmError` subclass thrown by every production path i…
- [install-capability-imports](./src/imports/install-capability-imports/install-capability-imports.md) — Wraps every host-supplied capability proxy in a permission check so the guest's call goes through `PermissionCheckerLik…
- [install-capability-imports](./src/imports/install-capability-imports/install-capability-imports.md) — Wraps every host-supplied capability proxy in a permission check so the guest's call goes through `PermissionCheckerLik…
- [otel](./src/otel/otel.md) — Owns the WASM sandbox host's OpenTelemetry instrument registry. Every counter the host emits on a production path lives…
- [otel](./src/otel/otel.md) — Owns the WASM sandbox host's OpenTelemetry instrument registry. Every counter the host emits on a production path lives…
- [outbound](./src/outbound/outbound.md) — Builds the outbound-socket allowlist from `policy.fetch[]` and wraps the host's `connect` adapter so denied destination…
- [outbound](./src/outbound/outbound.md) — Builds the outbound-socket allowlist from `policy.fetch[]` and wraps the host's `connect` adapter so denied destination…
- [policy](./src/policy/policy.md) — Defines the `PermissionPolicy` value object and the `resolvePermissionPolicy` builder that lifts a `PermissionsManifest…
- [policy](./src/policy/policy.md) — Defines the `PermissionPolicy` value object and the `resolvePermissionPolicy` builder that lifts a `PermissionsManifest…
- [preopens](./src/preopens/preopens.md) — Maps `policy.fs[]` rules onto a WASI preopen table. The WASI capability model is file-descriptor-based: a guest can onl…
- [preopens](./src/preopens/preopens.md) — Maps `policy.fs[]` rules onto a WASI preopen table. The WASI capability model is file-descriptor-based: a guest can onl…
- [sandbox](./src/sandbox/sandbox.md) — Top-level entry point for the WASM sandbox host. `createWasmSandbox` compiles (or accepts a pre-compiled) WASM module,…
- [sandbox](./src/sandbox/sandbox.md) — Top-level entry point for the WASM sandbox host. `createWasmSandbox` compiles (or accepts a pre-compiled) WASM module,…
- [testing](./src/testing/run-wasm-conformance.md) — Exports `runWasmSandboxConformance(label, makeSandbox)` — the conformance harness every WASM-tier sandbox host (this pa…
- [run-wasm-conformance](./src/testing/run-wasm-conformance.md) — Exports `runWasmSandboxConformance(label, makeSandbox)` — the conformance harness every WASM-tier sandbox host (this pa…
- [wasi](./src/wasi/wasi.md) — Minimal `wasi_snapshot_preview1` snapshot tied to the sandbox host's permission policy. Every host-visible side effect…
- [wasi](./src/wasi/wasi.md) — Minimal `wasi_snapshot_preview1` snapshot tied to the sandbox host's permission policy. Every host-visible side effect…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-sandbox-wasm
```
## Usage
```ts
import {
createWasmSandbox,
resolvePermissionPolicy,
} from "@suaveplan/api-sandbox-wasm";
const policy = resolvePermissionPolicy({
fs: ["./uploads/*", "rw:./tmp"],
fetch: ["api.stripe.com", "*.suaveplan.io"],
});
const handle = await createWasmSandbox({
pluginName: "stripe-checkout",
policy,
capabilities: [
{
capabilityId: "secrets",
importName: "secrets_get",
invoke: (...args) => secretsAdapter.get(args[0] as string),
},
],
checker,
wasmBytes: await readFile("./plugin.wasm"),
});
try {
const start = handle.instance.exports._start as () => void;
start();
} finally {
handle.dispose();
}
```
## Modules
- **error-codes** — `SAND_WASM_*` registry + `SandWasmError`.
- **policy** — `PermissionPolicy` value object + `resolvePermissionPolicy`.
- **preopens** — `buildWasiPreopens` + `matchPreopen`.
- **outbound** — `buildWasiOutboundAllowlist` + `gateOutboundConnect`.
- **wasi** — minimal `wasi_snapshot_preview1` snapshot with policy gating.
- **imports/install-capability-imports** — permission-gated host imports.
- **sandbox** — `createWasmSandbox` + `WasmSandboxHandle`.
- **otel** — `createSandWasmInstruments` (`plugin.permission_denied_total`).
- **testing** — `runWasmSandboxConformance` harness.
## Verification
```bash
bunx turbo lint typecheck test:coverage --filter=@suaveplan/api-sandbox-wasm
```
## License
MIT

View File

@@ -0,0 +1,86 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable `SAND_WASM.*` error-code registry and the
`SandWasmError` subclass thrown by every production path inside the
WASM sandbox host. Concentrating codes in a single module keeps the
catch sites code-matchable (no message-string parsing) and lets the
repo-wide conformance gate verify every documented code is
thrown from at least one non-test source file.
The registry is frozen at module load via `defineCodes` so accidental
mutation surfaces immediately, and the side-effect import from the
package's `src/index.ts` ensures any consumer that touches the public
surface registers the namespace.
## Features
- `SAND_WASM_CODES` — frozen literal map of every code.
- `SandWasmError` — `SuaveplanError` subclass with a public constructor
(required by `scripts/lint/check-error-subclass-constructors.ts`).
- `SAND_WASM_ERROR_NAME` — stable `error.name` for logger aggregation.
- `SandWasmCode` — type-level union for catch-site discrimination.
## Usage: Basic
```ts
import {
SAND_WASM_CODES,
SandWasmError,
} from "@suaveplan/api-sandbox-wasm/error-codes";
throw new SandWasmError(
SAND_WASM_CODES.OUTBOUND_DENIED,
"error.sand_wasm.outbound_denied",
{ context: { destination: "evil.example.com" } }
);
```
## Usage: Advanced
```ts
try {
await createWasmSandbox({ ... });
} catch (err) {
if (err instanceof SandWasmError) {
switch (err.code) {
case SAND_WASM_CODES.INSTANTIATION_FAILED:
metrics.increment("plugin.instantiation_failed");
break;
case SAND_WASM_CODES.WASI_VERSION_UNSUPPORTED:
logger.error("Unsupported WASI version", err.context);
break;
}
}
}
```
## API Reference
- `SAND_WASM_CODES.INSTANTIATION_FAILED` — compile or instantiate threw.
- `SAND_WASM_CODES.WASI_VERSION_UNSUPPORTED` — module imports a WASI
namespace not on the allowlist.
- `SAND_WASM_CODES.PREOPEN_DENIED` — `path_open` resolved outside any
preopen OR requested write against a read-only preopen.
- `SAND_WASM_CODES.OUTBOUND_DENIED` — `sock_connect` destination not in
`policy.fetch[]`.
## Implementation Notes
The `SuaveplanError` base class has a `protected constructor`. Without
the public re-declaration in the subclass, callers cannot `new
SandWasmError(...)` (TS2674). The repo's
`check-error-subclass-constructors.ts` lint gate enforces this — a
missing public constructor has surfaced four times in other packages,
which is why the rule exists.
Codes are kept in alphabetical order in tests but emitted in spec order
in the registry to match the proposal's enumeration. The
registry is frozen, so adding a new code requires editing both this
module AND its `.test.ts` AND extending the spec delta — three-way
diffing keeps drift visible during code review.

View File

@@ -0,0 +1,81 @@
# install-capability-imports
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps every host-supplied capability proxy in a permission check so the
guest's call goes through `PermissionCheckerLike.checkCapability`
BEFORE reaching the host adapter. Denied calls return
`WASI_ERRNO.ACCES` to the guest, fire the `onDeny` hook so the host
records `plugin.permission_denied_total`, and surface a host-side
`SandWasmError` on a microtask so async observers (audit log, metrics)
can capture the event without trapping the guest VM.
The returned imports table is consumed by `WebAssembly.instantiate`
under the `suaveplan_caps` module name. Permitted async host adapters
have their rejection forwarded to `onDeny` with reason
`host_adapter_error` so failure modes downstream of the policy gate
still show up in the denial metric.
## Features
- One permission check per call — no caching that would let a
manifest-change miss its effective window.
- Async-host-adapter rejections funnel through the same deny hook.
- Frozen imports table — consumers cannot inject extra imports post-hoc.
- WASI errno return type per preview1 ABI.
## Usage: Basic
```ts
import { installCapabilityImports } from "@suaveplan/api-sandbox-wasm";
const imports = installCapabilityImports(
[{
capabilityId: "secrets",
importName: "secrets_get",
invoke: (...args) => secretsAdapter.get(args[0] as string),
}],
checker,
(cap, reason) => instruments.recordPermissionDenied(cap, reason)
);
```
## Usage: Advanced
```ts
const checker: PermissionCheckerLike = {
checkCapability: (id, payload) => {
if (id === "secrets" && !manifest.secrets?.includes(payload.args[0] as string)) {
return { allow: false, reason: "secret_not_in_manifest" };
}
return { allow: true };
},
};
const imports = installCapabilityImports(caps, checker, onDeny);
```
## API Reference
- `installCapabilityImports(capabilities, checker, onDeny): frozen imports`
- `CapabilityImport.{capabilityId, importName, invoke(...args)}`
- `PermissionCheckerLike.checkCapability(id, payload): Allow | Deny`
- `CapabilityDenyHook = (capabilityId: string, reason: string) => void`
## Implementation Notes
The host-side `SandWasmError` on a denied call is deferred onto a
microtask via `Promise.reject(err).catch(() => {})`. This is
intentional: the guest's execution flow is the canonical authority —
the host error is a secondary observability event. Throwing
synchronously from the import wrapper would trap the guest VM, which
is a much heavier failure mode than returning an errno.
`PermissionCheckerLike` is structural so adapters consuming this
package do not need to import a sibling type. The shape is identical
to the eventual `PermissionChecker` from `@suaveplan/api-sandbox-permissions`,
so swapping the structural form for the nominal type is a one-line
change when the sibling ships.

View File

@@ -0,0 +1,71 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the WASM sandbox host's OpenTelemetry instrument registry. Every
counter the host emits on a production path lives in
`SAND_WASM_COUNTER_NAMES` so a regression that drops one of them
surfaces as a typecheck error upstream rather than a silent metric
gap. The `recordPermissionDenied` convenience method increments
`plugin.permission_denied_total` with the canonical
`{plugin, capability, reason}` label set every sandbox host shares
per design doc §5.2b.
The meter provider can be supplied explicitly (for tests that need a
recording double) or implicitly via the OTel global. The package never
installs an SDK — SDK init is operator responsibility.
## Features
- Pre-created counters at construction time so the first `.add()` is
allocation-free.
- Frozen instruments bundle and frozen counters table.
- Per-plugin meter namespacing under
`@suaveplan/api-sandbox-wasm/<pluginName>` to keep cardinality
bounded on multi-plugin hosts.
- Falls back to the OTel global provider when no override is given.
## Usage: Basic
```ts
import { createSandWasmInstruments } from "@suaveplan/api-sandbox-wasm";
const instruments = createSandWasmInstruments({ pluginName: "checkout" });
instruments.recordPermissionDenied("fetch", "destination_not_allowed");
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [readerWithPrometheusExport] });
const instruments = createSandWasmInstruments({
pluginName: "checkout",
meterProvider: provider,
});
```
## API Reference
- `createSandWasmInstruments(config): SandWasmInstruments`
- `SAND_WASM_COUNTER_NAMES` — canonical counter name list.
- `SandWasmInstruments.{meter, counters, recordPermissionDenied(capability, reason)}`
- `SandWasmInstrumentsConfig.{pluginName, meterProvider?}`
## Implementation Notes
The meter name is plugin-scoped so per-plugin meters can be wired to
per-plugin processors without cardinality leakage. The base label set
on every measurement is `{plugin}` — additional dimensions are added
by the call site (e.g. `capability` + `reason` for the denial
counter).
`plugin.permission_denied_total` is the single shared denial metric
every sandbox host emits — the canonical label set is documented in
the design doc §5.2b and pinned by tests so a future host
implementation cannot accidentally diverge.

View File

@@ -0,0 +1,72 @@
# outbound
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the outbound-socket allowlist from `policy.fetch[]` and wraps the
host's `connect` adapter so denied destinations are refused BEFORE any
OS socket is opened. Denial raises `SAND_WASM_OUTBOUND_DENIED` and
fires the injected `onDeny` hook so the sandbox host can increment
`plugin.permission_denied_total{capability="fetch"}` without coupling
this module to the OTel API.
The matcher supports exact hosts, `*.suffix` wildcards (RFC 6125
semantics — the apex `suaveplan.io` does NOT match `*.suaveplan.io`),
and optional port suffixes. Bare-host rules match any port; port-pinned
rules match only the named port.
## Features
- Exact-host and `*.suffix` rule patterns.
- Port-pinned rules (`api.stripe.com:443`).
- Default destination port 443 when none is supplied.
- O(rules) matching — no regex compilation, suitable for the typical
small allowlists.
- `gateOutboundConnect` wraps a host `connect` adapter; denied calls
never invoke the inner adapter.
## Usage: Basic
```ts
import { buildWasiOutboundAllowlist } from "@suaveplan/api-sandbox-wasm";
const allowlist = buildWasiOutboundAllowlist(policy);
if (!allowlist.isAllowed("api.stripe.com")) { /* deny */ }
```
## Usage: Advanced
```ts
import { gateOutboundConnect } from "@suaveplan/api-sandbox-wasm/outbound";
const gated = gateOutboundConnect(
allowlist,
async (destination) => net.connect(destination),
(destination) => instruments.recordPermissionDenied("fetch", destination)
);
const socket = await gated("api.stripe.com"); // throws SAND_WASM_OUTBOUND_DENIED for non-listed hosts.
```
## API Reference
- `buildWasiOutboundAllowlist(policy): OutboundAllowlist`
- `gateOutboundConnect<TSocket>(allowlist, connect, onDeny)`
- `OutboundAllowlist.{isAllowed(destination), rules}`
## Implementation Notes
Wildcards live inside a single `*.suffix` form rather than full glob
matching because RFC 6125 wildcard semantics are well-defined and
predictable. Adding nested wildcards (e.g. `**.example.com`) would
require careful escape handling that this module does not attempt; if
operators need deeper granularity they should enumerate sub-domains
explicitly.
Out-of-range port suffixes (>65535) on RULES are treated as host-only —
the parser falls back to the bare-host path rather than throwing,
because a parse failure here would be operator-visible during host
boot and add no security value. Out-of-range ports on DESTINATIONS are
treated as the default 443 for the same reason.

View File

@@ -0,0 +1,76 @@
# policy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `PermissionPolicy` value object and the
`resolvePermissionPolicy` builder that lifts a `PermissionsManifest`
into the structurally-typed object the WASM sandbox host consumes.
Keeping the policy shape in this package — rather than importing it
from `@suaveplan/api-sandbox-permissions` — lets the WASM host ship
ahead of the in-flight permissions package without sacrificing
strict typing of the gating surface.
The shape is intentionally structural so the eventual permissions
package can hand a compatible object without taking a hard runtime
dependency on this package's binding.
## Features
- Zod-validated normalisation: empty manifest, missing fields, and
malformed budgets all surface as a `ZodError` at the boundary.
- Deep freezing: every nested collection is `Object.freeze`d so a
consumer cannot mutate the resolved policy.
- `isPermissionPolicy` type guard for adapters that accept either a
raw manifest (re-resolved internally) or a pre-resolved policy.
- `rw:` prefix preserved verbatim — the preopens builder consumes it.
## Usage: Basic
```ts
import { resolvePermissionPolicy } from "@suaveplan/api-sandbox-wasm/policy";
const policy = resolvePermissionPolicy({
fs: ["./uploads/*"],
fetch: ["api.stripe.com"],
});
```
## Usage: Advanced
```ts
import {
isPermissionPolicy,
resolvePermissionPolicy,
} from "@suaveplan/api-sandbox-wasm/policy";
function ensurePolicy(input: unknown): PermissionPolicy {
if (isPermissionPolicy(input)) return input;
return resolvePermissionPolicy(input as PermissionsManifest);
}
```
## API Reference
- `resolvePermissionPolicy(manifest | undefined): PermissionPolicy`
- `isPermissionPolicy(value): value is PermissionPolicy`
- `PermissionPolicy` — `{ fs, fetch, budgets }` frozen value object.
## Implementation Notes
`PermissionsManifest` lives in `@suaveplan/api-types/manifest`. The
Zod schema is intentionally narrow — only the slots the WASM sandbox
host cares about. Budget enforcement is delegated to a future
`BudgetTracker` once `api-sandbox-permissions` ships; this package
records the values but does not enforce them.
The structural-typing strategy was chosen after weighing alternatives:
a direct import of `api-sandbox-permissions` would have forced a hard
ordering of Stage-8 shipments, and a typed-only import would have
required a `tsconfig` paths trick that the build system does not
support. The duck-typed `PermissionPolicy` shape avoids both pitfalls
while keeping the consumer surface narrow enough that a structural
mismatch is caught by tests.

View File

@@ -0,0 +1,71 @@
# preopens
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maps `policy.fs[]` rules onto a WASI preopen table. The WASI capability
model is file-descriptor-based: a guest can only open paths reachable
from a preopened directory, and the host controls whether each preopen
is read-only or read-write. This module owns that translation.
Paths outside the policy allowlist are NOT preopened, so the guest's
`path_open` cannot reach them — denial is enforced by the absence of
the preopen, not by a runtime check. Write access requires the `rw:`
rule prefix; the default is read-only.
## Features
- `rw:` prefix promotes a preopen to read-write; absence keeps it read-only.
- Globs `/*` and `/**` are stripped because WASI preopens are
directory-granular — file-level scoping is enforced by the WASI
runtime's resolved-path check.
- Deduplication: duplicate rules collapse, with `rw` winning over `ro`.
- `matchPreopen` returns the preopen for `path_open` gating, or
`undefined` for a miss / write-against-ro.
## Usage: Basic
```ts
import {
buildWasiPreopens,
resolvePermissionPolicy,
} from "@suaveplan/api-sandbox-wasm";
const policy = resolvePermissionPolicy({ fs: ["./uploads/*", "rw:./tmp"] });
const preopens = buildWasiPreopens(policy);
```
## Usage: Advanced
```ts
import { matchPreopen } from "@suaveplan/api-sandbox-wasm/preopens";
const matched = matchPreopen(preopens, "/abs/host/path/to/file", "rw");
if (matched === undefined) {
metrics.permissionDenied("fs", "preopen_miss_or_ro");
throw new SandWasmError(SAND_WASM_CODES.PREOPEN_DENIED, "denied");
}
```
## API Reference
- `buildWasiPreopens(policy): readonly WasiPreopen[]`
- `matchPreopen(preopens, hostPath, "ro" | "rw"): WasiPreopen | undefined`
- `WasiPreopen.{guestPath, hostPath, mode}`
## Implementation Notes
`hostPath` is computed via `node:path.resolve`, anchoring relative
rules against `process.cwd()` AT POLICY-BUILD TIME — operators must
build the policy in the directory the plugin expects to see. The
resolved path is used as the dedup key; rules that produce the same
absolute path collapse, with `rw` winning over `ro` because privilege
escalation is more visible than silent downgrade.
WASI's preview1 file-rights bitmask is rich (read, write, allocate,
truncate, etc.) — the implementation here intentionally uses a
two-state mode so policy authoring stays readable. Sites that need
finer granularity should layer their own bitmask check on top.

View File

@@ -0,0 +1,79 @@
# sandbox
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level entry point for the WASM sandbox host. `createWasmSandbox`
compiles (or accepts a pre-compiled) WASM module, validates its WASI
imports against the supported-version allowlist, builds the preopen
table and outbound allowlist from the `PermissionPolicy`, wraps every
capability import in the permission checker, and instantiates the
guest under those constraints.
The returned `WasmSandboxHandle` exposes the WebAssembly instance plus
the policy bindings (`preopens`, `outbound`) for diagnostic consumers,
and a `dispose()` method that is idempotent — calling it twice is a
no-op. Memory is bound automatically when the guest exports a `memory`
of type `WebAssembly.Memory`.
## Features
- Accepts a pre-compiled `WebAssembly.Module` or raw bytes.
- WASI version pre-check via `detectWasiVersion`.
- Wraps `WebAssembly.compile` / `WebAssembly.instantiate` failures
in `SAND_WASM_INSTANTIATION_FAILED`, preserving the underlying
`cause` for operator triage.
- Idempotent `dispose()` — the WebAssembly instance becomes eligible
for GC once the handle is released.
## Usage: Basic
```ts
const handle = await createWasmSandbox({
pluginName: "stripe-checkout",
policy,
capabilities: [],
checker,
wasmBytes: await readFile("./plugin.wasm"),
});
try {
(handle.instance.exports._start as () => void)();
} finally {
handle.dispose();
}
```
## Usage: Advanced
```ts
// Hot-reload path: re-use the compiled module across reloads.
const module = await WebAssembly.compile(bytes);
let handle = await createWasmSandbox({ ..., wasmModule: module });
hotReloadBus.on("reload", async () => {
handle.dispose();
handle = await createWasmSandbox({ ..., wasmModule: module });
});
```
## API Reference
- `createWasmSandbox(options): Promise<WasmSandboxHandle>`
- `CreateWasmSandboxOptions.{pluginName, policy, capabilities, checker, wasmModule | wasmBytes, wasiDeps?, instruments?}`
- `WasmSandboxHandle.{instance, preopens, outbound, wasi, instruments, dispose(), disposed}`
## Implementation Notes
The pre-compiled-vs-bytes split exists because hot-reload paths cache
the `WebAssembly.Module` across reloads to avoid re-paying the
validator cost on every cycle. Compilation is significantly more
expensive than instantiation, and operators have explicitly asked for
the option.
`dispose()` does NOT call any `fd_close` on the preopens because the
preopen layer in this minimal snapshot does not hold open file
descriptors — each `path_open` is a fresh `stat` against the host
filesystem. A richer WASI implementation that holds open fds would
need to track them in the handle and close them in `dispose()`.

View File

@@ -0,0 +1,69 @@
# run-wasm-conformance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exports `runWasmSandboxConformance(label, makeSandbox)` — the
conformance harness every WASM-tier sandbox host (this package's own,
or any future implementation) MUST pass. The harness pins the
observable contract: preopens reflect `policy.fs[]`, the outbound
allowlist reflects `policy.fetch[]`, the `rw:` prefix turns a preopen
read-write, `dispose()` is idempotent, and the instrument registry
exposes the canonical `plugin.permission_denied_total` counter.
The harness is exported from the `./testing` subpath so adapters
consume it as a normal import. The in-package fixture sandbox passes
every scenario; the harness is self-tested via
`run-wasm-conformance.test.ts`.
## Features
- Six pinned scenarios covering the spec delta's observable behaviour.
- Uses the host's `describe`/`it` from `@suaveplan/testing/runner` so
adapter test reports show nested suites under the host's tree.
- `WasmSandboxFactory` signature lets adapters pass a minimal builder
rather than re-implementing the full `createWasmSandbox` surface.
## Usage: Basic
```ts
// In an adapter package's test file:
import { runWasmSandboxConformance } from "@suaveplan/api-sandbox-wasm/testing";
import { makeMyAdapterSandbox } from "./my-adapter.js";
runWasmSandboxConformance("my-adapter", makeMyAdapterSandbox);
```
## Usage: Advanced
```ts
runWasmSandboxConformance("with-cache", async (input) => {
const cached = sandboxCache.get(JSON.stringify(input));
if (cached !== undefined) return cached;
const handle = await createWasmSandbox({ /* ... */ });
sandboxCache.set(JSON.stringify(input), handle);
return handle;
});
```
## API Reference
- `runWasmSandboxConformance(label: string, makeSandbox: WasmSandboxFactory): void`
- `WasmSandboxFactory = (input) => Promise<WasmSandboxHandle>` where
`input` is `{ fsRules, fetchRules, denyCapability }`.
## Implementation Notes
The harness consumes the adapter's `WasmSandboxHandle` via its public
surface only — `preopens`, `outbound`, `instruments`, and `dispose()`.
Adapters that wrap or proxy the handle must preserve those properties
identically.
The harness deliberately uses `dispose()` after every scenario rather
than letting GC handle it, because a WASM-runtime under load can pin
memory longer than the test budget allows. Adapters that hold open OS
resources MUST close them in `dispose()` for the harness to pass on
constrained CI runners.

View File

@@ -0,0 +1,74 @@
# wasi
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Minimal `wasi_snapshot_preview1` snapshot tied to the sandbox host's
permission policy. Every host-visible side effect goes through a
policy-derived gate: `path_open` consults the preopen table and the
requested mode (ro vs rw) before any `stat` call, `random_get` and
`clock_time_get` use injected sources so tests are deterministic, and
`proc_exit` forwards the exit code to a sink the host adapter wires to
its lifecycle pipeline.
The snapshot is intentionally a subset of preview1 — the imports
covered are the ones the sandbox host actually exposes to a sandboxed
plugin. Adding more imports is straightforward; the gating concentration
in this module is the value, not coverage breadth.
## Features
- Permission-derived `path_open` gating returning the appropriate
WASI errno (`BADF`, `ACCES`, `PERM`, `NOENT`, `SUCCESS`).
- Memory binding deferred until after instantiation (chicken-and-egg
with the guest's exported `memory`).
- Injectable `fs`, `random`, `clockNs`, `onProcExit` for tests.
- `detectWasiVersion` scans `WebAssembly.Module.imports()` so the host
can raise `SAND_WASM_WASI_VERSION_UNSUPPORTED` BEFORE instantiation.
## Usage: Basic
```ts
import { createWasiSnapshot, buildWasiPreopens } from "@suaveplan/api-sandbox-wasm";
const snapshot = createWasiSnapshot({ preopens: buildWasiPreopens(policy) });
const instance = await WebAssembly.instantiate(module, {
wasi_snapshot_preview1: snapshot.imports,
});
snapshot.bindMemory(instance.exports.memory as WebAssembly.Memory);
```
## Usage: Advanced
```ts
const snapshot = createWasiSnapshot({
preopens,
random: (n) => seedrng.bytes(n),
clockNs: () => fakeClock.nowNs(),
onProcExit: (code) => lifecycleDriver.onPluginExit(code),
});
```
## API Reference
- `createWasiSnapshot(deps): WasiSnapshot`
- `WasiSnapshot.{imports, bindMemory(memory), lastErrno()}`
- `detectWasiVersion(module): "wasi_snapshot_preview1" | "unsupported" | undefined`
- `WASI_ERRNO.*`, `WASI_PREOPEN_BASE_FD`, `WASI_OFLAGS_WRITE`
## Implementation Notes
WASI's preview1 ABI says imports are synchronous, but `path_open`'s
stat-against-real-filesystem cannot easily be made sync without
blocking the event loop. The pragmatic choice: the snapshot's
`path_open` is async, and the host stitches it into the WASM execution
via a top-level await at the call site. For the in-package fixture
this is non-issue because `_start` does not call `path_open`; real
plugins that invoke async host imports should know they cost a tick.
WASI version detection lives here rather than in the sandbox host so
the import-scanning logic is unit-testable in isolation. The host's
only job is to map `"unsupported"` to the documented error code.