From afcd34927e452ca0fda628320a6f7e365693460f Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 19 Jun 2026 22:47:48 -0700 Subject: [PATCH] docs: process-isolation + benchmarks + restored reference docs (api-reference, nodejs-compatibility, security-model, architecture, cost-evaluation, comparison/*); wire Reference sidebar --- website/docs.config.mjs | 49 +-- .../src/content/docs/docs/api-reference.mdx | 290 ++++++++++++++++++ .../src/content/docs/docs/architecture.mdx | 132 ++++++++ website/src/content/docs/docs/benchmarks.mdx | 125 ++++++++ .../docs/comparison/cloudflare-workers.mdx | 121 ++++++++ .../content/docs/docs/comparison/sandbox.mdx | 90 ++++++ .../src/content/docs/docs/cost-evaluation.mdx | 52 ++++ .../docs/docs/nodejs-compatibility.mdx | 129 ++++++++ .../content/docs/docs/process-isolation.mdx | 141 +++++++++ .../src/content/docs/docs/runtime-modes.mdx | 127 ++++++++ .../src/content/docs/docs/security-model.mdx | 125 ++++++++ 11 files changed, 1357 insertions(+), 24 deletions(-) create mode 100644 website/src/content/docs/docs/api-reference.mdx create mode 100644 website/src/content/docs/docs/architecture.mdx create mode 100644 website/src/content/docs/docs/benchmarks.mdx create mode 100644 website/src/content/docs/docs/comparison/cloudflare-workers.mdx create mode 100644 website/src/content/docs/docs/comparison/sandbox.mdx create mode 100644 website/src/content/docs/docs/cost-evaluation.mdx create mode 100644 website/src/content/docs/docs/nodejs-compatibility.mdx create mode 100644 website/src/content/docs/docs/process-isolation.mdx create mode 100644 website/src/content/docs/docs/runtime-modes.mdx create mode 100644 website/src/content/docs/docs/security-model.mdx diff --git a/website/docs.config.mjs b/website/docs.config.mjs index 846d56c11..00dd426c8 100644 --- a/website/docs.config.mjs +++ b/website/docs.config.mjs @@ -74,32 +74,33 @@ export const siteConfig = { { slug: "docs/features/output-capture", attrs: { "data-icon": "scroll" } }, { slug: "docs/features/resource-limits", attrs: { "data-icon": "gauge" } }, { slug: "docs/features/child-processes", attrs: { "data-icon": "split" } }, - // { slug: "docs/process-isolation", attrs: { "data-icon": "box" } }, + { slug: "docs/process-isolation", attrs: { "data-icon": "box" } }, + ], + }, + { + label: "Reference", + items: [ + { slug: "docs/api-reference", attrs: { "data-icon": "book" } }, + { slug: "docs/nodejs-compatibility", attrs: { "data-icon": "check" } }, + { slug: "docs/benchmarks", attrs: { "data-icon": "gauge" } }, + { + label: "Comparison", + items: [ + { slug: "docs/comparison/sandbox", attrs: { "data-icon": "gitCompare" } }, + { slug: "docs/comparison/cloudflare-workers", attrs: { "data-icon": "gitCompare" } }, + ], + }, + { + label: "Advanced", + items: [ + { slug: "docs/cost-evaluation", attrs: { "data-icon": "dollar" } }, + { slug: "docs/architecture", attrs: { "data-icon": "blocks" } }, + { slug: "docs/runtime-modes", attrs: { "data-icon": "split" } }, + { slug: "docs/security-model", attrs: { "data-icon": "lock" } }, + ], + }, ], }, - // { - // label: "Reference", - // items: [ - // { slug: "docs/api-reference", attrs: { "data-icon": "book" } }, - // { slug: "docs/nodejs-compatibility", attrs: { "data-icon": "check" } }, - // { slug: "docs/benchmarks" }, - // { - // label: "Comparison", - // items: [ - // { slug: "docs/comparison/sandbox", attrs: { "data-icon": "gitCompare" } }, - // { slug: "docs/comparison/cloudflare-workers", attrs: { "data-icon": "gitCompare" } }, - // ], - // }, - // { - // label: "Advanced", - // items: [ - // { slug: "docs/cost-evaluation", attrs: { "data-icon": "dollar" } }, - // { slug: "docs/architecture", attrs: { "data-icon": "blocks" } }, - // { slug: "docs/security-model", attrs: { "data-icon": "lock" } }, - // ], - // }, - // ], - // }, ], }; diff --git a/website/src/content/docs/docs/api-reference.mdx b/website/src/content/docs/docs/api-reference.mdx new file mode 100644 index 000000000..b5eafbc41 --- /dev/null +++ b/website/src/content/docs/docs/api-reference.mdx @@ -0,0 +1,290 @@ +--- +title: API Reference +description: Complete reference for the secure-exec public API surface. +--- +import { Aside } from '@astrojs/starlight/components'; + +The `secure-exec` package exports a single class, `NodeRuntime`, plus the types that describe its options and results. + +```ts +import { NodeRuntime } from "secure-exec"; +``` + +A runtime is created with `NodeRuntime.create()`, used to run guest programs, and torn down with `dispose()`. The shape every example follows: + +```ts +import { NodeRuntime } from "secure-exec"; + +const rt = await NodeRuntime.create(); +try { + const { stdout, exitCode } = await rt.exec("console.log('hi', 1 + 1)"); + console.log(stdout, exitCode); +} finally { + await rt.dispose(); +} +``` + + + +## Exports + +| Export | Kind | Description | +|---|---|---| +| `NodeRuntime` | class | The runtime. Create with `NodeRuntime.create()`. | +| `NodeRuntimeCreateOptions` | type | Options for `NodeRuntime.create()`. | +| `NodeRuntimeExecOptions` | type | Options for a single `exec()` / `run()` / `spawn()` call. | +| `NodeRuntimeExecResult` | type | Result of `exec()`. | +| `NodeRuntimeRunResult` | type | Result of `run()`. | +| `NodeRuntimeSpawnOptions` | type | Options for `spawn()`. | +| `NodeRuntimeProcess` | type | Live handle to a spawned guest process. | +| `NodeRuntimeFetchInput` | type | Request passed to `fetch()`. | +| `NodeRuntimeFetchResponse` | type | Response returned by `fetch()`. | +| `NodeRuntimeListener` | type | A matched guest TCP listener. | +| `NodeRuntimeListenerQuery` | type | Query for `findListener()` / `waitForListener()`. | +| `NodeRuntimeWaitForListenerOptions` | type | Options for `waitForListener()`. | +| `HostDirectoryMount` | type | A host directory projected into the VM. | +| `NodeModulesMount` | type | Object form of the `nodeModules` create option. | +| `HostToolDefinition` | type | A host-side tool the guest can invoke as a command. | +| `HostToolExample` | type | A worked example shown alongside a host tool. | + +## `NodeRuntime` + +Ergonomic, batteries-included runtime for executing guest JavaScript. A single instance can run many programs; each call executes a fresh guest process. + +### `NodeRuntime.create(options?)` + +```ts +static create(options?: NodeRuntimeCreateOptions): Promise +``` + +Boots a VM and returns a ready-to-use runtime. Spawns the sidecar, opens a session, creates the VM with a bootstrapped root filesystem, mounts the shell (WASM `sh` plus coreutils) and Node (V8-backed `node`) runtimes, and waits for the VM to report ready. + +### Methods + +| Method | Returns | Description | +|---|---|---| +| `exec(code, options?)` | `Promise` | Run `code` as a guest Node program and capture its output. | +| `run(code, options?)` | `Promise>` | Run `code` and return the JSON value it produces via `__return(value)`. | +| `spawn(code, options?)` | `Promise` | Start `code` as a long-running guest program and return a live handle. | +| `fetch(port, input)` | `Promise` | Drive an HTTP request to a guest server listening inside the VM. | +| `findListener(query)` | `NodeRuntimeListener \| null` | Look up a guest TCP listener once, non-blocking. | +| `waitForListener(query, options?)` | `Promise` | Block until a matching guest listener appears. | +| `registerTools(tools)` | `Promise` | Register host-side tools the guest can invoke as commands, after boot. | +| `writeFile(path, content)` | `Promise` | Write a file into the VM's virtual filesystem. | +| `readFile(path)` | `Promise` | Read a file from the VM's virtual filesystem as raw bytes. | +| `dispose()` | `Promise` | Tear down the VM and release the sidecar. | + +#### `exec(code, options?)` + +Writes `code` to an ES module inside the VM and executes it with `node `. It runs as standard ESM, so top-level `await` and `import` work. + +```ts +const { stdout, stderr, exitCode } = await rt.exec(` + import os from "node:os"; + console.log(os.platform()); +`); +``` + +#### `run(code, options?)` + +Like `exec`, but returns a JSON-serializable value. The guest calls the injected `__return(value)` function; that value is decoded on the host as `result.value`. If `__return` is never called, `value` is `undefined`. stdout, stderr, and exitCode are still captured. + +```ts +const { value } = await rt.run(` + __return(40 + 2); +`); +console.log(value); // 42 +``` + +#### `spawn(code, options?)` + +Starts `code` without waiting for it to finish and returns a `NodeRuntimeProcess` handle. Use this for guests that do not run to completion, such as a dev server you later drive with `fetch()`. + +```ts +const server = await rt.spawn(` + import http from "node:http"; + http.createServer((_, res) => res.end("ok")).listen(3000); +`); +await rt.waitForListener({ port: 3000 }); +const res = await rt.fetch(3000, { path: "/" }); +server.kill(); +await server.wait(); +``` + +#### `fetch(port, input)` + +Drives an HTTP request to a guest HTTP server listening inside the VM and reads the response back on the host. The request and response never leave the VM: the connection is made to the guest's loopback listener through the kernel socket table, so this works even when guest network egress is denied. + +#### `findListener(query)` and `waitForListener(query, options?)` + +`findListener` asks the kernel socket table whether a guest process is accepting connections on the requested port (optionally narrowed by `host` / `path`) and returns the match or `null`. `waitForListener` polls until a match appears, rejecting on timeout (default 10000 ms) or abort. + +#### `registerTools(tools)` + +Registers host-side tools on a live runtime. Each entry becomes a named guest command; running it round-trips back to the host and runs the tool's `handler`, whose return value is delivered to the guest. Make sure the `tool` permission scope is granted (for example `permissions: { tool: "allow" }`) so the tools are invocable. The `tools` create option does this automatically when no `tool` scope is set. + +## Types + +### `NodeRuntimeCreateOptions` + +| Option | Type | Description | +|---|---|---| +| `env` | `Record` | Environment variables visible to guest processes. | +| `cwd` | `string` | Initial working directory for guest processes. Defaults to `/home/user`. | +| `permissions` | `Permissions` | Permission policy, merged over a secure default that denies network access. See [Permissions](/docs/features/permissions). | +| `commandsDir` | `string` | Override the directory holding the WASM command binaries (the source of guest `sh`). | +| `files` | `Record` | Files seeded into the VM's virtual filesystem before boot, keyed by absolute guest path. | +| `mounts` | `HostDirectoryMount[]` | Host directories projected into the VM, Docker-style, read lazily. | +| `nodeModules` | `string \| NodeModulesMount` | Host `node_modules` directory to project so guest `import` / `require` resolve real packages. Defaults to mounting at `/tmp/node_modules`. | +| `tools` | `Record` | Host-side tools the guest can invoke as shell commands. | +| `loopbackExemptPorts` | `number[]` | Guest-bound ports that may accept non-loopback connections. | + + + +The `permissions` field is the `Permissions` type from `@secure-exec/core`. Each scope (`fs`, `network`, `childProcess`, `process`, `env`, `tool`) accepts either a mode (`"allow"` / `"deny"`) or a `{ default?, rules }` policy. The default policy denies `network` and allows the virtualized scopes so programs run. See [Permissions](/docs/features/permissions) for the full shape. + +### `HostDirectoryMount` + +| Field | Type | Description | +|---|---|---| +| `guestPath` | `string` | Absolute guest path the directory appears at inside the VM. | +| `hostPath` | `string` | Absolute host directory to project (read lazily through the VFS). | +| `readOnly` | `boolean` | Mount read-only (the default). Pass `false` to allow guest writes. | + +### `NodeModulesMount` + +Object form of the `nodeModules` create option. The string form (`nodeModules: "/abs/node_modules"`) is shorthand for `{ hostPath }`. + +| Field | Type | Description | +|---|---|---| +| `hostPath` | `string` | Absolute host `node_modules` directory to project (read lazily). | +| `guestPath` | `string` | Guest path to mount it at. Defaults to `/tmp/node_modules`. | + +### `NodeRuntimeExecOptions` + +Options for a single `exec()` / `run()` call. `NodeRuntimeSpawnOptions` extends this type (the streaming hooks apply to `spawn` too). + +| Option | Type | Description | +|---|---|---| +| `env` | `Record` | Extra environment variables for this run, merged over the VM env. | +| `cwd` | `string` | Working directory for this run. | +| `stdin` | `string \| Uint8Array` | Data piped to the guest program's stdin. | +| `timeout` | `number` | Abort the run after this many milliseconds. | +| `signal` | `AbortSignal` | Cancel the run when this signal aborts; the guest process is killed (SIGTERM) and the call rejects with the abort reason. | +| `onStdout` | `(chunk: Uint8Array) => void` | Called with each stdout chunk as it is produced. | +| `onStderr` | `(chunk: Uint8Array) => void` | Called with each stderr chunk as it is produced. | + +### `NodeRuntimeExecResult` + +```ts +interface NodeRuntimeExecResult { + stdout: string; + stderr: string; + exitCode: number; +} +``` + +### `NodeRuntimeRunResult` + +```ts +interface NodeRuntimeRunResult { + value?: T; + stdout: string; + stderr: string; + exitCode: number; +} +``` + +### `NodeRuntimeSpawnOptions` + +Identical to `NodeRuntimeExecOptions` (it extends it). The `onStdout` / `onStderr` hooks receive output chunks as the spawned process produces them. + +### `NodeRuntimeProcess` + +A live handle to a guest process started with `spawn()`. + +| Member | Type | Description | +|---|---|---| +| `pid` | `number` (readonly) | The guest process id. | +| `writeStdin(data)` | `(data: string \| Uint8Array) => void` | Write data to the process's stdin. | +| `closeStdin()` | `() => void` | Close the process's stdin, signalling end-of-input. | +| `kill(signal?)` | `(signal?: NodeJS.Signals \| number) => void` | Send a signal. Defaults to `SIGTERM`. Accepts a signal name or raw number. | +| `wait()` | `() => Promise` | Resolve with the exit code once the process terminates. | +| `exitCode` | `number \| null` (readonly) | The exit code once exited, or `null` while running. | + +### `NodeRuntimeFetchInput` + +| Field | Type | Description | +|---|---|---| +| `method` | `string` | HTTP method. Defaults to `GET`. | +| `path` | `string` | Request path and query, e.g. `/api/users?limit=10`. | +| `headers` | `Record` | Request headers. | +| `body` | `string \| Uint8Array` | Request body. | + +### `NodeRuntimeFetchResponse` + +| Field | Type | Description | +|---|---|---| +| `status` | `number` | HTTP status code, e.g. `200`. | +| `statusText` | `string` | HTTP status text, e.g. `OK`. | +| `headers` | `Record` | Response headers, lower-cased by name. | +| `body` | `string` | Response body decoded as UTF-8 text. | + +### `NodeRuntimeListenerQuery` + +| Field | Type | Description | +|---|---|---| +| `port` | `number` | TCP port the guest listener is bound to. | +| `host` | `string` | Bind host to match. Omit to match any host. | +| `path` | `string` | Unix socket path to match, for path-bound listeners. | + +### `NodeRuntimeListener` + +| Field | Type | Description | +|---|---|---| +| `processId` | `string` | The guest process id that owns the listening socket. | +| `host` | `string` | The host the listener is bound to, when reported. | +| `port` | `number` | The port the listener is bound to, when reported. | +| `path` | `string` | The unix socket path the listener is bound to, when reported. | + +### `NodeRuntimeWaitForListenerOptions` + +| Option | Type | Description | +|---|---|---| +| `timeoutMs` | `number` | Give up after this many milliseconds and reject. Defaults to 10000. | +| `signal` | `AbortSignal` | Abort the wait early; the promise rejects when it fires. | +| `pollIntervalMs` | `number` | How long to wait between lookups while polling. Defaults to 50. | + +### `HostToolDefinition` + +A host-side tool the guest invokes by name as a shell command. The invocation round-trips back to the host `handler`, which runs on the host (never inside the guest) and returns a JSON-serializable result delivered back to the guest. + +| Field | Type | Description | +|---|---|---| +| `description` | `string` | Human-readable description of what the tool does. | +| `inputSchema` | `object` | JSON Schema describing the tool's input. | +| `handler` | `(input: unknown) => unknown \| Promise` | Host handler invoked when the guest runs the tool. | +| `timeoutMs` | `number` | Abort the invocation after this many milliseconds. | +| `examples` | `HostToolExample[]` | Worked examples shown alongside the tool. | +| `commandAliases` | `string[]` | Extra command names the guest can use to invoke the tool. | + +### `HostToolExample` + +| Field | Type | Description | +|---|---|---| +| `description` | `string` | What this example demonstrates. | +| `input` | `unknown` | Example input matching the tool's input schema. | + +## The low-level handle + +`NodeRuntime` is built on top of `SidecarProcess`, the lower-level client handle to a running sidecar. It is not exported from `secure-exec`; reach for it from `@secure-exec/core/sidecar-process` only when you need direct protocol-level control, and prefer `NodeRuntime` otherwise. + +```ts +import { SidecarProcess } from "@secure-exec/core/sidecar-process"; +``` + +A matching `SidecarProcess` struct is available in the Rust client crate `secure-exec-client`. diff --git a/website/src/content/docs/docs/architecture.mdx b/website/src/content/docs/docs/architecture.mdx new file mode 100644 index 000000000..d56e6e812 --- /dev/null +++ b/website/src/content/docs/docs/architecture.mdx @@ -0,0 +1,132 @@ +--- +title: Architecture +description: How Secure Exec fits together, from the public TypeScript API down to the virtualized kernel. +--- +import { Aside } from '@astrojs/starlight/components'; + +Secure Exec runs untrusted guest code inside a fully virtualized VM. Nothing the guest does touches the host directly: there is no real host filesystem, no real host network socket, and no real host process. Every guest syscall is serviced by a kernel that Secure Exec owns. + +This page maps the moving parts, from the `secure-exec` package you import down to the Rust kernel that executes guest code. + +## The big picture + +A Secure Exec runtime spans two processes: + +1. **Your process.** You import `NodeRuntime` from `secure-exec` and call `NodeRuntime.create()`. The runtime drives a low-level `SidecarProcess` handle. +2. **The sidecar process.** `SidecarProcess` spawns the native `secure-exec-sidecar` binary and talks to it over a stdio wire protocol. The sidecar hosts the kernel, the virtual filesystem, the process table, and the language runtimes that actually run guest code. + +``` +your process sidecar process +┌───────────────────────────┐ ┌──────────────────────────────────┐ +│ NodeRuntime (secure-exec) │ │ secure-exec-sidecar │ +│ │ │ stdio │ kernel (VFS, processes, perms) │ +│ ▼ │ wire │ ├─ v8-runtime (guest JS) │ +│ SidecarProcess ────────────┼───────▶│ └─ wasm runtime (sh, coreutils)│ +│ (@secure-exec/core) │ proto │ │ +└───────────────────────────┘ └──────────────────────────────────┘ +``` + +The guest never runs in your process. It runs inside the sidecar, behind the kernel's isolation boundary. + +## TypeScript packages + +The code you import lives under `packages/`. + +### `secure-exec` + +The public package. It re-exports exactly one class, `NodeRuntime`, plus its option and result types. This is the surface almost every application should use. + +```ts +import { NodeRuntime } from "secure-exec"; + +const rt = await NodeRuntime.create(); +try { + const { stdout } = await rt.exec("console.log('hi', 1 + 1)"); + console.log(stdout.trim()); // hi 2 +} finally { + await rt.dispose(); +} +``` + +`NodeRuntime` is an ergonomic facade. `NodeRuntime.create()` hides the entire setup: spawning the sidecar, opening a session, creating the VM with a bootstrapped root filesystem, mounting the shell and Node runtimes, and waiting for the VM to report ready. Each `exec()` / `run()` / `spawn()` runs the guest as a fresh kernel process. + +See [Process Isolation](/docs/process-isolation) for what one runtime isolates and how to choose isolation granularity. + +### `@secure-exec/core` + +The generic protocol, client, descriptor, and runtime-asset package. `NodeRuntime` itself is implemented here; `secure-exec` is a thin re-export. + +`@secure-exec/core` also exports the low-level `SidecarProcess` handle for advanced callers who want to drive sessions, VMs, and the wire protocol directly rather than through `NodeRuntime`: + +```ts +import { SidecarProcess } from "@secure-exec/core/sidecar-process"; +``` + +`SidecarProcess` is what `NodeRuntime` uses under the hood: it spawns the sidecar binary and exposes the wire protocol. Most applications never need it. Reach for it only when you are building your own runtime facade. + + + +## Rust crates + +The native runtime lives under `crates/`. These build the `secure-exec-sidecar` binary and the kernel it hosts. + +| Crate | Role | +|---|---| +| `secure-exec-kernel` | The shared kernel plane: virtual filesystem, process and socket tables, pipes, PTYs, permission policy, and DNS. Every guest syscall goes through here. | +| `secure-exec-v8-runtime` | V8 isolate runtime that executes guest JavaScript. Guest JS runs in isolates; there are no host escapes and no real Node.js builtins for guest work. | +| `secure-exec-execution` | The native execution plane that wires the kernel to host capabilities on a real machine. | +| `secure-exec-bridge` | The browser/native portability seam. Shared contracts between the kernel and the execution planes live here. | +| `secure-exec-sidecar` | Builds the `secure-exec-sidecar` crate and binary. It speaks the stdio wire protocol and hosts the kernel and runtimes. | +| `secure-exec-client` | The Rust client transport. It exposes the `SidecarProcess` struct, the Rust counterpart to the TypeScript `SidecarProcess`. | + +Supporting crates include `secure-exec-vm-config` (shared VM-creation config DTOs) and `secure-exec-sidecar-browser` (the browser-side sidecar scaffold). + + + +## Language runtimes inside the VM + +The kernel cannot run a program until a runtime is mounted to handle it. `NodeRuntime.create()` mounts two: + +- **WASM command runtime.** Provides `sh` plus WASM-backed coreutils. `sh` is required: without it the kernel cannot spawn anything, including the guest `node` program and any child the guest spawns through `node:child_process`. +- **Node runtime.** Provides the real V8-backed `node`. Guest programs are written to a module inside the VM and executed as `node ` through the kernel. + +You can point at a different set of WASM command binaries with the `commandsDir` option. See [Child Processes](/docs/features/child-processes) for how guest code spawns commands inside the VM. + +## How a run flows through the system + +When you call `rt.exec(code)`: + +1. `NodeRuntime` writes the guest code to a module inside the VM's virtual filesystem. +2. It asks the kernel to spawn `node ` as a fresh guest process. +3. The request crosses the stdio wire protocol from `SidecarProcess` to the sidecar. +4. Inside the sidecar, the kernel starts the process; the V8 runtime executes the guest JavaScript in an isolate. +5. Guest syscalls (filesystem, process, env, and, when permitted, network) are serviced by the kernel against the VM's virtualized resources, gated by the permission policy. +6. Captured stdout, stderr, and the exit code travel back across the wire and resolve the `exec()` promise. + +Guest `fetch()` runs through undici inside the V8 isolate, then through the kernel socket table, so even network access stays inside the isolation boundary and under the permission policy. + +## Permissions + +The kernel enforces a permission policy on every scope. `NodeRuntime.create()` applies a secure default: the filesystem, child-process, process, and env scopes are allowed (they are fully virtualized, so the guest only ever sees the VM), and the network scope is denied until you opt in. + +```ts +// Grant the network while keeping the execution essentials. +const rt = await NodeRuntime.create({ permissions: { network: "allow" } }); +``` + +The policy merges over the secure default, so a partial object like `{ network: "allow" }` is all you need to open one scope. See [Permissions](/docs/features/permissions) for the full policy model. + +## Lifecycle + +The caller owns each runtime. `dispose()` tears down the sidecar process and frees the VM: + +```ts +const rt = await NodeRuntime.create(); +try { + // ... use rt ... +} finally { + await rt.dispose(); +} +``` + +Runtimes are independent. Disposing one never affects another, and a crash in one runtime's process does not touch the others. See [Process Isolation](/docs/process-isolation) for the isolation guarantees in detail. diff --git a/website/src/content/docs/docs/benchmarks.mdx b/website/src/content/docs/docs/benchmarks.mdx new file mode 100644 index 000000000..7a1ad0cf8 --- /dev/null +++ b/website/src/content/docs/docs/benchmarks.mdx @@ -0,0 +1,125 @@ +--- +title: Benchmarks +description: Cold start, warm execution, and reuse fast-path measurements for the Secure Exec SDK. +--- +import { Aside } from '@astrojs/starlight/components'; + +These numbers measure the public `secure-exec` SDK paths that consumers actually use. The cold start matrix (`packages/benchmarks/coldstart.bench.ts`) times the full journey from booting a runtime to running guest code, and compares three ways of provisioning a runtime against how much you can reuse. + + + +## Scenarios + +- **owned-sidecar**: `NodeRuntime.create()` boots a fresh sidecar process for each runtime. This is the default, fully isolated path (see [Process Isolation](/docs/process-isolation)). +- **shared-sidecar**: one sidecar process is created once and reused across many runtimes, instead of spawning a fresh sidecar per runtime. Sidecar setup is measured separately and excluded from cold start, so this isolates the per-runtime cost. (Sharing a sidecar is a reuse fast path layered on the low-level `SidecarProcess`, not the default `NodeRuntime.create()` path.) +- **resident-runner**: a shared sidecar plus a resident runner, so repeated small snippets reuse one live guest Node process instead of starting a new one each time. + +## Cold vs warm + +Each scenario reports two latencies: + +- **Cold**: provisioning the runtime and running the first guest snippet end to end. +- **Warm**: running a second snippet on the same runtime, after it is already up. + +A single sequential runtime (batch size 1) gives the cleanest picture: + +| Scenario | Cold mean | Cold p50 | Warm mean | Warm p50 | +| --- | ---: | ---: | ---: | ---: | +| owned-sidecar | 772.88ms | 771.96ms | 452.68ms | 452.37ms | +| shared-sidecar | 774.17ms | 774.50ms | 457.19ms | 452.75ms | +| resident-runner | 351.00ms | 349.56ms | 1.27ms | 1.27ms | + +The headline result is the resident runner: once the live guest process exists, a warm snippet runs in about **1.3ms**, roughly 350x faster than a warm execution that still has to stand up a guest process. Owned and shared sidecars cost about the same per runtime when run sequentially, because the sidecar process itself is cheap to spawn (around 3ms); sharing it mainly matters under concurrency. + +## Where the cold-start time goes + +Breaking down a single owned-sidecar cold start (batch 1, sequential, p50 per phase): + +| Phase | p50 | +| --- | ---: | +| sidecar_spawn | 2.78ms | +| session_open | 2.34ms | +| vm_create | 1.36ms | +| vm_configure | 0.17ms | +| runtime_mount_node | 2.71ms | +| runtime_mount_wasm | 172.95ms | +| runtime_create_total | 176.30ms | +| first_exec | 596.26ms | +| warm_exec | 452.37ms | + +Two phases dominate: mounting the WASM command set (about 173ms) and the first guest execution (about 596ms, which includes bringing up the guest Node runtime). Spawning the sidecar, opening a session, and creating the VM together cost only a few milliseconds. This is why the resident runner wins so decisively: it pays the first-exec cost once and then reuses the live process. + +## Concurrency + +The matrix also runs each scenario at larger batch sizes, both sequentially and concurrently (up to the host concurrency cap). Cold mean latency by batch size: + +| Scenario | Mode | b=1 | b=10 | b=50 | b=100 | b=200 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| owned-sidecar | sequential | 772.88ms | 780.07ms | 777.49ms | 777.89ms | 778.28ms | +| owned-sidecar | concurrent | 776.22ms | 1000.39ms | 1041.81ms | 1041.19ms | 1044.03ms | +| shared-sidecar | sequential | 774.17ms | 627.58ms | 610.76ms | 619.24ms | 616.20ms | +| shared-sidecar | concurrent | 775.66ms | 3493.51ms | 3197.96ms | 3187.99ms | 3235.65ms | +| resident-runner | sequential | 351.00ms | 202.61ms | 187.92ms | 186.10ms | 189.76ms | +| resident-runner | concurrent | 347.91ms | 205.00ms | 187.45ms | 187.07ms | 191.33ms | + +Takeaways: + +- **owned-sidecar** holds flat when sequential. Run concurrently, per-runtime cold time rises to about 1040ms as many runtimes mount their WASM command sets at once and contend for CPU. Each runtime still has its own sidecar, so they make progress in parallel. +- **shared-sidecar** is efficient sequentially (around 610ms once the shared sidecar is warm) but degrades sharply under concurrency (3000ms or more), because one sidecar process serializes the heavy concurrent mount and first-exec work. Share a sidecar when work arrives sequentially or at low concurrency, not for bursty parallel fan-out. +- **resident-runner** improves with batch size as the one-time runner creation amortizes, settling around 187ms cold and about 1.3ms warm regardless of mode. + +## Choosing a strategy + +- Need strong isolation and unpredictable, bursty load: use **owned-sidecar** (the default). Each runtime is its own crash and resource domain. +- Running many short tasks back to back where a shared failure domain is acceptable: a **shared-sidecar** amortizes setup. +- Running the same kind of small snippet over and over (for example an evaluation loop): a **resident-runner** turns a roughly 450ms warm execution into a roughly 1.3ms one. + +See [Process Isolation](/docs/process-isolation) for what each of these shares and isolates. + +## Methodology + +```text +CPU: 12th Gen Intel(R) Core(TM) i7-12700KF +Cores: 20 | Max concurrency: 16 | Max live runtimes: 8 +RAM: 62.6 GB | Node: v24.13.0 +Iterations: 5 (+ 1 warmup) +Batch sizes: 1, 10, 50, 100, 200 +Scenarios: owned-sidecar, shared-sidecar, resident-runner +Sidecar: release build of secure-exec-sidecar +``` + +Cold start is wall time from the start of runtime provisioning through the first guest execution. Warm is a second execution on the already-running runtime. For shared-sidecar, the one-time sidecar setup (around 4ms to 5ms) is measured separately and excluded from the per-runtime cold number. Phase timings (`sidecar_spawn`, `session_open`, `vm_create`, `runtime_mount_wasm`, `first_exec`, and the resident-runner phases) are recorded in the machine-readable JSON output. + +## Running the benchmarks + +Build a release sidecar first for meaningful timings: + +```bash +cargo build --release -p secure-exec-sidecar +``` + +Run the full suite: + +```bash +pnpm --dir packages/benchmarks bench +``` + +Run only the cold start matrix: + +```bash +SECURE_EXEC_SIDECAR_BIN="$PWD/target/release/secure-exec-sidecar" \ + pnpm --silent --dir packages/benchmarks bench:coldstart \ + > packages/benchmarks/results/coldstart-local.json \ + 2> packages/benchmarks/results/coldstart-local.log +``` + +A quick smoke run keeps it to one iteration: + +```bash +BENCH_BATCH_SIZES=1 \ +BENCH_ITERATIONS=1 \ +BENCH_WARMUP=0 \ +BENCH_SCENARIOS=owned-sidecar,shared-sidecar,resident-runner \ +SECURE_EXEC_SIDECAR_BIN="$PWD/target/release/secure-exec-sidecar" \ + pnpm --silent --dir packages/benchmarks bench:coldstart +``` diff --git a/website/src/content/docs/docs/comparison/cloudflare-workers.mdx b/website/src/content/docs/docs/comparison/cloudflare-workers.mdx new file mode 100644 index 000000000..09ad9f3bf --- /dev/null +++ b/website/src/content/docs/docs/comparison/cloudflare-workers.mdx @@ -0,0 +1,121 @@ +--- +title: Secure Exec vs Cloudflare Workers +description: How Secure Exec and Cloudflare Workers differ in isolation model, permissions, networking, and Node.js compatibility. +--- +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Secure Exec and Cloudflare Workers both run untrusted JavaScript inside V8, but they solve different problems. Workers is a managed, multi-tenant edge platform: you deploy code and Cloudflare runs it for you across its network. Secure Exec is a library you embed in your own application to run guest code locally inside a fully virtualized VM that you control. This page focuses on how the two differ in isolation, permissions, networking, and Node.js surface. + +## At a glance + +| | Secure Exec | Cloudflare Workers | +| --- | --- | --- | +| **Form factor** | Library you embed (`secure-exec`), runs where your app runs | Managed edge platform you deploy to | +| **Isolation unit** | Per runtime: each `NodeRuntime.create()` is its own VM and OS process | Per Worker isolate, scheduled by Cloudflare | +| **Guest runtime** | V8 isolate inside a virtualized POSIX kernel (filesystem, processes, sockets, PTYs) | V8 isolate with the `workerd` runtime | +| **Permissions** | Deny-by-default capability policy you configure per runtime | Platform-managed; no per-call capability policy | +| **Subprocesses** | Real `node:child_process` against kernel-managed processes | Not available | +| **Filesystem** | Full virtualized filesystem per runtime | Limited in-memory `node:fs` surface, ephemeral | +| **You operate it** | Yes (your process, your machine) | No (Cloudflare operates it) | + +## Isolation model + +This is the most important difference, and the easiest one to misstate. + +Cloudflare Workers isolates code dynamically. Cloudflare's runtime schedules many Workers into a pool of V8 isolates and can move work between them, with platform-level mitigations (such as I/O-gated clock coarsening) layered on to defend against side-channel attacks across that shared infrastructure. + +Secure Exec isolates code statically, one boundary per runtime. Every `NodeRuntime.create()` boots a fully virtualized VM backed by its own sidecar process. Two runtimes share nothing: not the filesystem, not globals, not module state, and not crash fate. Secure Exec does not reassign guest code between processes at runtime, and it does not claim Cloudflare-style dynamic, cross-tenant isolate scheduling. You decide the blast radius by deciding how many runtimes to create and what to put in each one. + +Within a single runtime, every `exec()` or `run()` call runs in a fresh guest process, so in-memory state from one run does not leak into the next. + + + +The practical consequence: with Workers you trust Cloudflare's platform to keep tenants apart. With Secure Exec you get a hard, statically-defined boundary per runtime that you place yourself, which is well suited to running one tenant or one task per runtime and disposing it when finished. + +## Permissions + +Cloudflare Workers does not expose a per-invocation capability policy. What a Worker can reach is governed by its bindings and platform configuration. + +Secure Exec applies a deny-by-default capability policy per runtime. Network access is denied until you opt in; the virtualized filesystem, child-process, process, and env scopes are enabled so programs can run at all (the guest only ever sees the VM, never the real host). You tighten or widen any scope when you create the runtime. + +```ts +import { NodeRuntime } from "secure-exec"; + +// Default: no network access. Filesystem and processes are virtualized. +const sandboxed = await NodeRuntime.create(); + +// Opt into network access while keeping the other defaults. +const networked = await NodeRuntime.create({ + permissions: { network: "allow" }, +}); +``` + + + +## Networking + +Inside the VM, guest `fetch()` runs through undici in the V8 isolate and then through the kernel's socket table, gated by the network permission. Guest code can also bind ports inside the VM: Secure Exec exposes `findListener()` and `waitForListener()` so the host can detect when a guest process starts listening (for example, to wait for an in-VM HTTP server before sending it a request). + +From the host side, `rt.fetch(port, input)` drives an HTTP request into a guest server listening inside the VM and reads the response back. The request never leaves the VM (it connects to the guest's loopback listener through the kernel socket table), so it works even when guest network egress is denied. + +Cloudflare Workers provides outbound `fetch()` and a Sockets API for outbound TCP/TLS, but a Worker does not listen on a port the way a normal server does; it responds to incoming requests routed by the platform. + +```ts +import { NodeRuntime } from "secure-exec"; + +const rt = await NodeRuntime.create(); +try { + // Start a long-running guest HTTP server and get a live handle back. + const server = await rt.spawn(` + import http from "node:http"; + http.createServer((_req, res) => res.end("ok")).listen(3000); + `); + + // Block until the listener is up, then drive a request into it from the host. + await rt.waitForListener({ port: 3000 }); + const res = await rt.fetch(3000, { path: "/" }); + console.log(res.status, res.body); // 200 ok + + server.kill(); + await server.wait(); +} finally { + await rt.dispose(); +} +``` + +## Subprocesses and the POSIX surface + +Secure Exec presents a virtualized POSIX environment. Guest code can use `node:child_process` to spawn commands that exist inside the VM (such as `sh` and the mounted coreutils), and every child is a kernel-managed process, never a real host process. The host can also start a long-running guest program and drive it with `rt.spawn()`. + +Cloudflare Workers has no subprocess model. There is no `child_process`, no process table, and no shell. + + + +## Node.js compatibility + +Both runtimes aim to present Node.js semantics on top of V8, and both implement a large subset of the Node.js standard library. The character of the gaps differs: + +- **Secure Exec** is backed by a real virtualized kernel, so capabilities that need an OS, a full filesystem, real child processes, sockets that listen, and a process table, are first-class. Pure-JS builtins are provided through `node-stdlib-browser`, and kernel-backed modules (such as `fs`, `net`, `child_process`, `dns`, `http`, `os`) are wired through the bridge to the kernel. +- **Cloudflare Workers** provides Node.js compatibility through the `nodejs_compat` flag on top of `workerd`. Its filesystem is a limited in-memory surface, there is no subprocess support, and listening servers are replaced by the request/response handler model. In exchange it ships a comprehensive native `node:crypto` and Web Crypto surface and platform-native logging. + + + +## When to choose which + +Choose **Cloudflare Workers** when you want a managed, globally distributed platform to deploy your own trusted code to, with the operations handled for you. + +Choose **Secure Exec** when you need to run untrusted or AI-generated code inside your own application with a hard, self-placed isolation boundary, a real virtualized filesystem and process model, and a capability policy you control. One runtime per tenant or per task gives you a clean blast radius you can dispose on demand. + + diff --git a/website/src/content/docs/docs/comparison/sandbox.mdx b/website/src/content/docs/docs/comparison/sandbox.mdx new file mode 100644 index 000000000..127640886 --- /dev/null +++ b/website/src/content/docs/docs/comparison/sandbox.mdx @@ -0,0 +1,90 @@ +--- +title: Secure Exec vs Container Sandbox +description: When to use a container sandbox versus Secure Exec for running untrusted code. +--- +import { Aside } from '@astrojs/starlight/components'; + +Secure Exec and container sandboxes both run untrusted code in isolation, but they sit at different points on the weight-versus-flexibility curve. Picking the right one depends on what you need to run. + +**Secure Exec** boots a fully virtualized VM for each runtime and runs guest JavaScript in a V8 isolate inside a dedicated sidecar process. There is no Docker daemon, no orchestrator, and no vendor account: you `npm install` the package and call `NodeRuntime.create()`. It is built for lightweight, high-fanout code execution like AI tool calls, user scripts, and plugins, where you want granular permissions and a small footprint. + +**Container sandboxes** (e2b, Daytona, Modal, Cloudflare Containers, and similar) spin up a full OS image with system packages, a writable disk, and the ability to run arbitrary binaries. They are built for heavyweight workloads that need a complete environment: coding agents, long-lived dev sessions, or anything that shells out to native tools. + +## How isolation works + +The biggest difference is what the isolation boundary is made of. + +- **Secure Exec** runs guest code in a V8 isolate hosted by its own sidecar process. Every `NodeRuntime.create()` is a separate VM with its own virtual filesystem, process table, network policy, and crash domain. Guest code never calls real Node.js builtins, never opens a real host socket, and never touches the real disk. Every syscall is routed through the kernel. See [Process Isolation](/docs/process-isolation). +- **Container sandboxes** isolate with OS-level primitives (namespaces, cgroups, or a microVM). The guest runs a real kernel and a real filesystem, so it can do anything a Linux process can, constrained by the container's configuration. + +Secure Exec presents normal Linux semantics to the code it runs (a POSIX-like virtual filesystem, processes, pipes, PTYs, sockets) without granting access to the host that backs them. + +## Comparison + +| Dimension | Secure Exec | Container Sandbox | +| --- | --- | --- | +| Isolation boundary | Virtualized VM + V8 isolate in a sidecar process | OS container or microVM | +| Setup | `npm install secure-exec` | Vendor account or Docker host | +| Hardware | Runs on your infrastructure | Often vendor-hosted | +| Filesystem | Virtual, in-memory, per runtime | Full OS filesystem | +| Network | Denied by default, opt-in per runtime | Full, or firewall rules | +| Permissions | Per-scope policy (fs, network, childProcess, process, env, tool) | Coarse, container-level | +| Languages | JavaScript and TypeScript (Node-compatible) | Any language the image supports | +| Arbitrary binaries | No (guest binaries run through the VM, not the host) | Yes | +| Crash domain | One process per runtime | One container per sandbox | + +## What Secure Exec gives you + +These capabilities are the reason to reach for Secure Exec over a container when the workload fits in a Node-compatible runtime: + +- **Deny-by-default permissions.** Network egress is blocked until you opt in, and every guest syscall is checked against a per-scope policy before any host resource is touched. A denied operation fails with `EACCES`. See [Permissions](/docs/features/permissions). +- **Virtual filesystem.** Each runtime gets its own in-memory filesystem. Guest reads and writes never reach the host disk, and two runtimes writing the same path do not collide. See [Filesystem](/docs/features/filesystem). +- **Mediated networking.** Guest `fetch()`, `node:http`, and raw sockets all flow through the kernel socket table, so you can allow, deny, or rule-match outbound traffic. See [Networking](/docs/features/networking). +- **Process-level isolation.** Each runtime is its own VM and its own crash domain, and each `exec()` / `run()` starts a fresh guest process so in-memory state never leaks between runs. See [Process Isolation](/docs/process-isolation). +- **npm compatibility.** Real npm packages run unmodified inside the VM, resolved over a faithfully mounted `node_modules` like a real filesystem. + +## When to use each + +### Use Secure Exec when + +- You are running **JavaScript or TypeScript** (AI tool calls, user scripts, plugins, evaluation loops). +- You want **no vendor dependency** and to run on your own infrastructure. +- You need **granular, deny-by-default permissions** over the filesystem, network, and child processes. +- You are running **many short tasks** and want a small per-task footprint. + +### Use a container sandbox when + +- You need a **full OS environment**: system packages, arbitrary binaries, or languages beyond a Node-compatible runtime. +- You need a **persistent, long-lived** environment such as a multi-hour dev session. +- The workload genuinely needs a real kernel and a real disk. + + + +## A minimal example + +Booting a Secure Exec runtime takes no infrastructure beyond the installed package. The runtime owns its VM until you dispose it: + +```ts +import { NodeRuntime } from "secure-exec"; + +// Network is denied by default; the guest runs in its own virtualized VM. +const rt = await NodeRuntime.create(); +try { + const result = await rt.run(` + __return(40 + 2); + `); + console.log(result.value); // 42 +} finally { + await rt.dispose(); +} +``` + +There is no container to build, no image to pull, and no daemon to keep running. + +## Performance + +Because there is no container image or microVM to boot, a Secure Exec runtime starts in a fraction of the time a container takes, and the dominant cost is bringing up the guest runtime rather than provisioning infrastructure. For workloads that run the same kind of snippet repeatedly, reusing a live guest process drives warm execution into the low-millisecond range. See [Benchmarks](/docs/benchmarks) for measured cold-start, warm, and reuse numbers and the methodology behind them. diff --git a/website/src/content/docs/docs/cost-evaluation.mdx b/website/src/content/docs/docs/cost-evaluation.mdx new file mode 100644 index 000000000..c7483ffa6 --- /dev/null +++ b/website/src/content/docs/docs/cost-evaluation.mdx @@ -0,0 +1,52 @@ +--- +title: Cost Evaluation +description: How Secure Exec compares on cost to per-second sandbox providers when you run it on your own hardware. +--- +import { Aside } from '@astrojs/starlight/components'; + +Secure Exec is a library you run on hardware you already control, not a metered service. That changes the cost model from "pay a provider per sandbox-second" to "pay for the compute you provision, then pack as much work onto it as it can hold." This page explains where the savings come from and how to reason about them honestly. It does not publish a single magic multiplier, because the real number depends on your workload, your hardware, and how you share runtimes. + + + +## Where the savings come from + +Two structural differences drive the cost gap versus per-second sandbox providers: + +- **You run on your own hardware.** You choose the cloud, instance type, architecture, and region. A small commodity instance (for example an ARM VM from a budget host) costs a flat hourly or monthly rate that is typically far below what per-sandbox-second billing adds up to once you have steady traffic. You also avoid egress fees and vendor lock-in. +- **You decide the isolation granularity.** Sandbox providers bill a full container or microVM per execution, usually with a minimum memory reservation that you pay for even when your code uses a fraction of it. With Secure Exec you own the runtime lifecycle: you can dedicate a runtime per tenant or per task for maximum isolation, or amortize setup by reusing one runtime (or one sidecar) across many runs. See [Process Isolation](/docs/process-isolation). + +## The isolation model matters for cost + +Each `NodeRuntime.create()` boots a fully virtualized VM backed by its own sidecar process, and each `exec()` / `run()` inside it is a fresh guest process. That gives you a dial between isolation and density: + +- **One runtime per task or tenant (strongest isolation).** Create a runtime, run the work, and dispose it, or give each tenant its own runtime. Each runtime is its own crash and resource domain, with the highest per-runtime overhead. Best when load is untrusted or bursty. +- **A shared runtime for trusted work.** Reuse one runtime across many runs to amortize the VM boot cost. Each `exec()` / `run()` still executes in a fresh guest process, so in-memory state does not leak between runs, but the VM and filesystem are shared. Good for trusted, sequential work. + +The denser you can safely pack work onto an instance, the lower your effective cost per execution. See [Process Isolation](/docs/process-isolation) for how to choose the granularity that fits your workload. + +## How to estimate your own cost + +Because Secure Exec runs on hardware you provision, the honest way to compare is to plug in your own numbers: + +1. **Pick your hardware and its rate.** Take the hourly or monthly price of the instance you would run on, and divide down to a per-second instance cost. +2. **Estimate how many concurrent runtimes fit.** Run the memory benchmark (`packages/benchmarks/memory.bench.ts`) on your target hardware to measure per-runtime overhead under your isolation strategy, then divide your usable RAM by that figure. Leave headroom (the benchmark and any orchestration layer will not bin-pack perfectly). +3. **Divide instance cost by concurrent runtimes.** That gives a cost-per-runtime-second you can compare against a provider's per-sandbox-second rate. + + + +## Comparing against sandbox providers + +When you do compare against a per-second sandbox provider, hold the methodology honest: + +- **Sandbox cost** is the provider's minimum allocatable memory times their per-GiB-second rate (plus any egress and platform fees). The minimum reservation is the floor you pay even for tiny workloads. +- **Secure Exec cost** is your instance cost per second divided by the number of runtimes you can keep live on it, with realistic headroom for bin-packing inefficiency. + +The advantage is largest for **many small, short executions**, where a per-sandbox minimum reservation dominates and your own hardware lets you pack densely. It narrows for **heavyweight, long-lived workloads** (for example dev servers that need hundreds of megabytes regardless), where the win shifts from density to hardware choice: you still avoid per-second metering, egress fees, and lock-in, but the raw memory-density advantage is smaller. + +| Workload | Primary cost advantage | +| --- | --- | +| Many small, short executions | Density: pack many runtimes per instance, no per-sandbox minimum | +| Heavyweight, long-lived workloads | Hardware choice, flat instance pricing, no egress or lock-in | +| High concurrency | Reuse a runtime across runs to amortize VM boot cost | + + diff --git a/website/src/content/docs/docs/nodejs-compatibility.mdx b/website/src/content/docs/docs/nodejs-compatibility.mdx new file mode 100644 index 000000000..8a5fb483a --- /dev/null +++ b/website/src/content/docs/docs/nodejs-compatibility.mdx @@ -0,0 +1,129 @@ +--- +title: Node.js Compatibility +description: Which node builtins guest code can import in Secure Exec, and how each one is backed. +--- +import { Aside } from '@astrojs/starlight/components'; + +Guest code in Secure Exec runs as Node.js. It never touches the host runtime: every guest `import`/`require` of a `node:` builtin resolves to a kernel-backed bridge or an in-isolate polyfill, never the real host module. This page describes which builtins are available and how each one is implemented. + +The guest reports itself as Node `v22.0.0` (`process.version`). + +## How builtins are backed + +There are three ways a builtin is provided to guest code: + +- **Bridge.** A kernel-backed implementation. Calls route through the kernel VFS, socket table, process table, DNS resolver, or host entropy. This is how `fs`, `net`, `http`, `child_process`, `dns`, `os`, and `crypto` reach virtualized resources while staying inside the isolation boundary. +- **Polyfill.** A pure-JavaScript implementation from `node-stdlib-browser` (for example `path`, `events`, `util`, `stream`, `zlib`). These need no host access; they run entirely inside the V8 isolate. +- **Denied.** The module is intentionally unavailable. Importing it throws an error with code `ERR_ACCESS_DENIED`. + +The canonical inventory lives in `crates/execution/assets/polyfill-registry.json`. + + + +## Bridge-backed builtins + +These route through the kernel and present normal Linux/Node semantics over virtualized resources. + +| Module | Backed by | +| --- | --- | +| `fs`, `fs/promises` | Kernel VFS. File and directory operations, fds, `createReadStream`/`createWriteStream`, metadata, symlinks. `watch`/`watchFile` are guest-side polling wrappers. | +| `child_process` | Kernel process table. `spawn`, `exec`, `execFile`, `spawnSync`, `execSync`, `execFileSync` (and the sync variants) run kernel-managed processes. See [Child Processes](/docs/features/child-processes). | +| `net` | Kernel socket table. TCP client and server sockets, plus Unix sockets. | +| `dns` | Kernel DNS resolver (`lookup`, `resolve*`, and the `dns/promises` surface). | +| `http`, `https`, `http2` | Built on the kernel socket path. `request`, `get`, `createServer`, agents with connection pooling. | +| `tls` | Layered on the kernel `net` polyfill. | +| `os` | VM-scoped values (platform, arch, hostname, CPU/memory, user info, `os.constants`). | +| `crypto` | Host entropy and crypto bridges: `getRandomValues`, `randomUUID`, `randomBytes`, `createHash`, `createHmac`, cipher/decipher, scrypt, and `crypto.subtle` (WebCrypto). | +| `process` | Virtualized `process` global: env (permission-gated), `cwd`/`chdir`, signals, timers, stdio, `umask`. | +| `module` | `createRequire`, `Module` basics, builtin resolution, `Module.builtinModules`. | +| `console` | Bridge shim with circular-safe formatting. By default output is dropped; stream it via the `onStdout`/`onStderr` hooks on `exec`/`run`/`spawn`. | +| `dgram` | Kernel socket table (UDP). | +| `perf_hooks`, `diagnostics_channel`, `async_hooks` | Bridge-backed compatibility surfaces. | +| `worker_threads` | Compatibility shim: `isMainThread` and inert ports for feature detection. Real worker threads are not spawned. | +| `vm` | Compatibility shim: `Script`, `createContext`, `isContext`, `runInNewContext`, `runInThisContext`. | +| `v8` | Compatibility shim for safe inspection/serialization helpers. | +| `tty` | `isatty` plus `ReadStream`/`WriteStream` compatibility constructors. | +| `readline`, `sqlite` | Bridge-backed compatibility surfaces. | +| `timers`, `timers/promises` | `setTimeout`, `setInterval`, `setImmediate`, and promise variants. | +| `stream/web`, `stream/consumers`, `stream/promises` | Web Streams and stream helper subpaths. | + +Network builtins (`net`, `dgram`, `dns`, `http`, `https`, `http2`, `tls`) are subject to the per-runtime permission policy, which **denies network access by default**. Operations fail until you opt in. See [Permissions](/docs/features/permissions). + +## Polyfilled builtins + +Pure-JavaScript implementations from `node-stdlib-browser`, running inside the isolate. They support default and named ESM imports. + +| Module | Polyfill | +| --- | --- | +| `path`, `path/posix`, `path/win32` | `path-browserify` | +| `buffer` | `buffer` (also re-exports `Blob`/`File`) | +| `events` | `events` | +| `stream` | `readable-stream` | +| `util`, `util/types` | node-stdlib-browser | +| `assert` | node-stdlib-browser | +| `url` | node-stdlib-browser shims | +| `querystring` | node-stdlib-browser | +| `string_decoder` | node-stdlib-browser | +| `zlib` | node-stdlib-browser | +| `punycode` | node-stdlib-browser | +| `constants` | `constants-browserify` (`os.constants` stays available via `os`) | +| `console`, `timers` | node-stdlib-browser base, with bridge wiring for stdio and the kernel clock | +| `sys` | alias of `util` | + +## Denied builtins + +Importing any of these throws an error with code `ERR_ACCESS_DENIED`: + +`cluster`, `domain`, `inspector`, `repl`, `trace_events`, `wasi`. + +## Global APIs + +Web platform globals expected by modern npm packages are provided in the isolate, including `fetch`, `Headers`, `Request`, and `Response`. Guest `fetch()` runs through undici inside the V8 isolate and then through the kernel socket table, so it obeys the same network permissions as the `http`/`net` builtins. `TextEncoder`/`TextDecoder`, `Buffer`, `URL`/`URLSearchParams`, `Blob`/`File`, `FormData`, `AbortController`/`AbortSignal`, `structuredClone`, and `performance` are also available. + +WebAssembly is enabled inside the isolate (`WebAssembly.Module`, `WebAssembly.Instance`, `WebAssembly.instantiate*`), so packages that ship `.wasm` work. Compilation stays inside the isolate and does not cross the isolation boundary. + +## Restricting the builtin surface + +By default a `NodeRuntime` presents the full Node platform described above. The lower-level VM config (`CreateVmConfig.jsRuntime`) can change this: a non-`node` `platform` (`browser`, `neutral`, or `bare`) exposes no Node builtins, and under the `node` platform an explicit `allowedBuiltins` list narrows the set (an empty list denies all). This mirrors esbuild's `platform` vocabulary. Anything excluded becomes a denied builtin (`ERR_ACCESS_DENIED`). + +## Logging behavior + +- `console.log`/`warn`/`error` serialize arguments with circular-safe, bounded formatting. +- `exec()` and `run()` results do not expose buffered `stdout`/`stderr` fields. +- By default the runtime drops console output instead of buffering it. +- To capture logs, pass the `onStdout`/`onStderr` hooks, which receive raw `Uint8Array` chunks in emission order. + +## TypeScript + +The core runtime executes JavaScript. For sandboxed TypeScript usage, see [TypeScript](/docs/features/typescript). + +## Example + +Import a mix of bridge-backed and polyfilled builtins from guest code: + +```ts +import { NodeRuntime } from "secure-exec"; + +const rt = await NodeRuntime.create(); + +try { + const { stdout } = await rt.exec(` + import path from "node:path"; + import { createHash } from "node:crypto"; + import { writeFileSync, readFileSync } from "node:fs"; + + const file = path.join("/tmp", "note.txt"); + writeFileSync(file, "hello"); + + const digest = createHash("sha256") + .update(readFileSync(file)) + .digest("hex"); + + console.log(digest); + `); + + console.log(stdout.trim()); +} finally { + await rt.dispose(); +} +``` diff --git a/website/src/content/docs/docs/process-isolation.mdx b/website/src/content/docs/docs/process-isolation.mdx new file mode 100644 index 000000000..cee48d048 --- /dev/null +++ b/website/src/content/docs/docs/process-isolation.mdx @@ -0,0 +1,141 @@ +--- +title: Process Isolation +description: Each runtime is its own isolated process, and each run inside it is a fresh guest process. +--- +import { Aside } from '@astrojs/starlight/components'; + +Secure Exec isolates guest code at the process level. The unit of isolation is the **runtime**: every `NodeRuntime.create()` boots a fully virtualized VM backed by its own sidecar process. Two runtimes share nothing, not the filesystem, not globals, not module state, and not crash fate. Inside a single runtime, every `exec()` or `run()` call executes in a fresh guest process, so one run cannot leak in-memory state into the next. + +This is the same principle as process isolation in browser and edge runtimes: untrusted code from different sources should not share a memory space or a failure domain. Secure Exec applies it statically, one isolation boundary per runtime, rather than reassigning code between processes at runtime. + +## Two boundaries + +There are two boundaries to reason about: + +1. **Between runtimes.** Each runtime is its own VM with its own virtual filesystem, process table, network policy, and memory. Nothing crosses from one runtime to another. +2. **Between runs in the same runtime.** Each `exec()` / `run()` starts a brand new guest process. Globals, module caches, and in-memory state do not survive from one run to the next. + +The example below exercises both: two runtimes write the same path without colliding, and two consecutive runs in one runtime each start with a clean global. + +```ts +import { NodeRuntime } from "secure-exec"; + +// Boot two independent VMs. Each is its own isolation domain. +const rtA = await NodeRuntime.create(); +const rtB = await NodeRuntime.create(); + +try { + // --- 1. Filesystem isolation between two runtimes --------------------- + // Both runtimes write to the exact same path. Because each runtime has its + // own virtual filesystem, the writes never collide. + await rtA.exec(` + import { writeFileSync } from "node:fs"; + writeFileSync("/tmp/shared-path.txt", "data from runtime A"); + `); + await rtB.exec(` + import { writeFileSync } from "node:fs"; + writeFileSync("/tmp/shared-path.txt", "data from runtime B"); + `); + + // `run()` wraps the body in an async function, so use dynamic `import()` + // (top-level `import` statements only work in `exec()`). + const readA = await rtA.run(` + const { readFileSync } = await import("node:fs"); + __return(readFileSync("/tmp/shared-path.txt", "utf8")); + `); + const readB = await rtB.run(` + const { readFileSync } = await import("node:fs"); + __return(readFileSync("/tmp/shared-path.txt", "utf8")); + `); + + console.log("runtime A reads back:", JSON.stringify(readA.value)); + console.log("runtime B reads back:", JSON.stringify(readB.value)); + console.log( + "filesystems isolated:", + readA.value === "data from runtime A" && + readB.value === "data from runtime B", + ); + + // --- 2. Each run is a fresh process (no shared globals) ---------------- + // The first run sets a global. The second run, in the SAME runtime, observes + // a clean global state because it is a brand new guest process. + const firstRun = await rtA.run(` + globalThis.__counter = (globalThis.__counter ?? 0) + 1; + __return(globalThis.__counter); + `); + const secondRun = await rtA.run(` + globalThis.__counter = (globalThis.__counter ?? 0) + 1; + __return(globalThis.__counter); + `); + + console.log("run 1 counter:", firstRun.value); + console.log("run 2 counter:", secondRun.value); + console.log( + "globals reset per run:", + firstRun.value === 1 && secondRun.value === 1, + ); +} finally { + // Each runtime owns its VM and must be disposed independently. + await rtA.dispose(); + await rtB.dispose(); +} +``` + +*[See Full Example](https://github.com/rivet-dev/secure-exec/tree/main/examples/docs/process-isolation)* + +Output: + +``` +runtime A reads back: "data from runtime A" +runtime B reads back: "data from runtime B" +filesystems isolated: true +run 1 counter: 1 +run 2 counter: 1 +globals reset per run: true +``` + +## What a runtime isolates + +Each runtime is a separate VM. Across runtimes, none of the following is shared: + +- **Filesystem.** Every runtime has its own virtual filesystem. The same path in two runtimes refers to two different files. +- **Globals and module state.** Guest globals, module caches, and singletons live only inside one runtime. +- **Memory.** Each runtime runs in its own process, so a heap blow-up in one cannot corrupt another. +- **Process table.** Child processes spawned via `node:child_process` are kernel-managed and visible only within their runtime. +- **Network policy.** Permissions (including the default network deny) apply per runtime. See [Permissions](/docs/features/permissions). +- **Crash domain.** If one runtime's process dies, only that runtime is affected. Other runtimes keep running. + +## Under the hood + +A `NodeRuntime` is the ergonomic entry point. Under it, each runtime owns one `SidecarProcess`, the low-level handle to the spawned, virtualized sidecar process. `SidecarProcess` is exported from `@secure-exec/core` for advanced callers that want to drive sessions, VMs, and the wire protocol directly: + +```ts +import { SidecarProcess } from "@secure-exec/core/sidecar-process"; +``` + +Most applications never need this. Reach for `SidecarProcess` only when you are building your own runtime facade rather than using `NodeRuntime`. + +## Choosing isolation granularity + +Because isolation is per runtime, you control the blast radius by deciding how many runtimes to create and what to put in each: + +- **One runtime per tenant.** Give each tenant its own runtime so a crash or resource exhaustion from one tenant cannot disrupt another. +- **One runtime per task.** For untrusted or high-risk code, create a runtime, run the task, and dispose it. The next task starts from a clean VM. +- **A shared runtime for trusted work.** When code is trusted and you want to amortize startup cost, reuse one runtime across many runs. Each run still gets a fresh guest process, so in-memory state does not leak, but the filesystem and VM are shared across runs. + + + +## Lifecycle + +The caller owns each runtime and is responsible for disposing it: + +```ts +const rt = await NodeRuntime.create(); +try { + // ... use rt ... +} finally { + await rt.dispose(); +} +``` + +Disposing a runtime tears down its sidecar process and frees the VM. Runtimes are independent, so disposing one never affects another. diff --git a/website/src/content/docs/docs/runtime-modes.mdx b/website/src/content/docs/docs/runtime-modes.mdx new file mode 100644 index 000000000..90ffd8891 --- /dev/null +++ b/website/src/content/docs/docs/runtime-modes.mdx @@ -0,0 +1,127 @@ +--- +title: Native and Browser Modes +description: Secure Exec runs the same architecture in two modes, a native sidecar process and an in-browser sidecar Worker. This page explains both and how their transports differ. +--- +import { Aside } from '@astrojs/starlight/components'; + +Secure Exec runs in two modes: **native** (Node.js, Deno, Bun, or any process that can spawn the sidecar binary) and **browser** (the runtime running entirely inside the page). Both modes use the exact same architecture. Only the carriers that connect the pieces change, because a browser tab cannot spawn a process or share memory the way a native host can. + +This page is the companion to [Architecture](/docs/architecture). It focuses on the two communication paths that make the runtime work, and on how those paths are implemented in each mode. + + + +## One model, two modes + +Every Secure Exec runtime is built from three roles: + +- **Client.** The trusted caller. It speaks the wire protocol and asks the sidecar to create VMs and run code. It never runs guest code itself. +- **Sidecar.** The trusted enforcement point. It owns the kernel (virtual filesystem, process and socket tables, pipes, PTYs, permission policy, DNS) and creates executors on demand. +- **Executor.** The untrusted leaf. It runs guest JavaScript and reaches the kernel for every syscall. It holds no capabilities of its own. + +Because of that shared shape, there are always exactly **two communication hops**: + +1. **Client to sidecar:** a framed request/response (plus events) protocol. +2. **Executor to kernel:** a synchronous, blocking syscall path where the executor waits until the kernel services the request. + +The difference between native and browser is entirely in how each hop is carried. + +## Native mode + +In native mode the kernel and the executor live in **one sidecar process**, on different threads. + +``` + client process sidecar process +┌────────────────────┐ stdio ┌────────────────────────────────┐ +│ NodeRuntime │ ◀══════════════▶│ kernel (VFS, processes, perms) │ +│ (secure-exec) │ wire protocol │ ▲ │ +│ SidecarProcess │ │ │ in-process channel │ +└────────────────────┘ │ ┌──┴── V8 isolate ──┐ │ + │ │ (own OS thread) │ guest JS│ + │ └───────────────────┘ │ + └────────────────────────────────┘ +``` + +- **Client to sidecar** travels over **stdio pipes**. The client spawns the `secure-exec-sidecar` binary and writes requests to its stdin, reading responses and events from its stdout. +- **Executor to kernel** travels over an **in-process channel**. The guest runs in a V8 isolate on its own OS thread. A guest syscall parks that thread on the channel until the kernel replies. Because both sides share the same address space, no shared-memory marshalling is needed beyond the message itself. + +## Browser mode + +In the browser there is no process to spawn and no shared address space, so the same three roles are split across **three realms**: the page's main thread, a dedicated sidecar Worker, and one or more executor Workers. + +``` + main thread sidecar Worker executor Worker(s) +┌────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ +│ BrowserRuntime │ post │ kernel (WASM) │ spawn │ guest JS in a │ +│ (@secure-exec/ │◀════▶│ VFS, processes, │══════▶│ Web Worker │ +│ core) │ Msg │ sockets, perms │ │ │ +└────────────────┘ │ ▲ │◀═════▶│ │ + ▲ └────────────│──────────┘ Shared└──────────────────┘ + │ host callbacks │ Array + │ (OPFS, fetch, clock, └─ Atomics ──┘Buffer + │ random, DNS, spawn Worker) + └─────────────────────────────────────────────────────────────── +``` + +- **Client to sidecar** travels over **`postMessage`** between the main thread and the sidecar Worker. The kernel is compiled to WebAssembly and runs inside that Worker. +- **Executor to kernel** travels over a **SharedArrayBuffer** with **Atomics**. The guest runs as JavaScript inside an executor Worker. A guest syscall writes the request into shared memory, blocks the executor Worker with `Atomics.wait`, and the sidecar Worker wakes it with `Atomics.notify` once the kernel has serviced the request. + +The sidecar runs in its own Worker for a specific reason: the kernel blocks while servicing waits (pipes, polling, PTYs, waiting on a child). Blocking is forbidden on the page's main thread but allowed inside a Worker, so giving the kernel its own Worker lets it keep the same blocking model native uses without freezing the page. + +Capabilities the WASM kernel cannot provide on its own (real storage, network egress, time, randomness, DNS, and spawning the executor Worker) are requested back from the main thread through host callbacks, the browser counterpart to native's reverse calls into the trusted client. + +## Hop 1: client to sidecar + +This hop is the **same protocol over a different pipe**. The framed wire protocol, the request and response catalog, the asynchronous events, and the message correlation are identical in both modes. Only the bottom transport layer changes. + +| | Native | Browser | +|---|---|---| +| Boundary | separate OS process | separate Web Worker | +| Carrier | stdio pipes | `postMessage` | +| Framing | length-prefixed binary frames | the same frames (length prefix optional, since `postMessage` already delivers discrete messages) | +| Protocol engine | shared | shared (same client code) | +| Reverse calls for host capabilities | serviced by the native client (for example host-backed mounts via the host filesystem) | serviced by the main thread (OPFS, `fetch`, `Date`, `crypto`, a JS DNS resolver, and spawning the executor Worker) | + +Because the protocol is identical, the same client drives both modes. The native client exposes `NodeRuntime`; the browser client exposes a browser runtime built on the same `@secure-exec/core` foundation. + +```ts +// Native: spawns the sidecar binary and talks over stdio. +import { NodeRuntime } from "secure-exec"; + +// Browser: boots the WASM sidecar Worker and talks over postMessage. +import { BrowserRuntime } from "@secure-exec/browser"; +``` + +## Hop 2: executor to kernel + +This hop is the **same semantics over a different blocking primitive**. In both modes a guest syscall is synchronous from the guest's point of view: the executor stops and waits until the kernel, the trusted enforcement point, has serviced the request and applied the permission policy. What differs is how the executor waits. + +| | Native | Browser | +|---|---|---| +| Boundary | thread boundary, shared address space | realm boundary, no shared address space | +| Carrier | in-process channel | SharedArrayBuffer | +| Blocking primitive | the thread parks on a channel receive | `Atomics.wait` on shared memory | +| Executor engine | V8 isolate on an OS thread | JavaScript evaluated inside a Web Worker | +| Kernel form | native code | WebAssembly | + +The executor engine is the one piece that is necessarily different. V8 isolates cannot run inside a browser tab, so the browser executor evaluates guest JavaScript in a Web Worker instead. Both engines sit behind the same kernel syscall contract, so the kernel, the permission policy, and the guest-facing bridge are shared. + + + +## What is shared and what differs + +Shared across both modes: + +- The wire protocol, framing, and message correlation. +- The client, built on `@secure-exec/core`. +- The kernel: virtual filesystem, process and socket tables, pipes, PTYs, permission policy, and DNS. +- The guest-facing syscall contract and the permission model. + +Different by necessity: + +- **Client to sidecar carrier:** stdio pipes (native) versus `postMessage` (browser). +- **Executor to kernel carrier:** an in-process channel (native) versus SharedArrayBuffer with Atomics (browser). +- **Sidecar form:** a native binary in its own process (native) versus a WebAssembly module in a dedicated Worker (browser). +- **Executor engine:** a V8 isolate (native) versus JavaScript in a Web Worker (browser). +- **Host capabilities:** the operating system through the native client versus browser APIs (OPFS, `fetch`, `Date`, `crypto`) through the main thread. + +The result is one architecture and one mental model. Whichever mode you target, a runtime is a client talking to a kernel-owning sidecar that runs your guest code behind an isolation boundary, with the permission policy enforced on every syscall. diff --git a/website/src/content/docs/docs/security-model.mdx b/website/src/content/docs/docs/security-model.mdx new file mode 100644 index 000000000..84cc9fe9f --- /dev/null +++ b/website/src/content/docs/docs/security-model.mdx @@ -0,0 +1,125 @@ +--- +title: Security Model +description: How secure-exec isolates untrusted code, what it protects against, and what you are responsible for. +--- +import { Aside, LinkCard } from '@astrojs/starlight/components'; + +Secure Exec runs guest code inside a fully virtualized VM. Every `NodeRuntime.create()` boots its own kernel with a virtual filesystem, process table, socket table, pipes, PTYs, a permission policy, and managed language runtimes. Guest JavaScript executes in a V8 isolate, and every guest syscall is serviced by the kernel rather than the host. There are no host escapes: guest code cannot spawn a real host process, touch the real host filesystem, or open a real host network socket. + +Host access is deny-by-default for the network and is only available through capabilities you explicitly configure. + +## Runtime guarantees + +When you run guest code through a `NodeRuntime`, the runtime enforces: + +- **Kernel isolation.** Guest code runs in a V8 isolate inside the VM. It reaches the outside world only through kernel-owned VFS, process, socket, pipe, PTY, permission, and DNS paths. +- **Per-runtime containment.** Each runtime is its own VM backed by its own sidecar process. Two runtimes share no filesystem, globals, module state, memory, or crash fate. See [Process Isolation](/docs/process-isolation). +- **Network deny-by-default.** Guest sockets and outbound requests are blocked until you opt in with a `network` permission. Other scopes (filesystem, child processes, process, env) are enabled so normal programs run. +- **Resource and timing limits.** The VM bounds memory, CPU time, payload sizes, and (optionally) freezes high-resolution timers to blunt timing side channels. See [Resource Limits](/docs/features/resource-limits). + +## Trust boundaries + +There are two boundaries to reason about. + +### The runtime boundary + +This is the VM that Secure Exec gives you. Guest code is confined to a V8 isolate inside the kernel. Every syscall, file read, socket open, child-process spawn, and DNS lookup is mediated by the kernel and checked against the runtime's permission policy. This is the boundary Secure Exec owns and enforces for you. + +Concretely, the kernel mediates: + +- **Filesystem.** A virtual, in-memory filesystem. Guest reads and writes never reach the real host filesystem. Host data enters the VM only through `files`, `mounts`, or `nodeModules` that you configure explicitly. See [Virtual Filesystem](/docs/features/virtual-filesystem). +- **Processes.** `node:child_process` spawns kernel-managed guest processes, never real host processes. Children can only run the commands the VM mounts (WASM-backed `sh` and coreutils, V8-backed `node`). See [Child Processes](/docs/features/child-processes). +- **Network.** Guest `fetch()`, `node:http`, and raw sockets all flow through the kernel socket table. Guest `fetch()` runs through undici inside the isolate and then through the kernel socket table; it never opens a real host socket. See [Networking](/docs/features/networking). +- **Host tools.** Registered `tools` are the only sanctioned way to hand the guest a named host capability. The guest invokes a tool by name with JSON input, the call round-trips to the host handler, and only the handler's return value comes back. The guest never receives the underlying host access. + +### The host boundary + +This is your process: the Node.js application, container, or serverless function that creates the runtime. Your host code is trusted infrastructure, and you are responsible for hardening it. + +The runtime boundary protects the host from the guest. It does not harden your host process against everything else. For internet-facing workloads that take untrusted input, run your host inside an already-hardened environment (for example AWS Lambda, Google Cloud Run, or a similar sandboxed platform). + +Both boundaries matter. The VM alone is not enough without a hardened host, and a hardened host alone does not protect against code that runs with full host access inside your own process. + +## Isolation granularity + +Because isolation is per runtime, you decide the blast radius by deciding how many runtimes to create and what to put in each. A crash, resource exhaustion, or escape attempt is contained to a single runtime; other runtimes keep running. + +- **One runtime per tenant** so one tenant cannot disrupt another. +- **One runtime per task** for untrusted or high-risk code: create it, run the task, dispose it, and the next task starts from a clean VM. +- **A shared runtime for trusted work** to amortize startup cost. Each `exec()` / `run()` still gets a fresh guest process, so in-memory state does not leak between runs, but the filesystem and VM are shared. + +See [Process Isolation](/docs/process-isolation) for the full model. + +## What enters the VM + +The host filesystem is never exposed to the guest by default. Host data crosses the runtime boundary only through options you set on `NodeRuntime.create()`: + +- **`files`** seeds bytes into the virtual filesystem. The bytes are copied in; the host path is never exposed. +- **`mounts`** projects a host directory at a guest path, Docker-style. The guest sees only the mounted subtree, read through the VFS lazily, never the wider host filesystem. Mounts are read-only unless you pass `readOnly: false`. +- **`nodeModules`** projects a host `node_modules` directory (read-only, lazily) at a guest path so guest `import`/`require` resolves real installed packages. It defaults to `/tmp/node_modules`, where the resolution walk for `exec()` / `run()` programs begins. + +In every case the guest sees only the subtree you mount, and writes to read-only mounts are rejected. See [Module Loading](/docs/features/module-loading) for how resolution works over the projected tree. + +```ts +import { NodeRuntime } from "secure-exec"; + +const rt = await NodeRuntime.create({ + // Network stays denied by default; only opt in when you mean to. + permissions: { network: "deny" }, + files: { "/home/user/input.json": '{"ok":true}' }, + nodeModules: "/abs/path/to/project/node_modules", +}); + +try { + const result = await rt.exec(` + import { readFileSync } from "node:fs"; + console.log(readFileSync("/home/user/input.json", "utf8")); + `); + console.log(result.stdout.trim()); +} finally { + await rt.dispose(); +} +``` + +## Permissions + +Permissions are the capability gate at the runtime boundary. They are merged over a secure default that denies the network and enables the filesystem, child processes, process info, and env. Because the merge is partial, you name only the scope you change. + +```ts +// Grant network egress; everything else keeps the secure defaults. +const rt = await NodeRuntime.create({ permissions: { network: "allow" } }); +``` + +A scope can be `"allow"`, `"deny"`, or a `{ default, rules }` policy that matches request patterns. Guest servers are reachable only over loopback inside the VM unless you list a port in `loopbackExemptPorts`. See [Permissions](/docs/features/permissions) and [Networking](/docs/features/networking) for the full policy shape. + +## Resource and timing limits + +The VM bounds guest execution so runaway or hostile code cannot hang or exhaust the host: + +- **`timeout`** and **`signal`** on `exec()` / `run()` kill or cancel a run from the outside. +- **Memory, CPU-time, payload, and timing limits** are enforced by the VM. In the default timing-mitigation mode, high-resolution clocks (`Date.now()`, `performance.now()`, `process.hrtime()`) are frozen within a run and `SharedArrayBuffer` is removed, to blunt timing side channels of the kind used in Spectre-style attacks. These VM-enforced limits are not yet exposed as `NodeRuntime.create()` options. + +See [Resource Limits](/docs/features/resource-limits) for the controls and their defaults. + +## Output handling + +`exec()` and `run()` return captured `stdout` and `stderr` as part of the result. For long-running or high-volume guests, stream output incrementally with the `onStdout` / `onStderr` hooks (which receive raw `Uint8Array` chunks) instead of accumulating everything in host memory. See [Output Capture](/docs/features/output-capture). + +## Under the hood + +A `NodeRuntime` is the ergonomic entry point. Under it, each runtime owns one `SidecarProcess`, the low-level handle to the spawned, virtualized sidecar process that hosts the VM and kernel. Most applications never touch it; it is exported from `@secure-exec/core` for advanced callers driving sessions, VMs, and the wire protocol directly. + +```ts +import { SidecarProcess } from "@secure-exec/core/sidecar-process"; +``` + +Reach for `SidecarProcess` only when building your own runtime facade rather than using `NodeRuntime`. + + + +## Related + + + + +