199 lines
7.3 KiB
TypeScript
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" },
|
|
}
|
|
);
|
|
}
|
|
}
|