first commit
This commit is contained in:
101
docs/api-stack/api/api-sandbox-wasm/README.md
Normal file
101
docs/api-stack/api/api-sandbox-wasm/README.md
Normal 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
71
docs/api-stack/api/api-sandbox-wasm/src/otel/otel.md
Normal file
71
docs/api-stack/api/api-sandbox-wasm/src/otel/otel.md
Normal 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.
|
||||
72
docs/api-stack/api/api-sandbox-wasm/src/outbound/outbound.md
Normal file
72
docs/api-stack/api/api-sandbox-wasm/src/outbound/outbound.md
Normal 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.
|
||||
76
docs/api-stack/api/api-sandbox-wasm/src/policy/policy.md
Normal file
76
docs/api-stack/api/api-sandbox-wasm/src/policy/policy.md
Normal 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.
|
||||
71
docs/api-stack/api/api-sandbox-wasm/src/preopens/preopens.md
Normal file
71
docs/api-stack/api/api-sandbox-wasm/src/preopens/preopens.md
Normal 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.
|
||||
79
docs/api-stack/api/api-sandbox-wasm/src/sandbox/sandbox.md
Normal file
79
docs/api-stack/api/api-sandbox-wasm/src/sandbox/sandbox.md
Normal 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()`.
|
||||
@@ -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.
|
||||
74
docs/api-stack/api/api-sandbox-wasm/src/wasi/wasi.md
Normal file
74
docs/api-stack/api/api-sandbox-wasm/src/wasi/wasi.md
Normal 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.
|
||||
Reference in New Issue
Block a user