Files
suaveplan/packages/api-stack/api/api-sandbox-wasm/src/sandbox/sandbox.ts
2026-05-27 23:29:53 +01:00

199 lines
7.3 KiB
TypeScript

import { SAND_WASM_CODES, SandWasmError } from "../error-codes/error-codes.js";
import {
type CapabilityImport,
installCapabilityImports,
type PermissionCheckerLike,
} from "../imports/install-capability-imports/install-capability-imports.js";
import {
createSandWasmInstruments,
type SandWasmInstruments,
} from "../otel/otel.js";
import {
buildWasiOutboundAllowlist,
type OutboundAllowlist,
} from "../outbound/outbound.js";
import type { PermissionPolicy } from "../policy/policy.js";
import { buildWasiPreopens, type WasiPreopen } from "../preopens/preopens.js";
import {
createWasiSnapshot,
detectWasiVersion,
type WasiSnapshot,
type WasiSnapshotDeps,
} from "../wasi/wasi.js";
/**
* Construction options for {@link createWasmSandbox}. Either supply a
* pre-compiled `wasmModule` OR raw bytes via `wasmBytes`; the former
* skips compilation (callers cache it across reloads) while the latter
* is convenient for direct `fs.readFile` consumers.
*/
export interface CreateWasmSandboxOptions {
/** Plugin name — used as the OTel meter namespace + denial labels. */
readonly pluginName: string;
/** Resolved permission policy gating fs / fetch / capability calls. */
readonly policy: PermissionPolicy;
/** Capability proxies exposed to the guest under `suaveplan_caps`. */
readonly capabilities: readonly CapabilityImport[];
/** Permission checker — typically from `@suaveplan/api-sandbox-permissions`. */
readonly checker: PermissionCheckerLike;
/** Pre-compiled module (preferred for hot-reload paths). */
readonly wasmModule?: WebAssembly.Module;
/** Raw WASM bytes if no module is supplied. */
readonly wasmBytes?: BufferSource;
/** Optional WASI snapshot dependency overrides (fs, clock, random). */
readonly wasiDeps?: Omit<WasiSnapshotDeps, "preopens">;
/** Optional OTel meter-provider override for the instrument registry. */
readonly instruments?: SandWasmInstruments;
}
/**
* Handle returned by {@link createWasmSandbox}. `instance` exposes the
* guest's exports; `preopens` / `outbound` allow the host adapter to
* mirror policy state for diagnostics; `dispose` tears down the
* instance and is idempotent.
*/
export interface WasmSandboxHandle {
readonly instance: WebAssembly.Instance;
readonly preopens: readonly WasiPreopen[];
readonly outbound: OutboundAllowlist;
readonly wasi: WasiSnapshot;
readonly instruments: SandWasmInstruments;
/**
* The permission-gated `suaveplan_caps` imports table the host
* supplied to `WebAssembly.instantiate`. Exposed so the host-side
* audit / observability code can introspect the wired shims (the
* imports themselves are also reachable via the WASM instance, but
* only by the guest).
*/
readonly capabilityImports: Readonly<
Record<string, (...args: never[]) => number>
>;
/** Dispose every resource attached to the sandbox. Safe to call twice. */
dispose(): void;
/** Whether dispose has run. */
readonly disposed: boolean;
}
/**
* Instantiate a sandboxed WASM plugin against a permission-derived WASI
* snapshot. Permission gating is concentrated here so a single audit of
* this function answers "what can the guest actually do?":
*
* 1. The module is compiled (if bytes were supplied) and its imports
* are scanned. Any `wasi_*` namespace not on `SUPPORTED_WASI_VERSIONS`
* fails with `SAND_WASM_WASI_VERSION_UNSUPPORTED`.
* 2. Preopens are built from `policy.fs[]`. Paths outside the allowlist
* are NOT preopened, so the guest's `path_open` cannot reach them.
* Write access requires the `rw:` rule prefix.
* 3. The outbound allowlist is built from `policy.fetch[]` and gates
* `sock_connect` via {@link gateOutboundConnect}.
* 4. Capability imports are wrapped in the permission checker —
* denied calls return `WASI_ERRNO.ACCES` and increment
* `plugin.permission_denied_total`.
*
* The returned handle's `dispose()` releases the WASI snapshot's memory
* reference and drops the outbound allowlist binding; the underlying
* `WebAssembly.Instance` becomes eligible for GC once no further refs
* are held.
*/
export async function createWasmSandbox(
options: CreateWasmSandboxOptions
): Promise<WasmSandboxHandle> {
const module = await resolveModule(options);
const wasiVersion = detectWasiVersion(module);
if (wasiVersion === "unsupported") {
throw new SandWasmError(
SAND_WASM_CODES.WASI_VERSION_UNSUPPORTED,
"error.sand_wasm.wasi_version_unsupported",
{ context: { plugin: options.pluginName } }
);
}
const preopens = buildWasiPreopens(options.policy);
const outbound = buildWasiOutboundAllowlist(options.policy);
const instruments =
options.instruments ??
createSandWasmInstruments({ pluginName: options.pluginName });
const wasi = createWasiSnapshot({
preopens,
...(options.wasiDeps ?? {}),
});
const capabilityImports = installCapabilityImports(
options.capabilities,
options.checker,
(capability, reason) => {
instruments.recordPermissionDenied(capability, reason);
}
);
let instance: WebAssembly.Instance;
try {
instance = await WebAssembly.instantiate(module, {
wasi_snapshot_preview1:
wasi.imports as unknown as WebAssembly.ModuleImports,
suaveplan_caps:
capabilityImports as unknown as WebAssembly.ModuleImports,
});
} catch (error) {
throw new SandWasmError(
SAND_WASM_CODES.INSTANTIATION_FAILED,
"error.sand_wasm.instantiation_failed",
{
cause: error as Error,
context: { plugin: options.pluginName },
}
);
}
const memory = (instance.exports as Readonly<Record<string, unknown>>)[
"memory"
];
if (memory instanceof WebAssembly.Memory) {
wasi.bindMemory(memory);
}
let disposed = false;
const handle: WasmSandboxHandle = {
instance,
preopens,
outbound,
wasi,
instruments,
capabilityImports,
get disposed() {
return disposed;
},
dispose(): void {
if (disposed) return;
disposed = true;
// Memory + instance become eligible for GC; no kernel
// resources are open beyond the policy bindings, which are
// closed implicitly by losing every closure reference.
},
};
return handle;
}
async function resolveModule(
options: CreateWasmSandboxOptions
): Promise<WebAssembly.Module> {
if (options.wasmModule !== undefined) return options.wasmModule;
if (options.wasmBytes === undefined) {
throw new SandWasmError(
SAND_WASM_CODES.INSTANTIATION_FAILED,
"error.sand_wasm.instantiation_failed",
{ context: { reason: "no_module_or_bytes" } }
);
}
try {
return await WebAssembly.compile(options.wasmBytes);
} catch (error) {
throw new SandWasmError(
SAND_WASM_CODES.INSTANTIATION_FAILED,
"error.sand_wasm.instantiation_failed",
{
cause: error as Error,
context: { reason: "compile_failed" },
}
);
}
}