Initial Commit
This commit is contained in:
198
packages/api-stack/api/api-sandbox-wasm/src/sandbox/sandbox.ts
Normal file
198
packages/api-stack/api/api-sandbox-wasm/src/sandbox/sandbox.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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" },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user