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; /** 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 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 { 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>)[ "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 { 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" }, } ); } }