diff --git a/README.md b/README.md index c18db9d..9139221 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,11 @@ Fetches the real Petstore OpenAPI spec from the web, then runs search + execute pnpm add @robinbraemer/codemode # Install a sandbox runtime (at least one): -pnpm add isolated-vm # V8 isolates — recommended for production on Node -pnpm add quickjs-emscripten # WASM QuickJS — fallback for Bun / CF Workers / browser +pnpm add @robinbraemer/llrt # Native LLRT — default candidate +pnpm add isolated-vm # V8 isolates — data-only Node.js fallback ``` -If both are installed, the auto-selector (`createExecutor`) picks `isolated-vm` on Node and `quickjs-emscripten` on Bun (where `isolated-vm` cannot dlopen because Bun's JavaScriptCore engine does not export the V8 symbols `isolated-vm` requires). +If both are installed, the auto-selector (`createExecutor`) picks native LLRT first, then `isolated-vm` on Node for data-only execution. Request-capable execution requires LLRT. `QuickJSExecutor` is still exported for explicit advanced use, but it is not selected automatically because its host-callback bridge cannot enforce all byte limits before values cross into host JavaScript. ## Quick Start @@ -114,7 +114,7 @@ CodeMode MCP Server → no network hop, auth handled automatically ``` -All code runs in an isolated V8 sandbox. The sandbox has zero I/O by default — no `require`, no `process`, no `fetch`, no filesystem. The only way to interact with the outside world is through the injected globals (`spec` for search, `{namespace}.request()` for execute). +All code runs in a fresh sandbox runtime. The sandbox has zero I/O by default — no `require`, no `process`, no `fetch`, no filesystem. Request-capable execution uses injected host callbacks (`spec` for search, `{namespace}.request()` for execute) and is supported by LLRT. Each tool call gets a fresh sandbox with no state carried over between calls. @@ -129,7 +129,7 @@ Each tool call gets a fresh sandbox with no state carried over between calls. | `namespace` | `string` | `"api"` | Client name in sandbox (`api.request(...)`). Must be a valid JS identifier, not a reserved name. | | `baseUrl` | `string` | `"http://localhost"` | Base URL for relative paths | | `sandbox` | `SandboxOptions` | see below | Sandbox resource limits | -| `executor` | `Executor` | `IsolatedVMExecutor` | Custom sandbox executor | +| `executor` | `Executor` | `createExecutor()` | Custom sandbox executor | | `maxResponseTokens` | `number` | `25000` | Token limit for response truncation (0 to disable) | | `maxRequests` | `number` | `50` | Max requests per `execute()` call | | `maxResponseBytes` | `number` | `10485760` | Max response body size in bytes (10MB) | @@ -140,7 +140,7 @@ Each tool call gets a fresh sandbox with no state carried over between calls. | Option | Type | Default | Description | |--------|------|---------|-------------| -| `memoryMB` | `number` | `64` | V8 isolate memory limit | +| `memoryMB` | `number` | `64` | Sandbox heap memory limit | | `timeoutMs` | `number` | `30000` | CPU timeout in ms (caps pure compute) | | `wallTimeMs` | `number` | `60000` | Wall-clock timeout in ms (caps total elapsed time including async I/O) | @@ -272,14 +272,14 @@ const tags = extractTags(rawSpec); ## Executors -CodeMode ships two executor backends. `IsolatedVMExecutor` is the recommended production backend on Node. `QuickJSExecutor` is a compatibility fallback for environments where `isolated-vm` cannot load (Bun, Cloudflare Workers, browser). +CodeMode ships three executor backends. `LlrtNativeExecutor` is the default and the only request-capable backend. `IsolatedVMExecutor` is the Node.js data-only fallback, and `QuickJSExecutor` is an explicit data-only compatibility backend. Use `createExecutor()` for automatic selection, or pass an executor instance explicitly: ```typescript -import { CodeMode, createExecutor, IsolatedVMExecutor, QuickJSExecutor } from '@robinbraemer/codemode'; +import { CodeMode, createExecutor, IsolatedVMExecutor, LlrtNativeExecutor } from '@robinbraemer/codemode'; -// Automatic — picks isolated-vm on Node, quickjs-emscripten on Bun +// Automatic — picks native LLRT first, then data-only isolated-vm on Node const codemode = new CodeMode({ spec, request: handler, @@ -300,15 +300,16 @@ const codemode = new CodeMode({ | Executor | Package | Performance | Portability | Production-ready | |----------|---------|-------------|-------------|------------------| -| `IsolatedVMExecutor` | `isolated-vm` | Native V8 speed | Node.js | ✅ | -| `QuickJSExecutor` | `quickjs-emscripten` | Slower (interpreted WASM) | Node, Bun, CF Workers, browser | ⚠️ fallback only — see caveats | +| `LlrtNativeExecutor` | `@robinbraemer/llrt` | Lightweight native LLRT | Node.js | ✅ default candidate | +| `IsolatedVMExecutor` | `isolated-vm` | Native V8 speed | Node.js | ⚠️ data-only fallback | +| `QuickJSExecutor` | `quickjs-emscripten` | Slower (interpreted WASM) | Node, Bun, CF Workers, browser | ⚠️ explicit only — see caveats | ### `QuickJSExecutor` caveats -- **Not a production backend.** Exists so the package loads on runtimes where `isolated-vm` cannot dlopen. Production callers on Node should use `IsolatedVMExecutor`. -- **Sandboxed code must avoid sequential `await` on host functions.** Use `Promise.all([fn1(), fn2()])` for parallel calls instead. Chained sequential `await`s currently crash with an upstream `quickjs-emscripten@0.32.0` release-asyncify regression ([justjake/quickjs-emscripten#258](https://github.com/justjake/quickjs-emscripten/issues/258)) — reproduces identically on Node and Bun. -- **Return-value semantics differ from `isolated-vm`.** Host ↔ guest values cross via a `JSON.stringify` envelope. `Date`, `Map`, `Set`, `BigInt` are converted to strings/objects, not preserved as instances. `isolated-vm` uses structured clone and preserves them. Stick to plain JSON-safe shapes in sandboxed code that targets both backends. -- **CPU timeout is wall-clock-based.** `isolated-vm` uses true CPU time; QuickJS uses elapsed time. Async host calls that take wall time count against the CPU budget under QuickJS. +- **Not a production backend and not auto-selected.** Use this only by constructing `new QuickJSExecutor(...)` explicitly. Production request-capable callers should use `LlrtNativeExecutor`. +- **Host callbacks are disabled.** QuickJS cannot enforce host-call byte limits before guest values are dumped into host JavaScript, and sequential host awaits still hit upstream release-asyncify crashes. `QuickJSExecutor` now fails closed when function globals are provided. +- **Return-value semantics differ from `isolated-vm`.** Final values cross via a `JSON.stringify` envelope. `Date`, `Map`, `Set`, `BigInt` are converted to strings/objects, not preserved as instances. `isolated-vm` uses structured clone and preserves them. Stick to plain JSON-safe shapes in sandboxed code that targets both backends. +- **CPU timeout is wall-clock-based.** `isolated-vm` uses true CPU time; QuickJS uses elapsed time. ### Custom Executor diff --git a/internal/superpowers/plans/2026-06-17-llrt-capability-boundary-hardening.md b/internal/superpowers/plans/2026-06-17-llrt-capability-boundary-hardening.md new file mode 100644 index 0000000..36305c0 --- /dev/null +++ b/internal/superpowers/plans/2026-06-17-llrt-capability-boundary-hardening.md @@ -0,0 +1,837 @@ +# LLRT Capability Boundary Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace codemode's loose function-global host callback model with explicit data-only and capability execution modes backed by an LLRT host manifest that exposes no raw bridge global. + +**Architecture:** `DataExecutor` runs JSON-only snippets and is implemented by all engines. `CapabilityExecutor` extends it with manifest-declared host capabilities and is implemented only by LLRT. The LLRT native binding builds a per-call `host` object with bound functions from the manifest instead of installing `globalThis.__llrtHostCall`. + +**Tech Stack:** TypeScript, Vitest, Rust, napi-rs, rquickjs, LLRT, pnpm, CNAP Bun/Nx integration. + +--- + +## Source Spec + +Implement against: + +`internal/superpowers/specs/2026-06-17-llrt-capability-boundary-hardening-design.md` + +## Task 1: Split The Codemode Executor Contract + +**Files:** + +- Modify: `packages/codemode/src/types.ts` +- Modify: `packages/codemode/test/executor-contract.ts` +- Modify: `packages/codemode/test/isolated-vm-executor.test.ts` +- Modify: `packages/codemode/test/quickjs-executor.test.ts` +- Modify: `packages/codemode/test/llrt-native-executor.test.ts` + +- [ ] **Step 1: Write failing type and behavior tests** + +Add data-only contract tests in `packages/codemode/test/executor-contract.ts`: + +```ts +it("rejects function values in data-only input", async () => { + const executor = factory(); + const result = await executor.executeData( + `async () => typeof api.request`, + { + api: { + request: async () => ({ status: 200 }), + }, + }, + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toContain("data-only"); +}); +``` + +Add a capability contract helper in the same file: + +```ts +export function capabilityExecutorContract( + name: string, + factory: (opts?: SandboxOptions) => CapabilityExecutor, +): void { + describe(`${name} capability execution`, () => { + it("exposes only manifest-declared namespace capabilities", async () => { + const executor = factory(); + const result = await executor.executeWithCapabilities( + `async () => { + const response = await api.request({ path: "/test" }); + return { + status: response.status, + secret: typeof api.secret, + topLevel: typeof request, + }; + }`, + {}, + { + namespaces: { + api: { + request: { + call: async (request: { path: string }) => ({ + status: 200, + body: { path: request.path }, + }), + }, + }, + }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + status: 200, + secret: "undefined", + topLevel: "undefined", + }); + }); + }); +} +``` + +Update backend test files so LLRT runs both contracts, while `isolated-vm` and QuickJS run only the data contract. + +- [ ] **Step 2: Run tests to verify red** + +Run: + +```bash +mise exec -- pnpm --filter @robinbraemer/codemode exec vitest run \ + test/llrt-native-executor.test.ts \ + test/isolated-vm-executor.test.ts \ + test/quickjs-executor.test.ts +``` + +Expected: TypeScript or runtime failures because `executeData`, `CapabilityExecutor`, and `executeWithCapabilities` do not exist. + +- [ ] **Step 3: Add executor types** + +Replace the public executor interface in `packages/codemode/src/types.ts` with: + +```ts +export interface ExecuteOptions { + memoryMB?: number; + timeoutMs?: number; + wallTimeMs?: number; + maxHostCalls?: number; + maxHostPayloadBytes?: number; + maxHostResultBytes?: number; + maxResultBytes?: number; +} + +export interface HostCallContext { + signal: AbortSignal; +} + +export interface HostCapability { + call(this: HostCallContext, ...args: unknown[]): unknown | Promise; +} + +export interface CapabilityManifest { + namespaces: Record>; +} + +export interface DataExecutor { + executeData( + code: string, + input: Record, + options?: ExecuteOptions, + ): Promise; + dispose?(): void; +} + +export interface CapabilityExecutor extends DataExecutor { + executeWithCapabilities( + code: string, + input: Record, + capabilities: CapabilityManifest, + options?: ExecuteOptions, + ): Promise; +} + +export type Executor = DataExecutor; +``` + +Keep `SandboxOptions` as construction-time defaults for now, but make per-call `ExecuteOptions` the executor method option type. + +- [ ] **Step 4: Add JSON-input guard helper** + +Create a local helper in each executor or shared module: + +```ts +export function findFunctionPath(value: unknown, path = "input"): string | null { + if (typeof value === "function") return path; + if (value === null || typeof value !== "object") return null; + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const found = findFunctionPath(value[index], `${path}[${index}]`); + if (found) return found; + } + return null; + } + for (const [key, entry] of Object.entries(value)) { + const found = findFunctionPath(entry, `${path}.${key}`); + if (found) return found; + } + return null; +} +``` + +If the repo already has an equivalent helper by this point, reuse it instead. + +- [ ] **Step 5: Implement data-only methods** + +Add `executeData()` to each executor. Keep a temporary `execute()` compatibility wrapper only if existing codemode code still needs it during this task: + +```ts +async executeData( + code: string, + input: Record, + options: ExecuteOptions = {}, +): Promise { + const functionPath = findFunctionPath(input); + if (functionPath) { + return { + result: undefined, + error: `data-only execution does not accept function values at ${functionPath}`, + stats: emptyStats(0, this.memoryMB), + }; + } + + return await this.runDataOnly(code, input, options); +} +``` + +For `isolated-vm` and QuickJS, move existing `execute()` logic under `executeData()` and delete host-function support paths. + +- [ ] **Step 6: Verify green** + +Run: + +```bash +mise exec -- pnpm --filter @robinbraemer/codemode exec vitest run \ + test/llrt-native-executor.test.ts \ + test/isolated-vm-executor.test.ts \ + test/quickjs-executor.test.ts +``` + +Expected: data-only contract passes for all engines. Capability contract remains skipped or fails until Task 3 wires LLRT capabilities. + +- [ ] **Step 7: Commit** + +Stage only files from this task: + +```bash +git add \ + packages/codemode/src/types.ts \ + packages/codemode/test/executor-contract.ts \ + packages/codemode/test/isolated-vm-executor.test.ts \ + packages/codemode/test/quickjs-executor.test.ts \ + packages/codemode/test/llrt-native-executor.test.ts +git commit -m "refactor: split codemode executor contract" +``` + +## Task 2: Replace LLRT Raw Global Host Dispatch With A Native Host Manifest + +**Files:** + +- Modify: `packages/llrt/src/types.ts` +- Modify: `packages/llrt/src/runtime.ts` +- Modify: `packages/llrt/src/native.ts` +- Modify: `packages/llrt/native/index.d.ts` +- Modify: `packages/llrt/native/src/runtime.rs` +- Modify: `packages/llrt/test/call-json.test.ts` +- Modify: `packages/llrt/test/runtime.test.ts` + +- [ ] **Step 1: Write failing LLRT tests** + +In `packages/llrt/test/call-json.test.ts`, add: + +```ts +it("does not expose a raw host bridge in data-only execution", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJson( + `async ({ host }) => ({ + host: typeof host, + raw: typeof globalThis.__llrtHostCall, + })`, + {}, + ); + + expect(result).toMatchObject({ + ok: true, + value: { host: "undefined", raw: "undefined" }, + }); +}); + +it("does not expose a raw host bridge in capability execution", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJsonWithHost( + `async ({ host }) => ({ + response: await host.api.request({ path: "/pets" }), + raw: typeof globalThis.__llrtHostCall, + missing: typeof host.api.secret, + })`, + {}, + { + namespaces: { + api: { + request: async (request: { path: string }) => ({ + status: 200, + path: request.path, + }), + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: true, + value: { + response: { status: 200, path: "/pets" }, + raw: "undefined", + missing: "undefined", + }, + }); +}); +``` + +- [ ] **Step 2: Run tests to verify red** + +Run: + +```bash +mise exec -- pnpm --filter @robinbraemer/llrt exec vitest run \ + test/call-json.test.ts \ + test/runtime.test.ts \ + --pool=forks --maxWorkers=1 --testTimeout=20000 +``` + +Expected: `callJsonWithHost` does not exist and host-mode still exposes the raw bridge. + +- [ ] **Step 3: Add LLRT manifest types** + +In `packages/llrt/src/types.ts`, add: + +```ts +export interface LlrtHostManifest { + namespaces: Record>; +} +``` + +Keep `LlrtCallOptions.functions` only temporarily if codemode still uses it during this task. Mark it internal in comments and remove it after Task 3. + +- [ ] **Step 4: Add native host path option** + +In `packages/llrt/src/native.ts` and `packages/llrt/native/index.d.ts`, extend native runtime options: + +```ts +hostPaths?: string[]; +``` + +Each path must be a dot-separated manifest path such as `api.request`. + +- [ ] **Step 5: Implement `callJsonWithHost()` in TypeScript** + +In `packages/llrt/src/runtime.ts`, add: + +```ts +async callJsonWithHost( + source: string, + input: TInput, + manifest: LlrtHostManifest, + options: Omit = {}, +): Promise> { + const flattened = flattenHostManifest(manifest); + return await this.callJsonInternal(source, input, { + ...options, + hostFunctions: flattened.functions, + hostPaths: flattened.paths, + }); +} +``` + +Refactor existing `callJson()` into a private `callJsonInternal()` so `callJson()` never passes a host dispatcher. + +- [ ] **Step 6: Build host object natively** + +In `packages/llrt/native/src/runtime.rs`, replace global bridge installation with native host object creation: + +```rust +if let Some(host_dispatcher) = host_dispatcher { + let host = build_host_object( + &ctx, + host_dispatcher, + host_paths.unwrap_or_default(), + max_host_payload_bytes, + error_marker.clone(), + )?; + argument.set("host", host)?; +} +``` + +`build_host_object` must: + +- split each path on `.`; +- create namespace objects as needed; +- attach an async function only at the declared leaf; +- bind the full path into the closure so guest code cannot choose arbitrary host names; +- never write to `ctx.globals()`. + +- [ ] **Step 7: Keep native size and error checks** + +The bound native function must call the existing dispatcher with the bound name: + +```rust +call_host_function( + Arc::clone(&host_dispatcher), + bound_name.clone(), + args_json, + max_host_payload_bytes, + host_error_marker.clone(), +) +``` + +Keep `host_error_from_message`, `host_error_to_quickjs`, payload checks, result parsing, timeout mapping, and memory mapping unchanged except for removing global installation. + +- [ ] **Step 8: Verify green** + +Run: + +```bash +mise exec -- pnpm --filter @robinbraemer/llrt exec vitest run \ + test/call-json.test.ts \ + test/runtime.test.ts \ + --pool=forks --maxWorkers=1 --testTimeout=20000 +``` + +Expected: all LLRT native host/data tests pass, including raw bridge absence. + +- [ ] **Step 9: Commit** + +```bash +git add \ + packages/llrt/src/types.ts \ + packages/llrt/src/runtime.ts \ + packages/llrt/src/native.ts \ + packages/llrt/native/index.d.ts \ + packages/llrt/native/src/runtime.rs \ + packages/llrt/test/call-json.test.ts \ + packages/llrt/test/runtime.test.ts +git commit -m "fix: bind llrt host capabilities without raw bridge globals" +``` + +## Task 3: Wire Codemode Capability Execution To LLRT + +**Files:** + +- Modify: `packages/codemode/src/executor/llrt-native.ts` +- Modify: `packages/codemode/src/executor/auto.ts` +- Modify: `packages/codemode/src/codemode.ts` +- Modify: `packages/codemode/src/mcp.ts` +- Modify: `packages/codemode/test/llrt-native-executor.test.ts` +- Modify: `packages/codemode/test/codemode.test.ts` +- Modify: `packages/codemode/test/auto-executor.test.ts` + +- [ ] **Step 1: Write failing codemode capability tests** + +In `packages/codemode/test/llrt-native-executor.test.ts`, assert: + +```ts +it("does not expose the LLRT raw bridge during capability execution", async () => { + const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 1000 }); + + const result = await executor.executeWithCapabilities( + `async () => ({ + response: await api.request({ path: "/pets" }), + raw: typeof globalThis.__llrtHostCall, + })`, + {}, + { + namespaces: { + api: { + request: { + call: async () => ({ status: 200 }), + }, + }, + }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + response: { status: 200 }, + raw: "undefined", + }); +}); +``` + +In `packages/codemode/test/codemode.test.ts`, assert `search()` has no request capability: + +```ts +it("runs search in data-only mode", async () => { + const result = await codemode.search( + `async () => ({ spec: spec.info.title, request: typeof api })`, + ); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('"request":"undefined"'); +}); +``` + +- [ ] **Step 2: Run tests to verify red** + +Run: + +```bash +mise exec -- pnpm --filter @robinbraemer/codemode exec vitest run \ + test/llrt-native-executor.test.ts \ + test/codemode.test.ts \ + test/auto-executor.test.ts +``` + +Expected: `executeWithCapabilities` is missing or `CodeMode` still uses the old `execute` path. + +- [ ] **Step 3: Implement `LlrtNativeExecutor.executeData()`** + +Move current JSON-global behavior to `executeData()`: + +```ts +async executeData( + code: string, + input: Record, + options: ExecuteOptions = {}, +): Promise { + const functionPath = findFunctionPath(input); + if (functionPath) return dataOnlyFunctionError(functionPath, this.memoryMB); + return await this.run(code, input, undefined, options); +} +``` + +- [ ] **Step 4: Implement `LlrtNativeExecutor.executeWithCapabilities()`** + +Build an LLRT host manifest from `CapabilityManifest`: + +```ts +async executeWithCapabilities( + code: string, + input: Record, + capabilities: CapabilityManifest, + options: ExecuteOptions = {}, +): Promise { + const functionPath = findFunctionPath(input); + if (functionPath) return dataOnlyFunctionError(functionPath, this.memoryMB); + return await this.run(code, input, capabilities, options); +} +``` + +`run()` should call `runtime.callJson()` for data-only and `runtime.callJsonWithHost()` for capability mode. + +- [ ] **Step 5: Update wrapped guest code** + +Change `wrapCode` so guest code receives declared capabilities through globals but does not infer functions from data: + +```ts +function wrapCode(code: string): string { + return `async ({ input, host }) => { + globalThis.require = undefined; + globalThis.process = undefined; + globalThis.fetch = undefined; + globalThis.console = { log: () => {}, warn: () => {}, error: () => {} }; + + for (const [name, value] of Object.entries(input)) { + globalThis[name] = value; + } + + if (host) { + for (const [namespace, value] of Object.entries(host)) { + globalThis[namespace] = value; + } + } + + return await (${code})(); + }`; +} +``` + +- [ ] **Step 6: Update `CodeMode` to use explicit modes** + +In `packages/codemode/src/codemode.ts`: + +```ts +const result = await executor.executeData(code, { spec }); +``` + +For execute: + +```ts +const executor = await this.getCapabilityExecutor(); +const result = await executor.executeWithCapabilities( + code, + {}, + { + namespaces: { + [this.namespace]: { + request: { call: bridge }, + }, + }, + }, +); +``` + +Add a `getCapabilityExecutor()` helper that fails clearly when the configured executor lacks `executeWithCapabilities`. + +- [ ] **Step 7: Update auto-selection tests** + +Assert auto-selection can distinguish data-only fallback from capability requirement: + +```ts +expect(typeof executor.executeData).toBe("function"); +if (!("executeWithCapabilities" in executor)) { + await expectCapabilityFailure(); +} +``` + +- [ ] **Step 8: Verify green** + +Run: + +```bash +mise exec -- pnpm --filter @robinbraemer/codemode run test +mise exec -- pnpm --filter @robinbraemer/codemode run typecheck +``` + +Expected: codemode tests and typecheck pass. + +- [ ] **Step 9: Commit** + +```bash +git add \ + packages/codemode/src/executor/llrt-native.ts \ + packages/codemode/src/executor/auto.ts \ + packages/codemode/src/codemode.ts \ + packages/codemode/src/mcp.ts \ + packages/codemode/test/llrt-native-executor.test.ts \ + packages/codemode/test/codemode.test.ts \ + packages/codemode/test/auto-executor.test.ts +git commit -m "refactor: route codemode through explicit capability execution" +``` + +## Task 4: Tighten Request Bridge Policy And Documentation + +**Files:** + +- Modify: `packages/codemode/src/request-bridge.ts` +- Modify: `packages/codemode/src/tools.ts` +- Modify: `packages/codemode/src/types.ts` +- Modify: `packages/codemode/test/request-bridge.test.ts` +- Modify: `README.md` + +- [ ] **Step 1: Write failing policy tests** + +Add or update tests in `packages/codemode/test/request-bridge.test.ts`: + +```ts +it("allows documented HEAD and OPTIONS methods", async () => { + const bridge = createRequestBridge( + async (_input, init) => new Response("", { status: init?.method === "HEAD" ? 204 : 200 }), + "http://localhost", + ); + + await expect(bridge({ method: "HEAD", path: "/v1/items" })).resolves.toMatchObject({ status: 204 }); + await expect(bridge({ method: "OPTIONS", path: "/v1/items" })).resolves.toMatchObject({ status: 200 }); +}); +``` + +- [ ] **Step 2: Run request bridge tests red or green** + +Run: + +```bash +mise exec -- pnpm --filter @robinbraemer/codemode exec vitest run test/request-bridge.test.ts +``` + +Expected: behavior may already pass; docs still need update. + +- [ ] **Step 3: Rename policy docs** + +In `types.ts`, distinguish: + +- `HostBridgePolicy` for LLRT host callback limits; +- `RequestCapabilityPolicy` for HTTP bridge limits. + +Keep `CodeModeOptions` accepting the same fields for now, but document which policy each field belongs to. + +- [ ] **Step 4: Align tool docs** + +Update `packages/codemode/src/tools.ts` and `README.md` so method examples and descriptions include `HEAD` and `OPTIONS` or explicitly describe them as advanced but allowed. + +- [ ] **Step 5: Verify green** + +Run: + +```bash +mise exec -- pnpm --filter @robinbraemer/codemode exec vitest run test/request-bridge.test.ts +mise exec -- pnpm --filter @robinbraemer/codemode run typecheck +``` + +- [ ] **Step 6: Commit** + +```bash +git add \ + packages/codemode/src/request-bridge.ts \ + packages/codemode/src/tools.ts \ + packages/codemode/src/types.ts \ + packages/codemode/test/request-bridge.test.ts \ + README.md +git commit -m "docs: clarify codemode request capability policy" +``` + +## Task 5: Update CNAP To Use Explicit Capability Execution + +**Files:** + +- Modify in CNAP worktree: `packages/domains/codemode/src/index.ts` +- Modify in CNAP worktree: `packages/domains/codemode/src/service.ts` +- Modify in CNAP worktree: `packages/domains/codemode/src/service.test.ts` +- Modify in CNAP worktree: `packages/mcp/src/mcp.ts` if tool registration needs mode-aware wiring + +- [ ] **Step 1: Write failing CNAP tests** + +In `packages/domains/codemode/src/service.test.ts`, add assertions that MCP `CodeMode` receives the explicit service executor and sandbox limits. Use the existing fake/mocked executor pattern in that file. + +Add a test proving snippet execution uses capability mode rather than data-only globals: + +```ts +it("executes snippets through explicit capability mode", async () => { + const result = await service.execute( + `async () => { + const response = await platform.request({ method: "GET", path: "/v1/clusters" }); + return response.body; + }`, + "token", + context, + ); + + expect(result.error).toBeUndefined(); + expect(publicApi.requests[0]?.headers.authorization).toBe("Bearer token"); +}); +``` + +- [ ] **Step 2: Run CNAP focused tests to verify red** + +Run from `/Users/robin/Developer/cnap-tech/cnap/.worktrees/adopt-llrt-runtime`: + +```bash +mise exec -- bun run nx -- run @platform/domain-codemode:test --skipNxCache +``` + +Expected: compile or test failures until codemode dependency/API use is updated. + +- [ ] **Step 3: Update CNAP service construction** + +Change `CodeModeService.createCodeMode()` so `new CodeMode(...)` receives the explicit executor and sandbox limits used by snippets: + +```ts +const codemode = new CodeMode({ + spec: async () => { ... }, + request: this.createRequestHandler(token, undefined, agentUsage), + namespace: 'platform', + executor: this.executor, + sandbox: { + memoryMB: 64, + timeoutMs: 30_000, + wallTimeMs: 60_000 + } +}); +``` + +If codemode replaces `executor` with a `capabilityExecutor` option, use that new option instead. + +- [ ] **Step 4: Update snippet execution** + +Replace direct `executor.execute(code, { platform: { request: bridge } })` with the new capability API exposed by codemode. The target shape is: + +```ts +const result = await this.executor.executeWithCapabilities( + code, + {}, + { + namespaces: { + platform: { + request: { call: bridge }, + }, + }, + }, +); +``` + +- [ ] **Step 5: Verify green** + +Run from CNAP worktree: + +```bash +mise exec -- bun run nx -- run @platform/domain-codemode:test --skipNxCache +mise exec -- bun run nx -- run-many -t check --projects=@platform/domain-codemode,@platform/mcp --skipNxCache +``` + +- [ ] **Step 6: Commit CNAP changes** + +```bash +git add \ + packages/domains/codemode/src/index.ts \ + packages/domains/codemode/src/service.ts \ + packages/domains/codemode/src/service.test.ts \ + packages/mcp/src/mcp.ts +git commit -m "refactor: use explicit codemode capability execution" +``` + +## Task 6: Final Verification And PR Prep + +**Files:** + +- Modify: release notes or PR description only if needed. + +- [ ] **Step 1: Run codemode verification** + +From `/Users/robin/Developer/cnap-tech/codemode`: + +```bash +mise exec -- pnpm --filter @robinbraemer/llrt run test:native +mise exec -- pnpm --filter @robinbraemer/codemode run test +mise exec -- pnpm --filter @robinbraemer/codemode run typecheck +mise exec -- pnpm run ci +git diff --check +``` + +- [ ] **Step 2: Run CNAP verification** + +From `/Users/robin/Developer/cnap-tech/cnap/.worktrees/adopt-llrt-runtime`: + +```bash +mise exec -- bun run nx -- run @platform/domain-codemode:test --skipNxCache +mise exec -- bun run nx -- run-many -t check --projects=@platform/domain-codemode,@platform/mcp --skipNxCache +task preflight +git diff --check +``` + +- [ ] **Step 3: Prepare PR text** + +Include: + +- architecture change summary; +- threat model summary; +- explicit data-only vs capability mode behavior; +- LLRT raw bridge removal; +- CNAP MCP/snippet integration changes; +- verification commands and results; +- remaining release workflow evidence if native package CI still needs a GitHub run. + +- [ ] **Step 4: Push and open PRs** + +Open codemode PR first. Then open/update CNAP PR pointing at the codemode package version or local workspace link strategy. diff --git a/internal/superpowers/specs/2026-06-17-llrt-capability-boundary-hardening-design.md b/internal/superpowers/specs/2026-06-17-llrt-capability-boundary-hardening-design.md new file mode 100644 index 0000000..b56ff63 --- /dev/null +++ b/internal/superpowers/specs/2026-06-17-llrt-capability-boundary-hardening-design.md @@ -0,0 +1,301 @@ +# LLRT Capability Boundary Hardening Design + +**Date:** 2026-06-17 + +**Status:** Draft implementation contract. + +**Goal:** Re-architect codemode's LLRT execution boundary so data-only execution cannot accidentally expose host capabilities, API-capable execution is explicitly capability-based, and the raw LLRT host bridge is not reachable from guest JavaScript. + +## Decision + +Replace the current generic `Executor.execute(code, globals)` capability model with explicit execution modes: + +- `dataOnly`: JSON input/output only. No host dispatcher, no `host` object, no raw host bridge, no function globals. +- `capability`: JSON input/output plus a small, named capability manifest. Codemode's first capability is exactly `{namespace}.request()`. + +The current `globals` abstraction is too broad for a security boundary because it treats any function value as a host callback. That makes capability injection an accidental property of data injection. The new API must make host capabilities a separate type and a separate method. + +## Source Findings + +LLRT module loading is disabled for `LlrtRuntime.callJson()`: + +- `packages/llrt/native/src/runtime.rs` constructs the VM with `ModuleBuilder::new()` and `allow_module_loading: false`. +- Upstream `llrt_core/src/vm.rs` only enables embedded, package, and file resolvers when `allow_module_loading` is true. +- Upstream `llrt_modules/src/module_builder.rs` shows `ModuleBuilder::default()` attaches built-in globals/modules, while `ModuleBuilder::new()` starts empty. + +Host callbacks are optional but currently string-dispatched: + +- `packages/llrt/src/runtime.ts` creates a host dispatcher only when `options.functions` is present. +- `packages/llrt/native/src/runtime.rs` installs `globalThis.__llrtHostCall` only when a host dispatcher exists. +- The wrapper exposes `host` as a `Proxy`; any string property becomes a host call name. +- Guest code can bypass the proxy and call `globalThis.__llrtHostCall(name, argsJson)` directly when host mode is enabled. + +Limits are split between Rust and TypeScript: + +- Rust enforces VM heap, stack, wall timeout, native host payload bytes, and final serialized result bytes. +- TypeScript enforces option validation, host call count, host result bytes, input serialization, and wrapper-side result checks. +- CPU time is explicitly unsupported by the current LLRT binding; wall time is the enforceable execution timeout. + +Codemode entrypoints are already semantically split, but the shared executor API is not: + +- `CodeMode.search()` injects only the OpenAPI `spec` data. +- `CodeMode.execute()` constructs a fresh request bridge and injects `{namespace}.request()`. +- `LlrtNativeExecutor` accepts arbitrary top-level function globals and one-level namespace methods. +- `IsolatedVMExecutor` and `QuickJSExecutor` now reject host function globals. + +CNAP integration depends on the loose API: + +- Snippet execution creates an explicit `LlrtNativeExecutor` and calls `executor.execute(code, { platform: { request: bridge } })`. +- MCP `createCodeMode()` creates `new CodeMode({ spec, request, namespace: "platform" })` without passing CNAP's explicit executor or sandbox limits, so it relies on codemode auto-selection. +- CNAP forwarding sets `Authorization: Bearer ` in the host request handler. Sandbox-supplied credential and routing headers are stripped by codemode's request bridge. +- Workspace context headers are defaults and may be overridden by guest code; public API authorization is the enforcement boundary for workspace access. + +## Threat Model + +Untrusted input: + +- model-generated JavaScript passed to `search`, `execute`, or snippets; +- request objects passed to `{namespace}.request()`; +- returned values and thrown values from guest code. + +Protected assets: + +- Node host process APIs such as `fs`, `process`, environment variables, network APIs, and native bindings; +- authenticated CNAP bearer token held by the host request handler; +- workspace-scoped data reachable through the public API; +- host memory and event loop availability. + +Allowed behavior: + +- `dataOnly` code may compute over JSON globals and return JSON-compatible values. +- `capability` code may call only capabilities named in its manifest. +- Codemode capability mode may call only the request capability exposed as `{namespace}.request(options)`. + +Forbidden behavior: + +- importing host modules such as `fs`, `node:fs`, or `node:process`; +- observing or invoking a raw host bridge global; +- passing arbitrary function globals through the executor; +- calling host capability names not declared by the manifest; +- forging typed host/limit errors; +- bypassing request count, concurrency, payload, result, memory, or wall-time limits; +- forwarding guest-supplied credential, routing override, forwarding, or hop-by-hop headers. + +Non-goals: + +- defending against a malicious native `@robinbraemer/llrt` package after installation; +- providing `isolated-vm`-style live object references; +- supporting arbitrary host function injection as a public codemode feature. + +## Architecture + +### Public Executor API + +Replace the current function-global contract with mode-specific APIs: + +```ts +export interface DataExecutor { + executeData( + code: string, + input: Record, + options?: ExecuteOptions, + ): Promise; + dispose?(): void; +} + +export interface CapabilityExecutor extends DataExecutor { + executeWithCapabilities( + code: string, + input: Record, + capabilities: CapabilityManifest, + options?: ExecuteOptions, + ): Promise; +} +``` + +`CapabilityManifest` is explicit data, not inferred from `input`: + +```ts +export interface CapabilityManifest { + namespaces: Record>; +} + +export interface HostCapability { + call(this: HostCallContext, ...args: unknown[]): unknown | Promise; +} +``` + +Rules: + +- `input` must be JSON-serializable and must not contain functions. +- `executeData()` must fail closed if any function value appears in `input`. +- `executeWithCapabilities()` is available only on LLRT. +- `IsolatedVMExecutor` and `QuickJSExecutor` implement `DataExecutor` only. +- Auto-selection may return a data executor for `search`, but API-capable `execute` must require a `CapabilityExecutor`. + +### LLRT Runtime API + +Split low-level LLRT calls: + +```ts +runtime.callJson(source, input, options) +runtime.callJsonWithHost(source, input, hostManifest, options) +``` + +`callJson()` never passes a host dispatcher to native code. + +`callJsonWithHost()` passes: + +- a host dispatcher; +- a manifest of allowed capability paths; +- host call limits. + +The native binding creates the `host` object inside the QuickJS context. Each exposed function is bound to one fixed capability name. Guest code receives `host` as an argument, but no raw `globalThis.__llrtHostCall` is installed. + +Target shape inside native execution: + +```text +argument = { + input: , + host: { + platform: { + request: + } + } +} +``` + +The wrapper no longer needs a JavaScript `Proxy` for host calls. Unknown host names cannot be synthesized by property access because only manifest-declared functions exist. + +### Codemode API + +`CodeMode.search()` uses `executeData()`: + +```ts +executor.executeData(code, { spec }); +``` + +`CodeMode.execute()` uses `executeWithCapabilities()`: + +```ts +executor.executeWithCapabilities( + code, + {}, + { + namespaces: { + [namespace]: { + request: { call: requestBridge }, + }, + }, + }, +); +``` + +No codemode caller should pass request functions through plain globals. The tool surface still shows `{namespace}.request()`, but internally it is a declared capability. + +### Request Capability Policy + +Request bridge limits stay separate from LLRT host-call limits: + +```ts +export interface RequestCapabilityPolicy { + maxRequests: number; + maxConcurrentRequests: number; + maxRequestBytes: number; + maxResponseBytes: number; + allowedHeaders?: string[]; + exposedResponseHeaders?: string[]; +} + +export interface HostBridgePolicy { + maxHostCalls: number; + maxHostPayloadBytes: number; + maxHostResultBytes: number; + maxResultBytes: number; +} +``` + +The request bridge continues to enforce: + +- relative-path-only requests; +- method whitelist; +- request count and concurrency limits; +- request and response byte limits; +- credential, routing, forwarding, and hop-by-hop header stripping; +- explicit response-header exposure. + +Align docs and behavior for `HEAD` and `OPTIONS`: either document them as allowed or remove them from the bridge. The recommended choice is to keep them allowed and document them, because the bridge already validates path and headers. + +### CNAP Integration + +CNAP should pass the same explicit executor and sandbox limits to both MCP and snippets. + +Required changes: + +- `CodeModeService.createCodeMode()` must construct `CodeMode` with the service's explicit LLRT capability executor and sandbox policy. +- `CodeModeService.execute()` must call codemode's capability API instead of calling `executor.execute()` with `{ platform: { request } }`. +- CNAP tests must prove: + - MCP `search` uses data-only execution; + - MCP/snippet `execute` uses capability mode; + - bearer token forwarding still happens only in the host request handler; + - `Akua-Context` remains an overridable request header and authorization remains enforced by the public API layer; + - configured sandbox limits are used for MCP and snippets. + +## Test Requirements + +LLRT package: + +- `callJson()` does not expose `host` or any raw bridge global. +- `callJsonWithHost()` exposes only manifest-declared host functions. +- `callJsonWithHost()` does not expose `globalThis.__llrtHostCall`. +- Direct attempts to call undeclared host names fail before invoking host code. +- Host call count, payload bytes, result bytes, wall timeout, memory, and final result limits still pass. +- Guest-forged host error markers remain ignored. +- Dynamic imports of `node:fs`, `fs`, and `node:process` remain blocked. + +Codemode package: + +- `executeData()` accepts JSON globals and rejects functions. +- `executeWithCapabilities()` exposes `{namespace}.request()` only through a capability manifest. +- Existing `CodeMode.search()` behavior works through data-only mode. +- Existing `CodeMode.execute()` behavior works through capability mode. +- `isolated-vm` and QuickJS remain data-only and reject capability execution. +- Auto executor selection cannot silently route API-capable execution to a data-only executor. +- Request bridge tests cover method docs, header stripping, path validation, body/result limits, request count, and concurrency. + +CNAP: + +- `CodeModeService.createCodeMode()` passes explicit executor and sandbox limits. +- MCP `search` and `execute` are still quota-gated. +- Snippet execution still forwards bearer token and workspace context. +- Snippet execution still records request count and execution stats. +- Data-only execution cannot call `platform.request()`. + +## Migration Plan + +1. Add the new `DataExecutor`, `CapabilityExecutor`, `CapabilityManifest`, `ExecuteOptions`, and policy types while keeping temporary compatibility adapters inside codemode only. +2. Update `LlrtNativeExecutor` to implement `executeData()` and `executeWithCapabilities()`. +3. Update `@robinbraemer/llrt` to expose `callJson()` and `callJsonWithHost()`, then remove raw bridge global exposure from host mode. +4. Update `CodeMode.search()` and `CodeMode.execute()` to use the explicit modes. +5. Update executor contract tests to separate data-only and capability contracts. +6. Update CNAP integration to use the explicit LLRT capability executor for both MCP and snippets. +7. Remove public documentation that suggests arbitrary function globals are supported. +8. Run codemode CI, LLRT native tests, CNAP domain-codemode tests, CNAP MCP checks, and CNAP preflight before PR/release. + +## Open Decisions + +- Whether to keep a deprecated `Executor.execute()` compatibility method for one release or break immediately. Recommendation: break immediately; the only known consumer is CNAP and this is a security boundary. +- Whether the low-level LLRT package should expose general host capabilities to external users. Recommendation: yes, but only through `callJsonWithHost()` manifest-bound functions, never through raw global dispatch. +- Whether to keep QuickJS as an explicit data-only executor. Recommendation: keep it for now, but never allow host callbacks through it. + +## Promotion Standard + +The refactor is complete only when current evidence proves: + +- data-only execution installs no host dispatcher and exposes no host object; +- capability execution exposes only manifest-declared functions; +- no guest-visible raw bridge global exists in either mode; +- codemode no longer treats function values in globals as capabilities; +- CNAP MCP and snippets both use the explicit LLRT capability executor and configured limits; +- focused security regression tests pass in `@robinbraemer/llrt`, `@robinbraemer/codemode`, and CNAP; +- full codemode CI and CNAP preflight pass or any skipped checks are documented with a concrete reason. diff --git a/packages/codemode/package.json b/packages/codemode/package.json index c971a6e..eb55d8e 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/codemode", - "version": "0.3.2", + "version": "0.4.0", "description": "Code Mode MCP tools from OpenAPI specs. Two tools (search + execute) replace hundreds of individual MCP tools.", "type": "module", "main": "./dist/index.js", @@ -47,7 +47,7 @@ "url": "https://github.com/cnap-tech/codemode.git" }, "peerDependencies": { - "@robinbraemer/llrt": "^0.1.2", + "@robinbraemer/llrt": "^0.2.0", "isolated-vm": "6", "quickjs-emscripten": ">=0.31" }, diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 9a58a18..43de2e6 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -1,4 +1,5 @@ import { createExecutor } from "./executor/auto.js"; +import { DEFAULT_MAX_CODE_BYTES } from "./limits.js"; import { createRequestBridge, type RequestBridgeContext, @@ -17,6 +18,7 @@ import type { ToolCallResult, ToolDefinition, } from "./types.js"; +import { isCapabilityExecutor } from "./types.js"; const RESERVED_NAMES = new Set([ "Object", "Array", "Promise", "Function", "String", "Number", "Boolean", @@ -79,6 +81,7 @@ export class CodeMode { private searchToolName: string; private executeToolName: string; private maxResponseTokens: number; + private maxCodeBytes: number; // Bridge config — a fresh bridge is created per execute() call // so the request counter resets each time. @@ -98,6 +101,7 @@ export class CodeMode { this.searchToolName = "search"; this.executeToolName = "execute"; this.maxResponseTokens = options.maxResponseTokens ?? 6_000; + this.maxCodeBytes = options.maxCodeBytes ?? DEFAULT_MAX_CODE_BYTES; validateNamespace(this.namespace); @@ -105,6 +109,7 @@ export class CodeMode { this.bridgeBaseUrl = options.baseUrl ?? "http://localhost"; this.bridgeOptions = { maxRequests: options.maxRequests, + maxConcurrentRequests: options.maxConcurrentRequests, maxRequestBytes: options.maxRequestBytes, maxResponseBytes: options.maxResponseBytes, allowedHeaders: options.allowedHeaders, @@ -156,10 +161,12 @@ export class CodeMode { * All $refs are pre-resolved inline. */ async search(code: string): Promise { + const sizeError = this.validateCodeSize(code); + if (sizeError) return sizeError; const executor = await this.getExecutor(); const spec = await this.getProcessedSpec(); - const result = await executor.execute(code, { spec }); + const result = await executor.executeData(code, { spec }); return this.formatResult(result); } @@ -169,21 +176,38 @@ export class CodeMode { * The code runs with `{namespace}.request()` available as a global. */ async execute(code: string): Promise { + const sizeError = this.validateCodeSize(code); + if (sizeError) return sizeError; const executor = await this.getExecutor(); + if (!isCapabilityExecutor(executor)) { + return { + content: [{ + type: "text", + text: "Error: The selected sandbox runtime does not support host functions or capability execution. Install @robinbraemer/llrt or pass a capability executor.", + }], + isError: true, + }; + } // Fresh bridge per execution — request counter resets each time const bridge = createRequestBridge( this.bridgeHandler, this.bridgeBaseUrl, this.bridgeOptions, ); - const client = { - request(this: RequestBridgeContext, options: SandboxRequestOptions) { - return bridge(options, this); + const result = await executor.executeWithCapabilities( + code, + {}, + { + namespaces: { + [this.namespace]: { + request: { + call(this: RequestBridgeContext, options: SandboxRequestOptions) { + return bridge(options, this); + }, + }, + }, + }, }, - }; - - const result = await executor.execute(code, { - [this.namespace]: client, - }); + ); return this.formatResult(result); } @@ -246,13 +270,48 @@ export class CodeMode { }; } - const resultText = - typeof result.result === "string" - ? result.result - : JSON.stringify(result.result, null, 2); + let resultText: string; + try { + resultText = + typeof result.result === "string" + ? result.result + : JSON.stringify(result.result, jsonReplacer, 2) ?? "undefined"; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Result serialization failed: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } return { content: [{ type: "text", text: truncateResponse(resultText, this.maxResponseTokens) }], }; } + + private validateCodeSize(code: string): ToolCallResult | undefined { + const bytes = Buffer.byteLength(code, "utf8"); + if (bytes <= this.maxCodeBytes) return undefined; + + return { + content: [ + { + type: "text", + text: `Code too large: ${bytes} bytes exceeds limit of ${this.maxCodeBytes} bytes`, + }, + ], + isError: true, + }; + } +} + +function jsonReplacer(_key: string, value: unknown): unknown { + if (typeof value === "bigint") { + throw new TypeError("BigInt values cannot be returned from CodeMode tools"); + } + return value; } diff --git a/packages/codemode/src/executor/auto.ts b/packages/codemode/src/executor/auto.ts index 6659072..afe44c1 100644 --- a/packages/codemode/src/executor/auto.ts +++ b/packages/codemode/src/executor/auto.ts @@ -3,8 +3,8 @@ import type { Executor, SandboxOptions } from "../types.js"; /** * Detect whether we're running under Bun. On Bun, isolated-vm cannot dlopen * (it relies on V8 symbols like `v8::ValueSerializer::Delegate::IsHostObject` - * that Bun's JavaScriptCore engine does not export), so we prefer the WASM - * QuickJS backend. + * that Bun's JavaScriptCore engine does not export), so the automatic runtime + * selector tries LLRT only and fails closed if it is unavailable. * * Uses Bun's officially documented detection pattern: * https://bun.com/docs/guides/util/detect-bun @@ -25,25 +25,23 @@ function isBun(): boolean { * - **LLRT native** → first when `@robinbraemer/llrt` is installed. This is * the lightweight default candidate and satisfies the shared executor * contract, including host callbacks. - * - **Bun** → QuickJS first (isolated-vm cannot load native bindings under - * JavaScriptCore), fall back to isolated-vm only if QuickJS isn't - * installed. - * - **Node without LLRT** → isolated-vm first (mature V8 isolates), then - * QuickJS if isolated-vm isn't installed (e.g. ARM Linux without build - * tools, or a Node minor without a prebuild). + * - **Node without LLRT** → isolated-vm for data-only sandbox execution. * - * QuickJS is a compatibility fallback, not a recommended production backend. - * See `QuickJSExecutor`'s docstring for the upstream `quickjs-emscripten` - * bugs it inherits. + * QuickJS remains available as an explicit advanced executor, but the automatic + * selector intentionally does not choose it. Its `quickjs-emscripten` host + * callback bridge cannot enforce byte limits before values cross into host JS + * without re-triggering upstream asyncify crashes, so auto-selection fails + * closed instead of silently weakening host-boundary controls. + * + * Request-capable execution requires LLRT. The non-LLRT fallback executors + * reject host function globals rather than exposing weaker host bridges. * * All sandbox runtimes are optional peer dependencies. */ export async function createExecutor( options: SandboxOptions = {}, ): Promise { - const order = isBun() - ? (["llrt", "quickjs", "isolated-vm"] as const) - : (["llrt", "isolated-vm", "quickjs"] as const); + const order = autoExecutorBackendOrder(); /* oxlint-disable no-await-in-loop */ for (const backend of order) { @@ -59,36 +57,34 @@ export async function createExecutor( const { LlrtNativeExecutor } = await import("./llrt-native.js"); return new LlrtNativeExecutor(options); - } else if (backend === "isolated-vm") { + } else { try { // @ts-ignore — optional peer dependency await import("isolated-vm"); const { IsolatedVMExecutor } = await import("./isolated-vm.js"); return new IsolatedVMExecutor(options); - } catch { - // not available — try the next backend - } - } else { - try { - // @ts-ignore — optional peer dependency - await import("quickjs-emscripten"); - const { QuickJSExecutor } = await import("./quickjs.js"); - return new QuickJSExecutor(options); - } catch { - // not available — try the next backend + } catch (error) { + if (isMissingOptionalDependency(error, "isolated-vm")) { + continue; + } + throw error; } } } /* oxlint-enable no-await-in-loop */ throw new Error( - "No sandbox runtime found. Install one of:\n" + + "No sandbox runtime found. Install one of:\n" + " npm install @robinbraemer/llrt # Native LLRT (default candidate)\n" + - " npm install isolated-vm # V8 isolates (Node.js fallback)\n" + - " npm install quickjs-emscripten # WASM QuickJS (Bun, Workers, browser)", + " npm install isolated-vm # Data-only V8 isolate fallback\n" + + "QuickJS is available only by passing new QuickJSExecutor(...) explicitly.", ); } +export function autoExecutorBackendOrder(): readonly ("llrt" | "isolated-vm")[] { + return isBun() ? ["llrt"] : ["llrt", "isolated-vm"]; +} + export function isMissingOptionalDependency( error: unknown, dependency: string, diff --git a/packages/codemode/src/executor/data-only.ts b/packages/codemode/src/executor/data-only.ts new file mode 100644 index 0000000..876c5de --- /dev/null +++ b/packages/codemode/src/executor/data-only.ts @@ -0,0 +1,75 @@ +import type { ExecuteResult, ExecuteStats } from "../types.js"; + +const MAX_GUARD_NODES = 100_000; + +interface PendingNode { + value: unknown; + path: string; +} + +export function findFunctionPath(value: unknown, path = "input"): string | null { + const seen = new WeakSet(); + const pending: PendingNode[] = [{ value, path }]; + let checked = 0; + let queued = 1; + + function checkBudget(nextPath: string): string | null { + checked += 1; + return checked > MAX_GUARD_NODES ? `${nextPath} (object graph too large)` : null; + } + + while (pending.length > 0) { + const current = pending.pop(); + if (!current) break; + + const budgetError = checkBudget(current.path); + if (budgetError) return budgetError; + if (typeof current.value === "function") return current.path; + if (current.value === null || typeof current.value !== "object") continue; + if (seen.has(current.value)) continue; + seen.add(current.value); + + for (const key in current.value) { + if (!Object.prototype.propertyIsEnumerable.call(current.value, key)) { + continue; + } + const descriptor = Object.getOwnPropertyDescriptor(current.value, key); + if (!descriptor) continue; + const childPath = + Array.isArray(current.value) && String(Number(key)) === key + ? `${current.path}[${key}]` + : `${current.path}.${key}`; + if (!("value" in descriptor)) { + return `${childPath} (accessor property is not data-only)`; + } + queued += 1; + if (queued > MAX_GUARD_NODES) { + return `${childPath} (object graph too large)`; + } + pending.push({ + value: descriptor.value, + path: childPath, + }); + } + } + + return null; +} + +export function dataOnlyFunctionError(functionPath: string): string { + return `data-only execution does not accept function values at ${functionPath}`; +} + +export function rejectDataOnlyFunctions( + input: Record, + stats: ExecuteStats, +): ExecuteResult | null { + const functionPath = findFunctionPath(input); + if (!functionPath) return null; + + return { + result: undefined, + error: dataOnlyFunctionError(functionPath), + stats, + }; +} diff --git a/packages/codemode/src/executor/isolated-vm.ts b/packages/codemode/src/executor/isolated-vm.ts index bf83073..fda7cb6 100644 --- a/packages/codemode/src/executor/isolated-vm.ts +++ b/packages/codemode/src/executor/isolated-vm.ts @@ -1,4 +1,24 @@ +import { + DEFAULT_MAX_RESULT_BYTES, +} from "../limits.js"; import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../types.js"; +import { findFunctionPath, rejectDataOnlyFunctions } from "./data-only.js"; + +const UTF8_BYTE_LENGTH_SOURCE = `function(value) { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index); + if (codePoint === undefined) continue; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else { + bytes += 4; + index += 1; + } + } + return bytes; +}`; /** * Executor implementation using isolated-vm (V8 isolates). @@ -6,26 +26,39 @@ import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../t * * Each execute() call creates a fresh V8 isolate with its own heap — no state * leaks between calls. The sandbox has zero I/O capabilities by default (no - * fetch, no fs, no require). The only way out is through injected host functions. + * fetch, no fs, no require). Host callbacks fail closed; use LLRT for + * request-capable execution. */ export class IsolatedVMExecutor implements Executor { private memoryMB: number; private timeoutMs: number; private wallTimeMs: number; + private maxResultBytes: number; constructor(options: SandboxOptions = {}) { this.memoryMB = options.memoryMB ?? 64; this.timeoutMs = options.timeoutMs ?? 30_000; this.wallTimeMs = options.wallTimeMs ?? 60_000; + this.maxResultBytes = options.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES; } async execute( code: string, globals: Record, ): Promise { + if (hasHostFunctions(globals)) { + return { + result: undefined, + error: + "IsolatedVMExecutor does not support host functions; use LlrtNativeExecutor for request-capable execution", + stats: emptyStats(0, this.memoryMB), + }; + } + // @ts-ignore — optional peer dependency const ivm = (await import("isolated-vm")).default ?? (await import("isolated-vm")); const isolate = new ivm.Isolate({ memoryLimit: this.memoryMB }); + const abortController = new AbortController(); let context: Awaited> | undefined; try { @@ -47,61 +80,29 @@ export class IsolatedVMExecutor implements Executor { // Inject globals — sequential awaits required: each jail.set/context.eval // depends on prior state (ref counters, globalThis assignments). /* oxlint-disable no-await-in-loop */ - let refCounter = 0; for (const [name, value] of Object.entries(globals)) { - if (typeof value === "function") { - // Async host function: set Reference, wrap with .apply() in isolate - const refName = `__ref${refCounter++}`; - await jail.set(refName, new ivm.Reference(value)); - await context.eval(` - globalThis[${JSON.stringify(name)}] = function(...args) { - return ${refName}.apply(undefined, args, { - arguments: { copy: true }, - result: { promise: true, copy: true }, - }); - }; - `); - } else if (isNamespaceWithMethods(value)) { - // Namespace object with methods (e.g. { request: fn }) - const ns = value as Record; - let nsSetup = `globalThis[${JSON.stringify(name)}] = {};\n`; - - for (const [key, val] of Object.entries(ns)) { - if (typeof val === "function") { - const refName = `__ref${refCounter++}`; - await jail.set(refName, new ivm.Reference(val)); - nsSetup += ` - globalThis[${JSON.stringify(name)}][${JSON.stringify(key)}] = function(...args) { - return ${refName}.apply(undefined, args, { - arguments: { copy: true }, - result: { promise: true, copy: true }, - }); - }; - `; - } - } - - // Inject non-function properties as JSON - const dataProps = Object.entries(ns).filter(([, v]) => typeof v !== "function"); - if (dataProps.length > 0) { - const dataObj = Object.fromEntries(dataProps); - nsSetup += `Object.assign(globalThis[${JSON.stringify(name)}], ${JSON.stringify(dataObj)});\n`; - } - - await context.eval(nsSetup); - } else { - // Plain data: inject as JSON - await context.eval( - `globalThis[${JSON.stringify(name)}] = ${JSON.stringify(value)};`, - ); - } + // Plain data: inject as JSON + await context.eval( + `globalThis[${JSON.stringify(name)}] = ${JSON.stringify(value)};`, + ); } /* oxlint-enable no-await-in-loop */ // Execute the code with both CPU timeout and wall-clock timeout. // The ivm timeout only covers CPU time; async host calls (request bridge) // can stall indefinitely without a wall-clock guard. - const wrappedCode = `(${code})()`; + const wrappedCode = `(async () => { + const __codemodeJsonStringify = JSON.stringify.bind(JSON); + const __codemodeUtf8ByteLength = ${UTF8_BYTE_LENGTH_SOURCE}; + const result = await (${code})(); + if (result === undefined) return "__cmUndef"; + const resultJson = __codemodeJsonStringify(result); + if (resultJson === undefined) return "__cmUndef"; + if (__codemodeUtf8ByteLength(resultJson) > ${this.maxResultBytes}) { + throw new Error("Execution result exceeds limit of ${this.maxResultBytes} bytes"); + } + return resultJson; + })()`; const script = await isolate.compileScript(wrappedCode); let wallTimer: ReturnType | undefined; @@ -113,7 +114,10 @@ export class IsolatedVMExecutor implements Executor { }).finally(() => clearTimeout(wallTimer)), new Promise((_, reject) => { wallTimer = setTimeout( - () => reject(new Error("Wall-clock timeout exceeded")), + () => { + abortController.abort(); + reject(new Error("Wall-clock timeout exceeded")); + }, this.wallTimeMs, ); // Don't prevent process exit @@ -124,7 +128,8 @@ export class IsolatedVMExecutor implements Executor { ]); const stats = captureStats(isolate); - return { result, stats }; + validateExecutionResult(result, this.maxResultBytes); + return { result: parseExecutionResult(result), stats }; } catch (err) { const stats = captureStats(isolate); return { @@ -134,11 +139,22 @@ export class IsolatedVMExecutor implements Executor { }; } finally { context?.release(); + abortController.abort(); if (!isolate.isDisposed) { isolate.dispose(); } } } + + async executeData( + code: string, + input: Record, + ): Promise { + const rejection = rejectDataOnlyFunctions(input, emptyStats(0, this.memoryMB)); + if (rejection) return rejection; + + return await this.execute(code, input); + } } /** @@ -177,13 +193,42 @@ function captureStats(isolate: { isDisposed: boolean; cpuTime: bigint; wallTime: }; } -function isNamespaceWithMethods(value: unknown): boolean { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value as Record).some( - (v) => typeof v === "function", - ) - ); +function hasHostFunctions(globals: Record): boolean { + return findFunctionPath(globals) !== null; +} + +function validateExecutionResult(result: unknown, maxResultBytes: number): void { + if (result === "__cmUndef" || result === undefined) return; + if (typeof result !== "string") { + throw new Error("Execution result serialization returned a non-string value"); + } + if (Buffer.byteLength(result, "utf8") > maxResultBytes) { + throw new Error(`Execution result exceeds limit of ${maxResultBytes} bytes`); + } +} + +function emptyStats(wallTimeMs: number, memoryMB: number): ExecuteStats { + return { + cpuTimeMs: wallTimeMs, + wallTimeMs, + heapUsedBytes: 0, + heapTotalBytes: 0, + externalBytes: 0, + heapSizeLimitBytes: memoryMB * 1024 * 1024, + totalPhysicalBytes: 0, + availableBytes: memoryMB * 1024 * 1024, + executableBytes: 0, + mallocedBytes: 0, + peakMallocedBytes: 0, + }; +} + +function parseExecutionResult(result: unknown): unknown { + if (result === "__cmUndef" || result === undefined) { + return undefined; + } + if (typeof result !== "string") { + return result; + } + return JSON.parse(result); } diff --git a/packages/codemode/src/executor/llrt-native.ts b/packages/codemode/src/executor/llrt-native.ts index 845cd8d..d7c66dd 100644 --- a/packages/codemode/src/executor/llrt-native.ts +++ b/packages/codemode/src/executor/llrt-native.ts @@ -1,20 +1,41 @@ -import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../types.js"; +import { + DEFAULT_MAX_HOST_CALLS, + DEFAULT_MAX_HOST_PAYLOAD_BYTES, + DEFAULT_MAX_HOST_RESULT_BYTES, + DEFAULT_MAX_RESULT_BYTES, +} from "../limits.js"; +import type { + CapabilityExecutor, + CapabilityManifest, + ExecuteResult, + ExecuteStats, + SandboxOptions, +} from "../types.js"; +import { rejectDataOnlyFunctions } from "./data-only.js"; /** * Experimental in-process LLRT executor backed by `@robinbraemer/llrt`. * - * Plain globals cross as JSON. Function globals and one-level namespace - * methods cross through the LLRT host callback bridge. + * Plain globals cross as JSON. Function globals are retained only for legacy + * compatibility and are internally bound through an explicit LLRT host manifest. */ -export class LlrtNativeExecutor implements Executor { +export class LlrtNativeExecutor implements CapabilityExecutor { private readonly memoryMB: number; private readonly timeoutMs: number; private readonly wallTimeMs: number; + private readonly maxHostCalls: number; + private readonly maxHostPayloadBytes: number; + private readonly maxHostResultBytes: number; + private readonly maxResultBytes: number; constructor(options: SandboxOptions = {}) { this.memoryMB = options.memoryMB ?? 64; this.timeoutMs = options.timeoutMs ?? 30_000; this.wallTimeMs = options.wallTimeMs ?? 60_000; + this.maxHostCalls = options.maxHostCalls ?? DEFAULT_MAX_HOST_CALLS; + this.maxHostPayloadBytes = options.maxHostPayloadBytes ?? DEFAULT_MAX_HOST_PAYLOAD_BYTES; + this.maxHostResultBytes = options.maxHostResultBytes ?? DEFAULT_MAX_HOST_RESULT_BYTES; + this.maxResultBytes = options.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES; } async execute( @@ -30,10 +51,97 @@ export class LlrtNativeExecutor implements Executor { wallTimeMs: Math.min(this.timeoutMs, this.wallTimeMs), }); const bindings = buildHostBindings(globals); - const result = await runtime.callJson( - wrapCode(code), - bindings.input, - { functions: bindings.functions }, + const options = { + maxHostCalls: this.maxHostCalls, + maxHostPayloadBytes: this.maxHostPayloadBytes, + maxHostResultBytes: this.maxHostResultBytes, + maxResultBytes: this.maxResultBytes, + }; + const result = bindings.hasHostFunctions + ? await runtime.callJsonWithHost( + wrapCode(code), + bindings.input, + { + namespaces: { + [LEGACY_HOST_NAMESPACE]: bindings.functions, + }, + }, + options, + ) + : await runtime.callJson( + wrapCode(code), + bindings.input, + options, + ); + + if (!result.ok) { + return { + result: undefined, + error: formatLlrtError(result.error), + stats: statsFromLlrt(result.stats, start, this.memoryMB), + }; + } + + return { + result: result.value, + stats: statsFromLlrt(result.stats, start, this.memoryMB), + }; + } catch (error) { + return { + result: undefined, + error: error instanceof Error ? error.message : String(error), + stats: emptyStats(Date.now() - start, this.memoryMB), + }; + } + } + + async executeData( + code: string, + input: Record, + ): Promise { + const rejection = rejectDataOnlyFunctions(input, emptyStats(0, this.memoryMB)); + if (rejection) return rejection; + + return await this.execute(code, input); + } + + async executeWithCapabilities( + code: string, + input: Record, + capabilities: CapabilityManifest, + ): Promise { + const rejection = rejectDataOnlyFunctions(input, emptyStats(0, this.memoryMB)); + if (rejection) return rejection; + const collision = capabilityNamespaceCollision(input, capabilities); + if (collision) { + return { + result: undefined, + error: `input global "${collision}" collides with capability namespace "${collision}"`, + stats: emptyStats(0, this.memoryMB), + }; + } + + const start = Date.now(); + + try { + const { LlrtRuntime } = await import("@robinbraemer/llrt"); + const runtime = new LlrtRuntime({ + memoryMB: this.memoryMB, + wallTimeMs: Math.min(this.timeoutMs, this.wallTimeMs), + }); + const result = await runtime.callJsonWithHost( + wrapCapabilityCode(code), + { + globals: input, + namespaces: capabilityNamespaces(capabilities), + }, + capabilityManifestToLlrt(capabilities), + { + maxHostCalls: this.maxHostCalls, + maxHostPayloadBytes: this.maxHostPayloadBytes, + maxHostResultBytes: this.maxHostResultBytes, + maxResultBytes: this.maxResultBytes, + }, ); if (!result.ok) { @@ -59,6 +167,7 @@ export class LlrtNativeExecutor implements Executor { } type HostCallable = (...args: unknown[]) => unknown | Promise; +const LEGACY_HOST_NAMESPACE = "__codemodeHost"; interface ExecutionInput { globals: Record; @@ -66,9 +175,15 @@ interface ExecutionInput { namespaceFunctions: Record>; } +interface CapabilityExecutionInput { + globals: Record; + namespaces: Record; +} + function buildHostBindings(globals: Record): { input: ExecutionInput; functions: Record; + hasHostFunctions: boolean; } { const input: ExecutionInput = { globals: {}, @@ -76,11 +191,21 @@ function buildHostBindings(globals: Record): { namespaceFunctions: {}, }; const functions: Record = {}; + let hasHostFunctions = false; + let hostIndex = 0; + + function nextHostName(): string { + const name = `f${hostIndex}`; + hostIndex += 1; + return name; + } for (const [name, value] of Object.entries(globals)) { if (isHostCallable(value)) { - input.globalFunctions[name] = name; - functions[name] = value; + const hostName = nextHostName(); + input.globalFunctions[name] = hostName; + functions[hostName] = value; + hasHostFunctions = true; continue; } @@ -90,9 +215,10 @@ function buildHostBindings(globals: Record): { for (const [key, entry] of Object.entries(value)) { if (isHostCallable(entry)) { - const hostName = `${name}.${key}`; + const hostName = nextHostName(); namespaceFunctions[key] = hostName; functions[hostName] = entry; + hasHostFunctions = true; } else { namespaceData[key] = entry; } @@ -108,7 +234,7 @@ function buildHostBindings(globals: Record): { input.globals[name] = value; } - return { input, functions }; + return { input, functions, hasHostFunctions }; } function isHostCallable(value: unknown): value is HostCallable { @@ -119,6 +245,47 @@ function isNamespace(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function capabilityManifestToLlrt(capabilities: CapabilityManifest): { + namespaces: Record>; +} { + const namespaces: Record> = {}; + for (const [namespace, methods] of Object.entries(capabilities.namespaces)) { + const namespaceFunctions: Record = {}; + for (const [methodName, capability] of Object.entries(methods)) { + namespaceFunctions[methodName] = function ( + this: { signal?: AbortSignal }, + ...args: unknown[] + ) { + return capability.call.call({ signal: this.signal ?? new AbortController().signal }, ...args); + }; + } + namespaces[namespace] = namespaceFunctions; + } + return { namespaces }; +} + +function capabilityNamespaces( + capabilities: CapabilityManifest, +): Record { + const namespaces: Record = {}; + for (const [namespace, methods] of Object.entries(capabilities.namespaces)) { + namespaces[namespace] = Object.keys(methods); + } + return namespaces; +} + +function capabilityNamespaceCollision( + input: Record, + capabilities: CapabilityManifest, +): string | null { + for (const namespace of Object.keys(capabilities.namespaces)) { + if (Object.prototype.hasOwnProperty.call(input, namespace)) { + return namespace; + } + } + return null; +} + function wrapCode(code: string): string { return `async ({ input, host }) => { globalThis.require = undefined; @@ -134,14 +301,42 @@ function wrapCode(code: string): string { globalThis[name] = value; } + const hostFunctions = host?.[${JSON.stringify(LEGACY_HOST_NAMESPACE)}]; for (const [name, hostName] of Object.entries(input.globalFunctions)) { - globalThis[name] = (...args) => host[hostName](...args); + globalThis[name] = (...args) => hostFunctions[hostName](...args); } for (const [namespace, methods] of Object.entries(input.namespaceFunctions)) { const namespaceValue = globalThis[namespace] ?? {}; for (const [methodName, hostName] of Object.entries(methods)) { - namespaceValue[methodName] = (...args) => host[hostName](...args); + namespaceValue[methodName] = (...args) => hostFunctions[hostName](...args); + } + globalThis[namespace] = namespaceValue; + } + + return await (${code})(); + }`; +} + +function wrapCapabilityCode(code: string): string { + return `async ({ input, host }) => { + globalThis.require = undefined; + globalThis.process = undefined; + globalThis.fetch = undefined; + globalThis.console = { + log: () => {}, + warn: () => {}, + error: () => {}, + }; + + for (const [name, value] of Object.entries(input.globals)) { + globalThis[name] = value; + } + + for (const [namespace, methods] of Object.entries(input.namespaces)) { + const namespaceValue = Object.create(null); + for (const methodName of methods) { + namespaceValue[methodName] = (...args) => host[namespace][methodName](...args); } globalThis[namespace] = namespaceValue; } diff --git a/packages/codemode/src/executor/llrt-process.ts b/packages/codemode/src/executor/llrt-process.ts index f8bede8..ffbcad5 100644 --- a/packages/codemode/src/executor/llrt-process.ts +++ b/packages/codemode/src/executor/llrt-process.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../types.js"; +import { rejectDataOnlyFunctions } from "./data-only.js"; export interface LlrtProcessExecutorOptions extends SandboxOptions { binaryPath?: string; @@ -25,18 +26,25 @@ export class LlrtProcessExecutor implements Executor { code: string, globals: Record, ): Promise { - const start = Date.now(); - if (Object.keys(globals).length > 0) { return { result: undefined, error: "LlrtProcessExecutor POC does not support globals or host callbacks yet", - stats: captureStats(start), + stats: captureStats(Date.now()), }; } + return await this.executeProcess(code, {}); + } + + private async executeProcess( + code: string, + globals: Record, + ): Promise { + const start = Date.now(); + try { - const stdout = await runLlrt(this.binaryPath, wrapCode(code), this.wallTimeMs); + const stdout = await runLlrt(this.binaryPath, wrapCode(code, globals), this.wallTimeMs); const encoded = lastJsonLine(stdout); const envelope = JSON.parse(encoded) as | { ok: true; value?: unknown } @@ -62,12 +70,24 @@ export class LlrtProcessExecutor implements Executor { }; } } + + async executeData( + code: string, + input: Record, + ): Promise { + const rejection = rejectDataOnlyFunctions(input, captureStats(Date.now())); + if (rejection) return rejection; + + return await this.executeProcess(code, input); + } } -function wrapCode(code: string): string { +function wrapCode(code: string, globals: Record): string { + const globalsJson = JSON.stringify(globals); return ` (async () => { try { + Object.assign(globalThis, ${globalsJson}); const value = await (${code})(); console.log(JSON.stringify({ ok: true, value })); } catch (error) { diff --git a/packages/codemode/src/executor/quickjs.ts b/packages/codemode/src/executor/quickjs.ts index b71b78f..8c57e9f 100644 --- a/packages/codemode/src/executor/quickjs.ts +++ b/packages/codemode/src/executor/quickjs.ts @@ -1,4 +1,22 @@ +import { DEFAULT_MAX_RESULT_BYTES } from "../limits.js"; import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../types.js"; +import { findFunctionPath, rejectDataOnlyFunctions } from "./data-only.js"; + +const UTF8_BYTE_LENGTH_SOURCE = `function(value) { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index); + if (codePoint === undefined) continue; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else { + bytes += 4; + index += 1; + } + } + return bytes; +}`; /** * Executor implementation using quickjs-emscripten (pure WASM QuickJS). @@ -8,17 +26,17 @@ import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../t * — most importantly **Bun** (its JavaScriptCore engine does not export V8 * symbols like `v8::ValueSerializer::Delegate::IsHostObject` that isolated-vm * needs), and any future Cloudflare Workers / browser deployment. Production - * callers on Node should use `IsolatedVMExecutor` for performance, maturity, - * and the upstream-bug-free async story (see below). Backend selection is - * automatic via `createExecutor` in `./auto.ts`. + * callers should use `LlrtNativeExecutor` or `IsolatedVMExecutor` for host + * callbacks, performance, maturity, and the upstream-bug-free async story + * (see below). Backend selection is automatic via `createExecutor` in + * `./auto.ts`. * * No native compilation is required: this runs on Node, Bun, Cloudflare * Workers, Deno, and the browser. * * Each execute() call creates a fresh QuickJS runtime + context — no state - * leaks between calls. The sandbox has zero I/O capabilities by default (no - * fetch, no fs, no require, no process). The only way out is through - * injected host functions. + * leaks between calls. The sandbox has zero I/O capabilities (no fetch, no fs, + * no require, no process). Host callbacks fail closed at execution start. * * Stats parity: the `ExecuteStats` shape is shared with `IsolatedVMExecutor`. * QuickJS does not expose V8-specific counters (executable bytes, peak @@ -38,9 +56,7 @@ import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../t * - **CPU timeout is wall-clock-based.** `IsolatedVMExecutor` enforces a true * CPU-time limit via V8's script timeout. QuickJS does not expose CPU * time separately from wall time, so `timeoutMs` here is measured from - * `execute()` entry with `Date.now()`. Async host calls that take wall - * time count against this budget — pick `timeoutMs` higher than you would - * for isolated-vm if guest code does any I/O via host functions. + * `execute()` entry with `Date.now()`. * * Upstream bugs in `quickjs-emscripten@0.32.0` release-asyncify * ------------------------------------------------------------- @@ -51,21 +67,17 @@ import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../t * * - **#258** — multiple sequential `await hostFn()` calls in user code crash * with "Aborted(Assertion failed: p->ref_count == 0)" + WASM "memory - * access out of bounds" trap. Single `await` works. Use `Promise.all` for - * parallel calls instead of chained sequential `await`s. + * access out of bounds" trap. * - **#261** — `QuickJSAsyncWASMModule.newRuntime` disposes in the wrong - * order, producing `Aborted(Assertion failed: ...)` noise on dispose - * after asyncified host functions have been defined. Caught in our - * `finally`; result correctness unaffected. + * order, producing `Aborted(Assertion failed: ...)` noise on dispose. * * This implementation also works around two related construction-ordering * bugs in the same release-asyncify build: * * 1. Calling `evalCode` / `evalCodeAsync` *before* `newAsyncifiedFunction` * registration corrupts asyncify bookkeeping and crashes the second - * sequential `await` in user code. Mitigation: register host functions - * first and build all setup (no-op console, plain-data globals) via the - * handle API only — see `injectNoopConsole`, `injectValue`. + * sequential `await` in user code. Host callbacks are therefore disabled + * for untrusted execution. * 2. The IIFE result handle is not reliably GC-anchored once the user's * `(async () => ...)()` resolves: `context.dump(handle)` then crashes * with "memory access out of bounds" even though the JS-side wrapper @@ -73,32 +85,43 @@ import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../t * `JSON.stringify` envelope so the value we read back is a primitive * string. See `execute()` for the wrapping detail. * - * **Guest-code constraint for callers**: sandboxed user code must not chain - * sequential `await`s on host functions. Use `Promise.all([fn1(), fn2()])` - * or call once-per-execution. This applies on every runtime, not just Bun. + * **Guest-code constraint for callers**: use QuickJS for explicit data-only + * execution. For request-capable execution, use LLRT. */ export class QuickJSExecutor implements Executor { private memoryMB: number; private timeoutMs: number; private wallTimeMs: number; + private maxResultBytes: number; constructor(options: SandboxOptions = {}) { this.memoryMB = options.memoryMB ?? 64; this.timeoutMs = options.timeoutMs ?? 30_000; this.wallTimeMs = options.wallTimeMs ?? 60_000; + this.maxResultBytes = options.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES; } async execute( code: string, globals: Record, ): Promise { + const start = Date.now(); + if (hasHostFunctions(globals)) { + return { + result: undefined, + error: + "QuickJSExecutor does not support host functions; use LlrtNativeExecutor for request-capable execution", + stats: emptyStats(start, this.memoryMB), + }; + } + // Lazy import: optional peer dependency. const qjs = await import("quickjs-emscripten"); const context = await qjs.newAsyncContext(); const runtime = context.runtime; runtime.setMemoryLimit(this.memoryMB * 1024 * 1024); - const start = Date.now(); + const abortController = new AbortController(); let cpuDeadlineHit = false; let wallTimer: ReturnType | undefined; let wallTimedOut = false; @@ -116,42 +139,18 @@ export class QuickJSExecutor implements Executor { return false; }); - // Track disposable handles created during global injection so we can - // dispose them in `finally` even if injection throws partway through. - const injectedHandles: Array<{ dispose(): void; alive: boolean }> = []; - try { - // CRITICAL: setup ordering matters with release-asyncify QuickJS. - // - // 1. Asyncified host functions (`newAsyncifiedFunction`) MUST be - // registered before any `evalCode`/`evalCodeAsync` — otherwise the - // second sequential `await hostFn()` in user code crashes with - // "memory access out of bounds" / GC mark assertions. - // 2. The no-op console is therefore also built via the handle API - // (`newObject` + `newFunction`), never via `evalCode`. - // 3. Plain-data and namespace-data injection use only handle-API calls - // (`newNumber` / `newString` / `newObject` / `newArray`), see - // `injectValue`. - // - // Inject host functions first so they are registered before the user's - // `evalCodeAsync` is invoked. Order among them is not significant. + // Plain-data injection uses only handle-API calls (`newNumber` / + // `newString` / `newObject` / `newArray`), never `evalCode`, to avoid + // quickjs-emscripten release-asyncify construction-ordering bugs. for (const [name, value] of Object.entries(globals)) { - if (typeof value === "function") { - injectAsyncFunction(context, name, value as (...args: unknown[]) => unknown, injectedHandles); - } else if (isNamespaceWithMethods(value)) { - injectNamespace(context, name, value as Record, injectedHandles); - } else { - const valueHandle = injectValue(context, value); - context.setProp(context.global, name, valueHandle); - disposeIfOwned(context, valueHandle); - } + const valueHandle = injectValue(context, value); + context.setProp(context.global, name, valueHandle); + disposeIfOwned(context, valueHandle); } - // Install no-op console AFTER asyncified functions (and via handle API - // only) so we don't trigger the eval-before-asyncified-registration - // failure mode. Injecting a real console would also be an OOM vector - // since logs accumulate in the host process outside the sandbox - // memory limit. + // Injecting a real console would be an OOM vector since logs accumulate + // in the host process outside the sandbox memory limit. injectNoopConsole(context); // Wall-clock timeout: hard-stop the entire execution including async @@ -160,6 +159,7 @@ export class QuickJSExecutor implements Executor { const wallPromise = new Promise((_, reject) => { wallTimer = setTimeout(() => { wallTimedOut = true; + abortController.abort(); // Force the QuickJS interrupt handler to abort on the next tick by // marking the deadline as exceeded. evalCodeAsync will surface // "interrupted" on the next bytecode boundary. @@ -186,7 +186,7 @@ export class QuickJSExecutor implements Executor { // // Errors thrown by the user code still come back through the normal // `resolution.error` channel; only successful results need wrapping. - const wrappedCode = `(async () => { const __r = await (${code})(); return __r === undefined ? "__cmUndef" : JSON.stringify(__r); })()`; + const wrappedCode = `(async () => { const __codemodeJsonStringify = JSON.stringify.bind(JSON); const __codemodeUtf8ByteLength = ${UTF8_BYTE_LENGTH_SOURCE}; const __r = await (${code})(); if (__r === undefined) return "__cmUndef"; const __j = __codemodeJsonStringify(__r); if (__j === undefined) return "__cmUndef"; if (__codemodeUtf8ByteLength(__j) > ${this.maxResultBytes}) throw new Error("Execution result exceeds limit of ${this.maxResultBytes} bytes"); return __j; })()`; const evalP = context.evalCodeAsync(wrappedCode); const evalResult = await Promise.race([evalP, wallPromise]); @@ -210,6 +210,7 @@ export class QuickJSExecutor implements Executor { const encoded = context.dump(resolution.value); resolution.value.dispose(); promiseHandle.dispose(); + validateExecutionResult(encoded, this.maxResultBytes); let value: unknown; if (encoded === "__cmUndef" || encoded === undefined) { @@ -242,15 +243,7 @@ export class QuickJSExecutor implements Executor { }; } finally { clearTimeout(wallTimer); - for (const handle of injectedHandles) { - if (handle.alive) { - try { - handle.dispose(); - } catch { - // already disposed - } - } - } + abortController.abort(); try { // `context.dispose()` already owns the runtime lifetime // (quickjs-emscripten-core attaches the runtime to the context's @@ -260,11 +253,20 @@ export class QuickJSExecutor implements Executor { context.dispose(); } catch { // ignore — best-effort cleanup; release-asyncify can throw - // assertion noise on dispose after asyncified host fns are defined - // (upstream quickjs-emscripten#261). + // assertion noise on dispose (upstream quickjs-emscripten#261). } } } + + async executeData( + code: string, + input: Record, + ): Promise { + const rejection = rejectDataOnlyFunctions(input, emptyStats(Date.now(), this.memoryMB)); + if (rejection) return rejection; + + return await this.execute(code, input); + } } /** @@ -277,8 +279,8 @@ export class QuickJSExecutor implements Executor { * event loop can also process the host-side promises that the asyncified * functions are awaiting. * - * Races against `wallPromise` so a hung host call still produces a wall-clock - * timeout error rather than blocking the executor forever. + * Races against `wallPromise` so a never-settling guest promise still produces + * a wall-clock timeout error rather than blocking the executor forever. */ async function raceWithJobPump( context: import("quickjs-emscripten").QuickJSAsyncContext, @@ -288,14 +290,13 @@ async function raceWithJobPump( // Start resolving the user-code promise on the QuickJS side. The returned // host-side Promise settles once the user's `(async () => ...)()` resolves // — but only if we keep draining the runtime's pending-job queue while we - // wait, because asyncified host callbacks enqueue their continuations - // there. + // wait. const resolveP = context.resolvePromise(promiseHandle); let settled = false; void resolveP.then(() => { settled = true; }, () => { settled = true; }); // Pump loop: drain pending jobs, yield one macrotask, repeat. We race - // against `wallPromise` so a hung host call surfaces as a wall-clock + // against `wallPromise` so a hung guest promise surfaces as a wall-clock // timeout instead of an infinite loop. // // Implementation notes: @@ -335,105 +336,22 @@ async function raceWithJobPump( return resolveP; } -interface InjectedHandle { - dispose(): void; - alive: boolean; -} - -/** - * Inject an async (or sync) host function as a global by name. - * - * We use `newAsyncifiedFunction` so the sandboxed code can `await` the call - * exactly like in isolated-vm. Arguments and return values cross the boundary - * via JSON-clone, matching isolated-vm's `{ copy: true }` semantics. - * - * NOTE on ordering: this MUST be called *before* any `evalCode` / - * `evalCodeAsync` against the context — otherwise the release-asyncify build - * corrupts its asyncify bookkeeping and sequential `await`s in user code - * crash with "memory access out of bounds" / GC assertion failures. See - * `injectNoopConsole` for the alternate handle-API approach used for setup. - */ -function injectAsyncFunction( - context: import("quickjs-emscripten").QuickJSAsyncContext, - name: string, - fn: (...args: unknown[]) => unknown, - tracked: InjectedHandle[], -): void { - const handle = context.newAsyncifiedFunction(name, async (...argHandles) => { - const args = argHandles.map((h) => context.dump(h)); - let result: unknown; - try { - result = await fn(...args); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { error: context.newString(message) }; - } - return injectValue(context, result); - }); - context.setProp(context.global, name, handle); - handle.dispose(); - const tracker: InjectedHandle = { - alive: false, - dispose: () => {}, - }; - tracked.push(tracker); -} - -/** - * Inject a namespace object containing host functions and/or data. - * - * Function values become asyncified callables; non-function values are - * JSON-cloned. Matches `IsolatedVMExecutor`'s namespace injection semantics. - */ -function injectNamespace( - context: import("quickjs-emscripten").QuickJSAsyncContext, - name: string, - ns: Record, - tracked: InjectedHandle[], -): void { - const nsHandle = context.newObject(); - for (const [key, val] of Object.entries(ns)) { - if (typeof val === "function") { - const fnHandle = context.newAsyncifiedFunction(`${name}.${key}`, async (...argHandles) => { - const args = argHandles.map((h) => context.dump(h)); - let result: unknown; - try { - result = await (val as (...a: unknown[]) => unknown)(...args); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { error: context.newString(message) }; - } - return injectValue(context, result); - }); - context.setProp(nsHandle, key, fnHandle); - fnHandle.dispose(); - } else if (val !== undefined) { - const valueHandle = injectValue(context, val); - context.setProp(nsHandle, key, valueHandle); - disposeIfOwned(context, valueHandle); - } +function validateExecutionResult(result: unknown, maxResultBytes: number): void { + if (result === "__cmUndef" || result === undefined) return; + if (typeof result !== "string") { + throw new Error("Execution result serialization returned a non-string value"); + } + if (Buffer.byteLength(result, "utf8") > maxResultBytes) { + throw new Error(`Execution result exceeds limit of ${maxResultBytes} bytes`); } - context.setProp(context.global, name, nsHandle); - const tracker: InjectedHandle = { - alive: true, - dispose: () => { - tracker.alive = false; - nsHandle.dispose(); - }, - }; - tracked.push(tracker); - tracker.dispose(); } /** * Marshal a JS host value into a fresh QuickJS handle. * * We build handles directly via `newNumber` / `newString` / `newObject` / - * `newArray` rather than going through `evalCode`. Calling `evalCode` from - * inside an asyncified host callback can corrupt the QuickJS reference count - * (observed: "Assertion failed: p->ref_count == 0 / > 0" on sequential - * awaits) because the C-side eval allocates handles on a stack frame that - * asyncify is already unwinding. + * `newArray` rather than going through `evalCode`, keeping setup independent + * from the release-asyncify eval ordering bugs. * * Semantics match isolated-vm's `{ copy: true }`: only JSON-cloneable shapes * (primitives, plain objects, arrays) cross the boundary. Functions, Symbols, @@ -569,15 +487,25 @@ function captureStats( }; } -function isNamespaceWithMethods(value: unknown): boolean { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value as Record).some( - (v) => typeof v === "function", - ) - ); +function emptyStats(startMs: number, memoryMB: number): ExecuteStats { + const wallTimeMs = Date.now() - startMs; + return { + cpuTimeMs: wallTimeMs, + wallTimeMs, + heapUsedBytes: 0, + heapTotalBytes: 0, + externalBytes: 0, + heapSizeLimitBytes: memoryMB * 1024 * 1024, + totalPhysicalBytes: 0, + availableBytes: memoryMB * 1024 * 1024, + executableBytes: 0, + mallocedBytes: 0, + peakMallocedBytes: 0, + }; +} + +function hasHostFunctions(globals: Record): boolean { + return findFunctionPath(globals) !== null; } /** diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index 4958ad9..afd2396 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -3,7 +3,10 @@ export { CodeMode } from "./codemode.js"; // Types export type { + CapabilityExecutor, + CapabilityManifest, CodeModeOptions, + DataExecutor, Executor, ExecuteResult, ExecuteStats, @@ -14,6 +17,10 @@ export type { ToolCallResult, ToolDefinition, } from "./types.js"; +export { + emptyExecuteStats, + isCapabilityExecutor, +} from "./types.js"; // Executors (for advanced usage / custom executor selection) export { IsolatedVMExecutor } from "./executor/isolated-vm.js"; diff --git a/packages/codemode/src/limits.ts b/packages/codemode/src/limits.ts new file mode 100644 index 0000000..239a8c5 --- /dev/null +++ b/packages/codemode/src/limits.ts @@ -0,0 +1,5 @@ +export const DEFAULT_MAX_CODE_BYTES = 256 * 1024; +export const DEFAULT_MAX_HOST_CALLS = 100; +export const DEFAULT_MAX_HOST_PAYLOAD_BYTES = 1024 * 1024; +export const DEFAULT_MAX_HOST_RESULT_BYTES = 10 * 1024 * 1024; +export const DEFAULT_MAX_RESULT_BYTES = 10 * 1024 * 1024; diff --git a/packages/codemode/src/mcp.ts b/packages/codemode/src/mcp.ts index 9355e19..7757995 100644 --- a/packages/codemode/src/mcp.ts +++ b/packages/codemode/src/mcp.ts @@ -18,6 +18,7 @@ */ import type { CodeMode } from "./codemode.js"; +import { DEFAULT_MAX_CODE_BYTES } from "./limits.js"; import { z } from "zod"; /** @@ -37,7 +38,9 @@ export function registerTools( def.name, { description: def.description, - inputSchema: { code: z.string().describe("JavaScript code to execute") }, + inputSchema: { + code: z.string().max(DEFAULT_MAX_CODE_BYTES).describe("JavaScript code to execute"), + }, }, async (args: { code: string }) => { return codemode.callTool(def.name, { code: args.code }); diff --git a/packages/codemode/src/request-bridge.ts b/packages/codemode/src/request-bridge.ts index 2bec1b7..424601c 100644 --- a/packages/codemode/src/request-bridge.ts +++ b/packages/codemode/src/request-bridge.ts @@ -27,6 +27,8 @@ export interface SandboxResponse { export interface RequestBridgeOptions { /** Maximum number of requests per bridge instance. Default: 50. */ maxRequests?: number; + /** Maximum number of in-flight requests per bridge instance. Default: 8. */ + maxConcurrentRequests?: number; /** Maximum request body size in bytes. Default: 1MB. */ maxRequestBytes?: number; /** Maximum response body size in bytes. Default: 10MB. */ @@ -69,6 +71,7 @@ const BLOCKED_HEADER_PATTERNS = [ ]; const DEFAULT_MAX_REQUESTS = 50; +const DEFAULT_MAX_CONCURRENT_REQUESTS = 8; const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; // 1MB const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10MB @@ -123,9 +126,10 @@ async function readResponseWithLimit( if (!reader) { // No body stream — fall back to .text() (e.g., empty responses) const text = await abortable(response.text(), signal); - if (text.length > maxBytes) { + const bytes = Buffer.byteLength(text, "utf8"); + if (bytes > maxBytes) { throw new Error( - `Response too large: ${text.length} bytes exceeds limit of ${maxBytes} bytes`, + `Response too large: ${bytes} bytes exceeds limit of ${maxBytes} bytes`, ); } return text; @@ -262,6 +266,7 @@ export function createRequestBridge( options: RequestBridgeOptions = {}, ): RequestBridgeFn { const maxRequests = options.maxRequests ?? DEFAULT_MAX_REQUESTS; + const maxConcurrentRequests = options.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS; const maxRequestBytes = options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES; const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; const allowedHeaders = options.allowedHeaders @@ -272,6 +277,7 @@ export function createRequestBridge( : undefined; let requestCount = 0; + let inFlightRequests = 0; const bridge = async ( opts: SandboxRequestOptions, @@ -287,79 +293,89 @@ export function createRequestBridge( `Request limit exceeded: max ${maxRequests} requests per execution`, ); } - - // Validate HTTP method - const upperMethod = method.toUpperCase(); - if (!ALLOWED_METHODS.has(upperMethod)) { + if (inFlightRequests >= maxConcurrentRequests) { throw new Error( - `Invalid HTTP method: "${method}". Allowed: ${[...ALLOWED_METHODS].join(", ")}`, + `Concurrent request limit exceeded: max ${maxConcurrentRequests} in-flight requests per execution`, ); } + inFlightRequests += 1; - // Validate path (SSRF prevention) - validatePath(path); - - // Build URL - const url = new URL(path, baseUrl); - if (query) { - for (const [key, value] of Object.entries(query)) { - url.searchParams.set(key, String(value)); - } - } - - // Filter headers - const filteredHeaders = filterHeaders(headers, allowedHeaders); - - // Build request init - const init: RequestInit = { - method: upperMethod, - headers: { ...filteredHeaders }, - signal, - }; - - if (body !== undefined && body !== null) { - const bodyJson = JSON.stringify(body); - const bodyBytes = utf8ByteLength(bodyJson); - if (bodyBytes > maxRequestBytes) { + try { + // Validate HTTP method + const upperMethod = method.toUpperCase(); + if (!ALLOWED_METHODS.has(upperMethod)) { throw new Error( - `Request body too large: ${bodyBytes} bytes exceeds limit of ${maxRequestBytes} bytes`, + `Invalid HTTP method: "${method}". Allowed: ${[...ALLOWED_METHODS].join(", ")}`, ); } - init.body = bodyJson; - (init.headers as Record)["content-type"] = - (init.headers as Record)["content-type"] ?? "application/json"; - } - // Call the host handler - const response = await abortable( - Promise.resolve(handler(url.toString(), init)), - signal, - ); - throwIfAborted(signal); + // Validate path (SSRF prevention) + validatePath(path); - const responseHeaders = filterResponseHeaders(response.headers, exposedResponseHeaders); + // Build URL + const url = new URL(path, baseUrl); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, String(value)); + } + } - // Read response body with streaming size limit to avoid host OOM. - // Abort as soon as accumulated bytes exceed the limit. - const contentType = response.headers.get("content-type") ?? ""; - const text = await readResponseWithLimit(response, maxResponseBytes, signal); + // Filter headers + const filteredHeaders = filterHeaders(headers, allowedHeaders); + + // Build request init + const init: RequestInit = { + method: upperMethod, + headers: { ...filteredHeaders }, + signal, + }; + + if (body !== undefined && body !== null) { + const bodyJson = JSON.stringify(body); + const bodyBytes = utf8ByteLength(bodyJson); + if (bodyBytes > maxRequestBytes) { + throw new Error( + `Request body too large: ${bodyBytes} bytes exceeds limit of ${maxRequestBytes} bytes`, + ); + } + init.body = bodyJson; + (init.headers as Record)["content-type"] = + (init.headers as Record)["content-type"] ?? "application/json"; + } - let responseBody: unknown; - if (contentType.includes("application/json")) { - try { - responseBody = JSON.parse(text); - } catch { + // Call the host handler + const response = await abortable( + Promise.resolve(handler(url.toString(), init)), + signal, + ); + throwIfAborted(signal); + + const responseHeaders = filterResponseHeaders(response.headers, exposedResponseHeaders); + + // Read response body with streaming size limit to avoid host OOM. + // Abort as soon as accumulated bytes exceed the limit. + const contentType = response.headers.get("content-type") ?? ""; + const text = await readResponseWithLimit(response, maxResponseBytes, signal); + + let responseBody: unknown; + if (contentType.includes("application/json")) { + try { + responseBody = JSON.parse(text); + } catch { + responseBody = text; + } + } else { responseBody = text; } - } else { - responseBody = text; - } - return { - status: response.status, - headers: responseHeaders, - body: responseBody, - }; + return { + status: response.status, + headers: responseHeaders, + body: responseBody, + }; + } finally { + inFlightRequests -= 1; + } }; Object.defineProperty(bridge, 'requestCount', { diff --git a/packages/codemode/src/types.ts b/packages/codemode/src/types.ts index 9537cd0..a44d21a 100644 --- a/packages/codemode/src/types.ts +++ b/packages/codemode/src/types.ts @@ -36,23 +36,85 @@ export interface ExecuteResult { stats: ExecuteStats; } +export interface HostCallContext { + signal: AbortSignal; +} + +export interface HostCapability { + call(this: HostCallContext, ...args: unknown[]): unknown | Promise; +} + +export interface CapabilityManifest { + namespaces: Record>; +} + +export interface DataExecutor { + executeData( + code: string, + input: Record, + ): Promise; + + /** Clean up resources. */ + dispose?(): void; +} + +export interface CapabilityExecutor extends DataExecutor { + executeWithCapabilities( + code: string, + input: Record, + capabilities: CapabilityManifest, + ): Promise; +} + +export function isCapabilityExecutor( + executor: DataExecutor, +): executor is DataExecutor & CapabilityExecutor { + return ( + "executeWithCapabilities" in executor && + typeof executor.executeWithCapabilities === "function" + ); +} + +export function emptyExecuteStats(options: { + memoryMB?: number; + wallTimeMs?: number; +} = {}): ExecuteStats { + const wallTimeMs = options.wallTimeMs ?? 0; + const heapSizeLimitBytes = (options.memoryMB ?? 0) * 1024 * 1024; + return { + cpuTimeMs: wallTimeMs, + wallTimeMs, + heapUsedBytes: 0, + heapTotalBytes: 0, + externalBytes: 0, + heapSizeLimitBytes, + totalPhysicalBytes: 0, + availableBytes: heapSizeLimitBytes, + executableBytes: 0, + mallocedBytes: 0, + peakMallocedBytes: 0, + }; +} + /** * Sandbox executor interface. Implement this to use a custom sandbox runtime. * * Built-in implementations: * - `LlrtNativeExecutor` (requires `@robinbraemer/llrt` peer dependency) - * - `IsolatedVMExecutor` (requires `isolated-vm` peer dependency) - * - `QuickJSExecutor` (requires `quickjs-emscripten` peer dependency) + * - `IsolatedVMExecutor` (requires `isolated-vm` peer dependency; data-only, + * rejects host function globals) + * - `QuickJSExecutor` (requires `quickjs-emscripten` peer dependency; data-only, + * rejects host function globals) */ -export interface Executor { +export interface Executor extends DataExecutor { /** - * Execute JavaScript code in a sandboxed environment. + * Legacy execution entrypoint. Prefer `executeData()` for JSON-only code and + * `CapabilityExecutor.executeWithCapabilities()` for host-capable code. * * @param code - An async arrow function as a string, e.g. `async () => { ... }` - * @param globals - Named globals to inject into the sandbox. Each value is either: - * - A plain object/array/primitive (injected as a frozen read-only value) - * - A function (injected as a callable host function) - * - An object with function values (injected as a namespace with callable methods) + * @param globals - Named JSON-compatible globals to inject into the sandbox. + * Only LLRT's legacy path accepts function values; data-only executors reject + * them. */ execute( code: string, @@ -73,6 +135,14 @@ export interface SandboxOptions { timeoutMs?: number; /** Wall-clock timeout in ms — caps total elapsed time including async I/O (default: 60000) */ wallTimeMs?: number; + /** Maximum number of host calls per execution (default: 100). */ + maxHostCalls?: number; + /** Maximum JSON-encoded host-call arguments in bytes (default: 1MB). */ + maxHostPayloadBytes?: number; + /** Maximum JSON-encoded host-call result in bytes (default: 10MB). */ + maxHostResultBytes?: number; + /** Maximum JSON-encoded final execution result in bytes (default: 10MB). */ + maxResultBytes?: number; } /** @@ -152,12 +222,24 @@ export interface CodeModeOptions { */ maxResponseTokens?: number; + /** + * Maximum JavaScript source size in bytes before sandbox compilation. + * Default: 256KB. + */ + maxCodeBytes?: number; + /** * Maximum number of requests per execution. * Default: 50. */ maxRequests?: number; + /** + * Maximum number of in-flight requests per execution. + * Default: 8. + */ + maxConcurrentRequests?: number; + /** * Maximum response body size in bytes. * Default: 10MB (10_485_760). diff --git a/packages/codemode/src/types/llrt-peer.d.ts b/packages/codemode/src/types/llrt-peer.d.ts index 6f89e88..12e6d29 100644 --- a/packages/codemode/src/types/llrt-peer.d.ts +++ b/packages/codemode/src/types/llrt-peer.d.ts @@ -4,6 +4,10 @@ declare module "@robinbraemer/llrt" { wallTimeMs?: number; cpuTimeMs?: number; maxStackBytes?: number; + maxHostCalls?: number; + maxHostPayloadBytes?: number; + maxHostResultBytes?: number; + maxResultBytes?: number; } interface LlrtCallOptions { @@ -11,10 +15,25 @@ declare module "@robinbraemer/llrt" { wallTimeMs?: number; cpuTimeMs?: number; maxStackBytes?: number; + maxHostCalls?: number; + maxHostPayloadBytes?: number; + maxHostResultBytes?: number; + maxResultBytes?: number; functions?: Record; } - type LlrtHostFunction = (...args: unknown[]) => unknown | Promise; + interface LlrtHostCallContext { + signal: AbortSignal; + } + + type LlrtHostFunction = ( + this: LlrtHostCallContext, + ...args: unknown[] + ) => unknown | Promise; + + interface LlrtHostManifest { + namespaces: Record>; + } interface LlrtStats { wallTimeMs: number; @@ -40,6 +59,13 @@ declare module "@robinbraemer/llrt" { options?: LlrtCallOptions, ): Promise>; + callJsonWithHost( + source: string, + input: TInput, + manifest: LlrtHostManifest, + options?: Omit, + ): Promise>; + dispose(): void; } diff --git a/packages/codemode/test/auto-executor.test.ts b/packages/codemode/test/auto-executor.test.ts index 54901aa..920475a 100644 --- a/packages/codemode/test/auto-executor.test.ts +++ b/packages/codemode/test/auto-executor.test.ts @@ -1,9 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { LlrtNativeExecutor } from "../src/executor/llrt-native.js"; -import { isMissingOptionalDependency } from "../src/executor/auto.js"; +import { + autoExecutorBackendOrder, + isMissingOptionalDependency, +} from "../src/executor/auto.js"; afterEach(() => { vi.doUnmock("@robinbraemer/llrt"); + vi.doUnmock("isolated-vm"); + vi.doUnmock("quickjs-emscripten"); vi.resetModules(); }); @@ -34,4 +39,18 @@ describe("createExecutor", () => { isMissingOptionalDependency(new Error("broken llrt package"), "@robinbraemer/llrt"), ).toBe(false); }); + + it("does not classify installed-but-broken fallback runtimes as safely missing", () => { + const brokenNativeBinding = Object.assign( + new Error("isolated-vm native binding is broken"), + { code: "ERR_DLOPEN_FAILED" }, + ); + + expect(isMissingOptionalDependency(brokenNativeBinding, "isolated-vm")).toBe(false); + }); + + it("does not include QuickJS in the automatic backend order", () => { + expect(autoExecutorBackendOrder()).toContain("llrt"); + expect(autoExecutorBackendOrder()).not.toContain("quickjs"); + }); }); diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 062ade2..e03be25 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -1,12 +1,54 @@ import { describe, it, expect, beforeEach } from "vitest"; import { CodeMode } from "../src/codemode.js"; -import type { Executor, ExecuteResult } from "../src/types.js"; +import type { + CapabilityManifest, + Executor, + ExecuteResult, +} from "../src/types.js"; // A simple in-memory executor for testing (no sandbox dependency needed) class TestExecutor implements Executor { + calls = 0; + dataCalls = 0; + capabilityCalls = 0; + async execute( code: string, globals: Record, + ): Promise { + this.calls += 1; + return await this.runCode(code, globals); + } + + async executeData( + code: string, + input: Record, + ): Promise { + this.dataCalls += 1; + return await this.runCode(code, input); + } + + async executeWithCapabilities( + code: string, + input: Record, + capabilities: CapabilityManifest, + ): Promise { + this.capabilityCalls += 1; + const globals: Record = { ...input }; + for (const [namespace, methods] of Object.entries(capabilities.namespaces)) { + const namespaceValue: Record = {}; + for (const [method, capability] of Object.entries(methods)) { + namespaceValue[method] = (...args: unknown[]) => + capability.call.apply({ signal: new AbortController().signal }, args); + } + globals[namespace] = namespaceValue; + } + return await this.runCode(code, globals); + } + + private async runCode( + code: string, + globals: Record, ): Promise { // Create a minimal sandbox using Function constructor // (NOT safe for production - only for testing) @@ -192,6 +234,43 @@ describe("CodeMode", () => { expect(result.isError).toBe(true); }); + it("rejects oversized code before invoking the executor", async () => { + const executor = new TestExecutor(); + const cm = new CodeMode({ + spec: testSpec, + request: testHandler, + executor, + maxCodeBytes: 12, + }); + + const result = await cm.search(`async () => "this is too large"`); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("Code too large"); + expect(executor.calls).toBe(0); + }); + + it("runs search in data-only mode", async () => { + const executor = new TestExecutor(); + const cm = new CodeMode({ + spec: testSpec, + request: testHandler, + executor, + }); + + const result = await cm.search(` + async () => ({ pathCount: Object.keys(spec.paths).length, request: typeof api }) + `); + + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0]!.text)).toEqual({ + pathCount: 3, + request: "undefined", + }); + expect(executor.dataCalls).toBe(1); + expect(executor.calls).toBe(0); + }); + it("supports spec as async getter", async () => { const cm = new CodeMode({ spec: async () => testSpec, @@ -262,6 +341,15 @@ describe("CodeMode", () => { expect(data.status).toBe(404); }); + it("returns a controlled error for non-JSON-serializable results", async () => { + const result = await codemode.execute(` + async () => 1n + `); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("Result serialization failed"); + }); + it("respects custom namespace", async () => { const cm = new CodeMode({ spec: testSpec, diff --git a/packages/codemode/test/data-only.test.ts b/packages/codemode/test/data-only.test.ts new file mode 100644 index 0000000..5106a6e --- /dev/null +++ b/packages/codemode/test/data-only.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { findFunctionPath } from "../src/executor/data-only.js"; + +describe("data-only input guard", () => { + it("rejects accessor properties without invoking them", () => { + const input = {}; + Object.defineProperty(input, "danger", { + enumerable: true, + get() { + throw new Error("getter should not run"); + }, + }); + + expect(findFunctionPath(input)).toContain("accessor property is not data-only"); + }); + + it("checks only present entries in sparse arrays", () => { + const input: unknown[] = []; + input.length = 1_000_000; + input[999_999] = () => "blocked"; + + expect(findFunctionPath(input)).toBe("input[999999]"); + }); +}); diff --git a/packages/codemode/test/executor-contract.ts b/packages/codemode/test/executor-contract.ts index f9a7cf6..2102ea9 100644 --- a/packages/codemode/test/executor-contract.ts +++ b/packages/codemode/test/executor-contract.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import type { Executor, SandboxOptions } from "../src/types.js"; +import type { CapabilityExecutor, Executor, SandboxOptions } from "../src/types.js"; import { CodeMode } from "../src/codemode.js"; /** @@ -10,6 +10,7 @@ import { CodeMode } from "../src/codemode.js"; * deliberately to assert no leakage. */ export type ExecutorFactory = (opts?: SandboxOptions) => Executor; +export type CapabilityExecutorFactory = (opts?: SandboxOptions) => CapabilityExecutor; /** * Optional per-backend knobs for tests whose absolute thresholds depend on @@ -27,6 +28,8 @@ export interface ExecutorContractOptions { /** Outer loop iterations for the allocation stress. */ iterations: number; }; + /** Whether this backend supports host function callbacks. Default: true. */ + supportsHostFunctions?: boolean; } const DEFAULT_MEMORY_STRESS = { memoryMB: 8, iterations: 10_000_000 } as const; @@ -47,6 +50,7 @@ export function executorContract( options: ExecutorContractOptions = {}, ): void { const memoryStress = options.memoryStress ?? DEFAULT_MEMORY_STRESS; + const supportsHostFunctions = options.supportsHostFunctions ?? true; describe(name, () => { it("executes simple code", async () => { @@ -74,29 +78,76 @@ export function executorContract( expect(result.result).toBe("My API"); }); - it("injects async host functions in a namespace", async () => { + it("rejects function values in data-only input", async () => { const executor = factory(); - const result = await executor.execute( - `async () => { - const res = await api.request({ method: "GET", path: "/test" }); - return res; - }`, + const result = await executor.executeData( + `async () => typeof api.request`, { api: { - request: async (opts: any) => ({ - status: 200, - body: { message: "hello from " + opts.path }, - }), + request: async () => ({ status: 200 }), }, }, ); - expect(result.error).toBeUndefined(); - expect(result.result).toEqual({ - status: 200, - body: { message: "hello from /test" }, - }); + + expect(result.result).toBeUndefined(); + expect(result.error).toContain("data-only"); + }); + + it("rejects function values in cyclic data-only input", async () => { + const executor = factory(); + const input: Record = {}; + input.self = input; + input.api = { + request: async () => ({ status: 200 }), + }; + + const result = await executor.executeData( + `async () => typeof api.request`, + input, + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toContain("input.api.request"); }); + if (supportsHostFunctions) { + it("injects async host functions in a namespace", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { + const res = await api.request({ method: "GET", path: "/test" }); + return res; + }`, + { + api: { + request: async (opts: any) => ({ + status: 200, + body: { message: "hello from " + opts.path }, + }), + }, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + status: 200, + body: { message: "hello from /test" }, + }); + }); + } else { + it("rejects host function globals", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => api.request({ method: "GET", path: "/test" })`, + { + api: { + request: async () => ({ status: 200 }), + }, + }, + ); + expect(result.error).toContain("does not support host functions"); + }); + } + it("console.log is a no-op (does not crash)", async () => { const executor = factory(); const result = await executor.execute( @@ -154,20 +205,22 @@ export function executorContract( expect(result.error).toBeDefined(); }); - it("enforces wall-clock timeout on stalled async host calls", async () => { - const executor = factory({ timeoutMs: 5_000, wallTimeMs: 200 }); - const result = await executor.execute( - `async () => { - // Call a host function that never resolves — wall-clock timeout should fire - return await hang(); - }`, - { - hang: () => new Promise(() => {}), // never resolves - }, - ); - expect(result.error).toBeDefined(); - expect(result.error).toContain("Wall-clock timeout"); - }); + if (supportsHostFunctions) { + it("enforces wall-clock timeout on stalled async host calls", async () => { + const executor = factory({ timeoutMs: 5_000, wallTimeMs: 200 }); + const result = await executor.execute( + `async () => { + // Call a host function that never resolves — wall-clock timeout should fire + return await hang(); + }`, + { + hang: () => new Promise(() => {}), // never resolves + }, + ); + expect(result.error).toBeDefined(); + expect(result.error).toContain("Wall-clock timeout"); + }); + } it("isolates executions (no state leakage)", async () => { const executor = factory(); @@ -235,44 +288,150 @@ export function executorContract( expect(result.result).toEqual({ blocked: true }); }); - it("chains multiple async host calls", async () => { - const executor = factory(); + if (supportsHostFunctions) { + it("chains multiple async host calls", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { + const a = await add(1, 2); + const b = await add(a, 3); + return b; + }`, + { + add: async (a: number, b: number) => a + b, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.result).toBe(6); + }); + + it("handles concurrent async calls via Promise.all", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { + const results = await Promise.all([ + api.request({ path: "/a" }), + api.request({ path: "/b" }), + api.request({ path: "/c" }), + ]); + return results.map(r => r.body.path); + }`, + { + api: { + request: async (opts: any) => ({ + status: 200, + body: { path: opts.path }, + }), + }, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual(["/a", "/b", "/c"]); + }); + + it("rejects oversized host call payloads before invoking the host function", async () => { + const executor = factory({ maxHostPayloadBytes: 64 }); + let called = false; + + const result = await executor.execute( + `async () => api.request({ body: "é".repeat(40) })`, + { + api: { + request: async () => { + called = true; + return { status: 200 }; + }, + }, + }, + ); + + expect(result.error).toMatch(/payload|arguments/i); + expect(called).toBe(false); + }); + + it("rejects oversized host call results", async () => { + const executor = factory({ maxHostResultBytes: 64 }); + + const result = await executor.execute( + `async () => api.request({ path: "/large" })`, + { + api: { + request: async () => ({ + status: 200, + body: "x".repeat(128), + }), + }, + }, + ); + + expect(result.error).toContain("result"); + }); + } + + it("rejects oversized final execution results before returning to the host", async () => { + const executor = factory({ maxResultBytes: 64 }); + + const result = await executor.execute( + `async () => ({ body: "x".repeat(128) })`, + {}, + ); + + expect(result.error).toMatch(/execution result exceeds limit/i); + }); + + it("counts final execution result limits in UTF-8 bytes", async () => { + const executor = factory({ maxResultBytes: 100 }); + + const result = await executor.execute( + `async () => "é".repeat(64)`, + {}, + ); + + expect(result.error).toMatch(/execution result exceeds limit/i); + }); + + it("does not let guest code tamper with final result byte accounting helpers", async () => { + const executor = factory({ maxResultBytes: 64 }); + const result = await executor.execute( `async () => { - const a = await add(1, 2); - const b = await add(a, 3); - return b; + globalThis.__codemodeUtf8ByteLength = () => 0; + return { body: "x".repeat(128) }; }`, - { - add: async (a: number, b: number) => a + b, - }, + {}, ); - expect(result.error).toBeUndefined(); - expect(result.result).toBe(6); + + expect(result.error).toMatch(/execution result exceeds limit/i); }); - it("handles concurrent async calls via Promise.all", async () => { - const executor = factory(); + it("does not let guest code tamper with JSON.stringify result serialization", async () => { + const executor = factory({ maxResultBytes: 64 }); + const result = await executor.execute( `async () => { - const results = await Promise.all([ - api.request({ path: "/a" }), - api.request({ path: "/b" }), - api.request({ path: "/c" }), - ]); - return results.map(r => r.body.path); + JSON.stringify = (value) => value; + return { body: "x".repeat(128) }; }`, - { - api: { - request: async (opts: any) => ({ - status: 200, - body: { path: opts.path }, - }), - }, - }, + {}, + ); + + expect(result.error).toMatch( + /serialization returned a non-string value|execution result exceeds limit/i, ); - expect(result.error).toBeUndefined(); - expect(result.result).toEqual(["/a", "/b", "/c"]); + }); + + it("does not let guest code forge a small JSON.stringify result", async () => { + const executor = factory({ maxResultBytes: 64 }); + + const result = await executor.execute( + `async () => { + JSON.stringify = () => "\\"small\\""; + return { body: "x".repeat(128) }; + }`, + {}, + ); + + expect(result.error).toMatch(/execution result exceeds limit/i); }); }); @@ -355,6 +514,20 @@ export function executorContract( summary: "List clusters", }); + if (!supportsHostFunctions) { + const execResult = await codemode.execute(` + async () => { + const res = await cnap.request({ method: "GET", path: "/v1/clusters" }); + return res.body; + } + `); + + expect(execResult.isError).toBe(true); + expect(execResult.content[0]?.text).toContain("does not support host functions"); + codemode.dispose(); + return; + } + // Execute: list clusters const execResult = await codemode.execute(` async () => { @@ -431,3 +604,64 @@ export function executorContract( }); }); } + +export function capabilityExecutorContract( + name: string, + factory: CapabilityExecutorFactory, +): void { + describe(`${name} capability execution`, () => { + it("exposes only manifest-declared namespace capabilities", async () => { + const executor = factory(); + const result = await executor.executeWithCapabilities( + `async () => { + const response = await api.request({ path: "/test" }); + return { + status: response.status, + secret: typeof api.secret, + topLevel: typeof request, + }; + }`, + {}, + { + namespaces: { + api: { + request: { + call: async (request: { path: string }) => ({ + status: 200, + body: { path: request.path }, + }), + }, + }, + }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + status: 200, + secret: "undefined", + topLevel: "undefined", + }); + }); + + it("rejects data input that collides with capability namespaces", async () => { + const executor = factory(); + const result = await executor.executeWithCapabilities( + `async () => api.secret`, + { api: { secret: "leaked" } }, + { + namespaces: { + api: { + request: { + call: async () => ({ status: 200 }), + }, + }, + }, + }, + ); + + expect(result.result).toBeUndefined(); + expect(result.error).toContain("collides with capability namespace"); + }); + }); +} diff --git a/packages/codemode/test/isolated-vm-executor.test.ts b/packages/codemode/test/isolated-vm-executor.test.ts index 081c069..d4e7290 100644 --- a/packages/codemode/test/isolated-vm-executor.test.ts +++ b/packages/codemode/test/isolated-vm-executor.test.ts @@ -1,7 +1,31 @@ +import { expect, it } from "vitest"; import { IsolatedVMExecutor } from "../src/executor/isolated-vm.js"; import { executorContract } from "./executor-contract.js"; executorContract( "IsolatedVMExecutor", (opts) => new IsolatedVMExecutor(opts), + { supportsHostFunctions: false }, ); + +it("fails closed when host functions are provided", async () => { + const executor = new IsolatedVMExecutor({ memoryMB: 8, wallTimeMs: 20 }); + + const result = await executor.execute( + `async () => { + await api.request({ path: "/slow" }); + }`, + { + api: { + request: async function ( + this: { signal?: AbortSignal }, + _request: { path: string }, + ) { + return { status: 499, body: { aborted: true } }; + }, + }, + }, + ); + + expect(result.error).toContain("does not support host functions"); +}); diff --git a/packages/codemode/test/llrt-native-executor.test.ts b/packages/codemode/test/llrt-native-executor.test.ts index 8bd49e7..8120bbf 100644 --- a/packages/codemode/test/llrt-native-executor.test.ts +++ b/packages/codemode/test/llrt-native-executor.test.ts @@ -1,18 +1,24 @@ import { expect, it } from "vitest"; import { LlrtNativeExecutor } from "../src/executor/llrt-native.js"; -import { executorContract } from "./executor-contract.js"; +import { capabilityExecutorContract, executorContract } from "./executor-contract.js"; import { describeWithLlrtNativeBinding as describe, llrtNativeBindingAvailable, } from "./llrt-native-test-helper.js"; import type { LlrtHostCallContext } from "@robinbraemer/llrt"; +const LLRT_HOST_BRIDGE_GLOBAL = "__llrtHostCall"; + if (llrtNativeBindingAvailable) { executorContract( "LlrtNativeExecutor", (opts) => new LlrtNativeExecutor(opts), { memoryStress: { memoryMB: 1, iterations: 100_000 } }, ); + capabilityExecutorContract( + "LlrtNativeExecutor", + (opts) => new LlrtNativeExecutor(opts), + ); } else { describe.skip("LlrtNativeExecutor", () => { it("requires a built LLRT native binding", () => {}); @@ -33,6 +39,45 @@ describe("LlrtNativeExecutor", () => { expect(result.stats.heapSizeLimitBytes).toBe(8 * 1024 * 1024); }); + it("does not expose the host bridge for data-only globals", async () => { + const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 1000 }); + + const result = await executor.execute( + `async () => typeof globalThis.${LLRT_HOST_BRIDGE_GLOBAL}`, + { spec: { info: { title: "Petstore" } } }, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toBe("undefined"); + }); + + it("does not expose the LLRT raw bridge during capability execution", async () => { + const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 1000 }); + + const result = await executor.executeWithCapabilities( + `async () => ({ + response: await api.request({ path: "/pets" }), + raw: typeof globalThis.${LLRT_HOST_BRIDGE_GLOBAL}, + })`, + {}, + { + namespaces: { + api: { + request: { + call: async () => ({ status: 200 }), + }, + }, + }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + response: { status: 200 }, + raw: "undefined", + }); + }); + it("returns runtime errors as ExecuteResult errors", async () => { const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 1000 }); @@ -67,6 +112,28 @@ describe("LlrtNativeExecutor", () => { expect(result.result).toEqual({ title: "Petstore", path: "/v1/pets" }); }); + it("does not expose the LLRT raw bridge during legacy host function execution", async () => { + const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 1000 }); + + const result = await executor.execute( + `async () => ({ + response: await api.request({ path: "/v1/pets" }), + raw: typeof globalThis.${LLRT_HOST_BRIDGE_GLOBAL}, + })`, + { + api: { + request: async () => ({ status: 200 }), + }, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + response: { status: 200 }, + raw: "undefined", + }); + }); + it("does not expose host call context as a guest argument", async () => { const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 1000 }); diff --git a/packages/codemode/test/llrt-process-executor.test.ts b/packages/codemode/test/llrt-process-executor.test.ts index 9e6e7e7..30e582e 100644 --- a/packages/codemode/test/llrt-process-executor.test.ts +++ b/packages/codemode/test/llrt-process-executor.test.ts @@ -57,6 +57,18 @@ describe("LlrtProcessExecutor", () => { expect(result.error).toContain("does not support globals"); }); + it("executes data-only code with JSON globals", async () => { + const executor = new LlrtProcessExecutor({ binaryPath: await createFakeLlrtBinary() }); + + const result = await executor.executeData( + `async () => spec.info.title`, + { spec: { info: { title: "API" } } }, + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toBe("API"); + }); + it("enforces wall-clock timeout for a stuck process", async () => { const executor = new LlrtProcessExecutor({ binaryPath: await createFakeLlrtBinary(), diff --git a/packages/codemode/test/package-publication.test.ts b/packages/codemode/test/package-publication.test.ts index 85c91a8..f7c59f5 100644 --- a/packages/codemode/test/package-publication.test.ts +++ b/packages/codemode/test/package-publication.test.ts @@ -10,7 +10,7 @@ const publishWorkflowPath = join(root, ".github/workflows/publish.yml"); describe("codemode package publication", () => { it("publishes the LLRT executor release with a compatible optional peer range", () => { expect(codemodePackageJson.version).toMatch(/^(?!0\.2\.0$)\d+\.\d+\.\d+(?:[-+].*)?$/); - expect(codemodePackageJson.peerDependencies["@robinbraemer/llrt"]).toBe("^0.1.2"); + expect(codemodePackageJson.peerDependencies["@robinbraemer/llrt"]).toBe("^0.2.0"); expect(codemodePackageJson.devDependencies["@robinbraemer/llrt"]).toBe("workspace:*"); }); diff --git a/packages/codemode/test/petstore-limit.test.ts b/packages/codemode/test/petstore-limit.test.ts index 4b78bb7..06d6c22 100644 --- a/packages/codemode/test/petstore-limit.test.ts +++ b/packages/codemode/test/petstore-limit.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, afterEach } from "vitest"; import { CodeMode } from "../src/codemode.js"; -import type { Executor, ExecuteResult } from "../src/types.js"; +import type { + CapabilityManifest, + Executor, + ExecuteResult, +} from "../src/types.js"; const PETSTORE_BASE = "https://petstore3.swagger.io"; @@ -24,6 +28,37 @@ class TestExecutor implements Executor { async execute( code: string, globals: Record, + ): Promise { + return await this.runCode(code, globals); + } + + async executeData( + code: string, + input: Record, + ): Promise { + return await this.runCode(code, input); + } + + async executeWithCapabilities( + code: string, + input: Record, + capabilities: CapabilityManifest, + ): Promise { + const globals: Record = { ...input }; + for (const [namespace, methods] of Object.entries(capabilities.namespaces)) { + const namespaceValue: Record = {}; + for (const [method, capability] of Object.entries(methods)) { + namespaceValue[method] = (...args: unknown[]) => + capability.call.apply({ signal: new AbortController().signal }, args); + } + globals[namespace] = namespaceValue; + } + return await this.runCode(code, globals); + } + + private async runCode( + code: string, + globals: Record, ): Promise { const globalNames = Object.keys(globals); const globalValues = Object.values(globals); diff --git a/packages/codemode/test/quickjs-executor.test.ts b/packages/codemode/test/quickjs-executor.test.ts index 14c0523..84d5235 100644 --- a/packages/codemode/test/quickjs-executor.test.ts +++ b/packages/codemode/test/quickjs-executor.test.ts @@ -6,9 +6,34 @@ executorContract( "QuickJSExecutor", (opts) => new QuickJSExecutor(opts), // quickjs OOMs at a lower limit with a tighter loop than V8. - { memoryStress: { memoryMB: 4, iterations: 1_000_000 } }, + { + memoryStress: { memoryMB: 4, iterations: 1_000_000 }, + supportsHostFunctions: false, + }, ); +it("fails closed when host functions are provided", async () => { + const executor = new QuickJSExecutor({ memoryMB: 8, wallTimeMs: 20 }); + + const result = await executor.execute( + `async () => { + await api.request({ path: "/slow" }); + }`, + { + api: { + request: async function ( + this: { signal?: AbortSignal }, + _request: { path: string }, + ) { + return { status: 499, body: { aborted: true } }; + }, + }, + }, + ); + + expect(result.error).toContain("does not support host functions"); +}); + // ─── Cross-backend tests ──────────────────────────────────────────────────── // These compare BOTH backends side-by-side and therefore cannot live in the // backend-agnostic contract. They stay here in the quickjs file because that's @@ -16,7 +41,8 @@ executorContract( describe("ExecuteStats shape parity (QuickJS vs IsolatedVM)", () => { it("both executors produce ExecuteStats with the same keys", async () => { - const { IsolatedVMExecutor } = await import("../src/executor/isolated-vm.js"); + const IsolatedVMExecutor = await loadIsolatedVMExecutorOrSkip(); + if (!IsolatedVMExecutor) return; const code = `async () => { let s = 0; for (let i = 0; i < 100; i++) s += i; return s; }`; const ivmExec = new IsolatedVMExecutor(); @@ -45,16 +71,9 @@ describe("ExecuteStats shape parity (QuickJS vs IsolatedVM)", () => { } }); - it("documents the return-value semantic divergence (structured clone vs JSON)", async () => { - // isolated-vm uses structured clone (`{ copy: true }`) — preserves Date, - // Map, Set, BigInt as their original types. QuickJSExecutor uses a - // JSON.stringify envelope as a workaround for upstream GC-anchoring bugs - // in quickjs-emscripten@0.32.0 release-asyncify, so those types degrade - // to their JSON representation. - // - // This test locks the divergence so any future change (e.g. an upstream - // fix that lets us drop the JSON envelope) breaks loudly. - const { IsolatedVMExecutor } = await import("../src/executor/isolated-vm.js"); + it("returns JSON-safe final values from both backends", async () => { + const IsolatedVMExecutor = await loadIsolatedVMExecutorOrSkip(); + if (!IsolatedVMExecutor) return; const code = `async () => new Date("2026-01-15T00:00:00Z")`; const ivmExec = new IsolatedVMExecutor(); @@ -66,12 +85,20 @@ describe("ExecuteStats shape parity (QuickJS vs IsolatedVM)", () => { expect(ivmRes.error).toBeUndefined(); expect(qjsRes.error).toBeUndefined(); - // isolated-vm: structured clone preserves the Date instance. - expect(ivmRes.result).toBeInstanceOf(Date); - expect((ivmRes.result as Date).toISOString()).toBe("2026-01-15T00:00:00.000Z"); - - // QuickJS: JSON envelope returns the date as an ISO-8601 string. + expect(typeof ivmRes.result).toBe("string"); + expect(ivmRes.result).toBe("2026-01-15T00:00:00.000Z"); expect(typeof qjsRes.result).toBe("string"); expect(qjsRes.result).toBe("2026-01-15T00:00:00.000Z"); }); }); + +async function loadIsolatedVMExecutorOrSkip() { + try { + const { IsolatedVMExecutor } = await import("../src/executor/isolated-vm.js"); + const probe = new IsolatedVMExecutor({ memoryMB: 8, wallTimeMs: 1000 }); + await probe.execute(`async () => true`, {}); + return IsolatedVMExecutor; + } catch { + return undefined; + } +} diff --git a/packages/codemode/test/request-bridge.test.ts b/packages/codemode/test/request-bridge.test.ts index 55c45ae..5786201 100644 --- a/packages/codemode/test/request-bridge.test.ts +++ b/packages/codemode/test/request-bridge.test.ts @@ -136,6 +136,68 @@ describe("request limits", () => { }), ).rejects.toThrow("Request body too large"); }); + + it("enforces maxConcurrentRequests", async () => { + let releaseFirst: (() => void) | undefined; + const firstMayFinish = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markHandlerRunning: (() => void) | undefined; + const handlerIsRunning = new Promise((resolve) => { + markHandlerRunning = resolve; + }); + const handler: RequestHandler = async () => { + markHandlerRunning?.(); + await firstMayFinish; + return Response.json({ ok: true }); + }; + const bridge = createRequestBridge(handler, "http://localhost", { + maxConcurrentRequests: 1, + }); + + const first = bridge({ method: "GET", path: "/slow" }); + await handlerIsRunning; + + await expect( + bridge({ method: "GET", path: "/second" }), + ).rejects.toThrow("Concurrent request limit exceeded"); + + releaseFirst?.(); + await first; + }); + + it("forwards abort signals and releases the in-flight slot after abort", async () => { + let sawSignal = false; + const handler: RequestHandler = async (input, init) => { + const url = typeof input === "string" ? new URL(input) : new URL(input.url); + if (url.pathname === "/next") { + return Response.json({ ok: true }); + } + sawSignal = init?.signal instanceof AbortSignal; + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }); + return Response.json({ ok: true }); + }; + const bridge = createRequestBridge(handler, "http://localhost", { + maxConcurrentRequests: 1, + }); + const abortController = new AbortController(); + + const first = bridge( + { method: "GET", path: "/slow" }, + { signal: abortController.signal }, + ); + abortController.abort(); + + await expect(first).rejects.toThrow(/aborted|Request aborted/); + expect(sawSignal).toBe(true); + await expect(bridge({ method: "GET", path: "/next" })).resolves.toMatchObject({ + status: 200, + }); + }); }); describe("header filtering", () => { diff --git a/packages/llrt/native/Cargo.toml b/packages/llrt/native/Cargo.toml index a15e60c..6d49e79 100644 --- a/packages/llrt/native/Cargo.toml +++ b/packages/llrt/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "llrt_node" -version = "0.1.2" +version = "0.2.0" edition = "2021" license = "MIT" diff --git a/packages/llrt/native/src/runtime.rs b/packages/llrt/native/src/runtime.rs index 57e1cf2..54307f1 100644 --- a/packages/llrt/native/src/runtime.rs +++ b/packages/llrt/native/src/runtime.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -16,6 +17,7 @@ use napi::{ threadsafe_function::ThreadsafeFunction, Status, }; use napi_derive::napi; +use serde::{Deserialize, Serialize}; use rquickjs::{ atom::PredefinedAtom, function::This, @@ -25,6 +27,9 @@ use rquickjs::{ }; type HostDispatcher = ThreadsafeFunction, String, Status, false>; +const HOST_ERROR_PREFIX: &str = "__LLRT_HOST_ERROR__"; +const DEFAULT_MAX_HOST_PAYLOAD_BYTES: usize = 1024 * 1024; +const DEFAULT_MAX_RESULT_BYTES: usize = 10 * 1024 * 1024; #[napi(object)] pub struct NativeStats { @@ -41,9 +46,14 @@ pub struct NativeRuntimeOptions { pub wall_time_ms: Option, pub cpu_time_ms: Option, pub max_stack_bytes: Option, + pub max_host_payload_bytes: Option, + pub max_result_bytes: Option, + pub error_marker: Option, + pub host_paths: Option>, } #[napi(object)] +#[derive(Clone, Deserialize, Serialize)] pub struct NativeErrorInfo { pub name: String, pub message: String, @@ -88,14 +98,57 @@ async fn call_json_inner( host_dispatcher: Option>, ) -> Result { let start = Instant::now(); - let max_stack_bytes = options - .max_stack_bytes - .map(|value| value as usize) - .unwrap_or_else(|| VmOptions::default().max_stack_size); - let memory_limit_bytes = options - .memory_mb - .map(|value| (value * 1024.0 * 1024.0) as usize) - .unwrap_or(64 * 1024 * 1024); + if let Some(cpu_time_ms) = options.cpu_time_ms { + if cpu_time_ms.is_finite() && cpu_time_ms > 0.0 { + return Ok(option_error( + start, + "cpu_time_ms is not enforced by LLRT native bindings; use wall_time_ms", + )); + } + } + let max_stack_bytes = match finite_positive_usize( + options.max_stack_bytes, + "max_stack_bytes", + VmOptions::default().max_stack_size, + 1.0, + start, + )? { + Ok(value) => value, + Err(result) => return Ok(result), + }; + let memory_limit_bytes = match finite_positive_usize( + options.memory_mb, + "memory_mb", + 64 * 1024 * 1024, + 1024.0 * 1024.0, + start, + )? { + Ok(value) => value, + Err(result) => return Ok(result), + }; + let max_host_payload_bytes = match finite_positive_usize( + options.max_host_payload_bytes, + "max_host_payload_bytes", + DEFAULT_MAX_HOST_PAYLOAD_BYTES, + 1.0, + start, + )? { + Ok(value) => value, + Err(result) => return Ok(result), + }; + let max_result_bytes = match finite_positive_usize( + options.max_result_bytes, + "max_result_bytes", + DEFAULT_MAX_RESULT_BYTES, + 1.0, + start, + )? { + Ok(value) => value, + Err(result) => return Ok(result), + }; + let error_marker = options + .error_marker + .unwrap_or_else(|| format!("{HOST_ERROR_PREFIX}{:p}:", &start)); let vm = Vm::from_options(VmOptions { module_builder: ModuleBuilder::new(), @@ -107,10 +160,21 @@ async fn call_json_inner( .map_err(|error| Error::from_reason(error.to_string()))?; vm.runtime.set_memory_limit(memory_limit_bytes).await; - let wall_timeout = wall_time_duration(options.wall_time_ms); + let wall_timeout = match wall_time_duration(options.wall_time_ms, start) { + Ok(value) => value, + Err(result) => return Ok(result), + }; let timeout_flag = configure_wall_time_limit(&vm, wall_timeout).await; let result = execute_with_wall_timeout( - execute_function(&vm, source, input_json, host_dispatcher), + execute_function( + &vm, + source, + input_json, + host_dispatcher, + options.host_paths.unwrap_or_default(), + max_host_payload_bytes, + error_marker.clone(), + ), wall_timeout, ) .await; @@ -156,6 +220,18 @@ async fn call_json_inner( }); } }; + if value_json.len() > max_result_bytes { + return Ok(NativeCallResult { + ok: false, + value_json: None, + error: Some(limit_error( + "RESULT_LIMIT", + "LlrtResultLimitError", + &format!("LLRT execution result exceeds limit of {max_result_bytes} bytes"), + )), + stats, + }); + } Ok(NativeCallResult { ok: true, @@ -165,12 +241,66 @@ async fn call_json_inner( }) } -fn wall_time_duration(wall_time_ms: Option) -> Option { - let wall_time_ms = wall_time_ms?; - if !wall_time_ms.is_finite() || wall_time_ms < 0.0 { - return None; +fn wall_time_duration( + wall_time_ms: Option, + start: Instant, +) -> std::result::Result, NativeCallResult> { + let Some(wall_time_ms) = wall_time_ms else { + return Ok(None); + }; + if !wall_time_ms.is_finite() || wall_time_ms <= 0.0 { + return Err(option_error( + start, + "wall_time_ms must be a finite positive number", + )); + } + Ok(Some(Duration::from_secs_f64(wall_time_ms / 1000.0))) +} + +fn finite_positive_usize( + value: Option, + name: &str, + default_value: usize, + multiplier: f64, + start: Instant, +) -> Result> { + let Some(value) = value else { + return Ok(Ok(default_value)); + }; + if !value.is_finite() || value <= 0.0 { + return Ok(Err(option_error( + start, + &format!("{name} must be a finite positive number"), + ))); + } + let scaled = value * multiplier; + if !scaled.is_finite() || scaled > usize::MAX as f64 { + return Ok(Err(option_error( + start, + &format!("{name} exceeds supported limit"), + ))); + } + Ok(Ok(scaled as usize)) +} + +fn option_error(start: Instant, message: &str) -> NativeCallResult { + NativeCallResult { + ok: false, + value_json: None, + error: Some(NativeErrorInfo { + code: "UNSUPPORTED".to_string(), + name: "LlrtUnsupportedOptionError".to_string(), + message: message.to_string(), + stack: None, + }), + stats: NativeStats { + wall_time_ms: start.elapsed().as_secs_f64() * 1000.0, + cpu_time_ms: None, + memory_used_bytes: None, + memory_limit_bytes: None, + max_stack_bytes: None, + }, } - Some(Duration::from_secs_f64(wall_time_ms / 1000.0)) } async fn configure_wall_time_limit( @@ -220,14 +350,52 @@ fn timeout_error() -> NativeErrorInfo { } } +fn limit_error(code: &str, name: &str, message: &str) -> NativeErrorInfo { + NativeErrorInfo { + code: code.to_string(), + name: name.to_string(), + message: message.to_string(), + stack: None, + } +} + +fn host_limit_error(code: &str, message: &str) -> NativeErrorInfo { + limit_error(code, "LlrtHostLimitError", message) +} + +fn host_error_to_quickjs(error: NativeErrorInfo, error_marker: &str) -> rquickjs::Error { + let marker = serde_json::to_string(&error).unwrap_or_else(|_| { + r#"{"code":"EVALUATION_ERROR","name":"Error","message":"Host error"}"#.to_string() + }); + rquickjs::Error::new_from_js_message( + "host function", + "JSON string", + format!("{error_marker}{marker}"), + ) +} + async fn execute_function( vm: &Vm, source: String, input_json: String, host_dispatcher: Option>, + host_paths: Vec, + max_host_payload_bytes: usize, + error_marker: String, ) -> std::result::Result { vm.ctx - .async_with(async |ctx| execute_in_context(ctx, source, input_json, host_dispatcher).await) + .async_with(async |ctx| { + execute_in_context( + ctx, + source, + input_json, + host_dispatcher, + host_paths, + max_host_payload_bytes, + error_marker, + ) + .await + }) .await } @@ -236,11 +404,22 @@ async fn execute_in_context<'js>( source: String, input_json: String, host_dispatcher: Option>, + host_paths: Vec, + max_host_payload_bytes: usize, + error_marker: String, ) -> std::result::Result { - execute_in_context_inner(ctx.clone(), source, input_json, host_dispatcher) + execute_in_context_inner( + ctx.clone(), + source, + input_json, + host_dispatcher, + host_paths, + max_host_payload_bytes, + error_marker.clone(), + ) .await .catch(&ctx) - .map_err(|error| native_error_from_caught(&ctx, error)) + .map_err(|error| native_error_from_caught(&ctx, error, &error_marker)) } async fn execute_in_context_inner<'js>( @@ -248,18 +427,38 @@ async fn execute_in_context_inner<'js>( source: String, input_json: String, host_dispatcher: Option>, + host_paths: Vec, + max_host_payload_bytes: usize, + error_marker: String, ) -> rquickjs::Result { - if let Some(host_dispatcher) = host_dispatcher { - let host_function = Func::from(Async(move |name: String, args_json: String| { - call_host_function(Arc::clone(&host_dispatcher), name, args_json) - })); - ctx.globals().set("__llrtHostCall", host_function)?; - } - let function: QuickFunction = ctx.eval(format!("({source})"))?; let input = json_parse(&ctx, input_json.into_bytes())?; let argument = Object::new(ctx.clone())?; argument.set("input", input)?; + if let Some(host_dispatcher) = host_dispatcher { + if host_paths.is_empty() { + let host_error_marker = error_marker.clone(); + let host_function = Func::from(Async(move |name: String, args_json: String| { + call_host_function( + Arc::clone(&host_dispatcher), + name, + args_json, + max_host_payload_bytes, + host_error_marker.clone(), + ) + })); + ctx.globals().set("__llrtHostCall", host_function)?; + } else { + let host = build_host_object( + &ctx, + host_dispatcher, + host_paths, + max_host_payload_bytes, + error_marker.clone(), + )?; + argument.set("host", host)?; + } + } let result = function.call::<_, Value>((This(ctx.globals()), argument))?; let promise_constructor: Value = ctx.globals().get(PredefinedAtom::Promise)?; @@ -273,18 +472,99 @@ async fn execute_in_context_inner<'js>( Ok(json_stringify(&ctx, result)?.unwrap_or_default()) } +fn build_host_object<'js>( + ctx: &Ctx<'js>, + dispatcher: Arc, + host_paths: Vec, + max_host_payload_bytes: usize, + error_marker: String, +) -> rquickjs::Result> { + let host = Object::new(ctx.clone())?; + let mut namespaces: BTreeMap> = BTreeMap::new(); + + for path in host_paths { + let Some((namespace, method)) = path.split_once('.') else { + return Err(rquickjs::Error::new_from_js_message( + "host path", + "namespace.method", + format!("Invalid LLRT host path: {path}"), + )); + }; + if !is_safe_host_segment(namespace) || !is_safe_host_segment(method) { + return Err(rquickjs::Error::new_from_js_message( + "host path", + "safe namespace.method", + format!("Invalid LLRT host path: {path}"), + )); + } + namespaces + .entry(namespace.to_string()) + .or_default() + .push((method.to_string(), path)); + } + + for (namespace, methods) in namespaces { + let namespace_object = Object::new(ctx.clone())?; + for (method, path) in methods { + let host_error_marker = error_marker.clone(); + let host_dispatcher = Arc::clone(&dispatcher); + let host_function = Func::from(Async(move |args_json: String| { + call_host_function( + Arc::clone(&host_dispatcher), + path.clone(), + args_json, + max_host_payload_bytes, + host_error_marker.clone(), + ) + })); + namespace_object.set(method, host_function)?; + } + host.set(namespace, namespace_object)?; + } + + Ok(host) +} + +fn is_safe_host_segment(segment: &str) -> bool { + if matches!(segment, "__proto__" | "prototype" | "constructor") { + return false; + } + let mut chars = segment.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first == '$' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|char| char == '_' || char == '$' || char.is_ascii_alphanumeric()) +} + async fn call_host_function( dispatcher: Arc, name: String, args_json: String, + max_host_payload_bytes: usize, + error_marker: String, ) -> rquickjs::Result { + if args_json.len() > max_host_payload_bytes { + return Err(host_error_to_quickjs(host_limit_error( + "HOST_PAYLOAD_LIMIT", + &format!("LLRT host call arguments exceed limit of {max_host_payload_bytes} bytes"), + ), &error_marker)); + } let payload_json = serde_json::json!({ "name": name, "argsJson": args_json, }) .to_string(); + if payload_json.len() > max_host_payload_bytes { + return Err(host_error_to_quickjs(host_limit_error( + "HOST_PAYLOAD_LIMIT", + &format!("LLRT host call payload exceeds limit of {max_host_payload_bytes} bytes"), + ), &error_marker)); + } - dispatcher + let result_json = dispatcher .call_async_catch(payload_json) .await .map_err(|error| { @@ -297,17 +577,30 @@ async fn call_host_function( .await .map_err(|error| { rquickjs::Error::new_from_js_message("host function", "JSON string", error.to_string()) - }) + })?; + + if let Some(error) = host_error_from_message(&result_json, &error_marker) { + return Err(host_error_to_quickjs(error, &error_marker)); + } + + Ok(result_json) } -fn native_error_from_caught<'js>(ctx: &Ctx<'js>, error: CaughtError<'js>) -> NativeErrorInfo { +fn native_error_from_caught<'js>( + ctx: &Ctx<'js>, + error: CaughtError<'js>, + error_marker: &str, +) -> NativeErrorInfo { match error { - CaughtError::Exception(exception) => NativeErrorInfo { - code: "EVALUATION_ERROR".to_string(), - name: exception_name(&exception).unwrap_or_else(|| "Error".to_string()), - message: exception.message().unwrap_or_default(), - stack: exception.stack(), - }, + CaughtError::Exception(exception) => { + let message = exception.message().unwrap_or_default(); + host_error_from_message(&message, error_marker).unwrap_or_else(|| NativeErrorInfo { + code: "EVALUATION_ERROR".to_string(), + name: exception_name(&exception).unwrap_or_else(|| "Error".to_string()), + message, + stack: exception.stack(), + }) + } CaughtError::Value(value) => NativeErrorInfo { code: "EVALUATION_ERROR".to_string(), name: value.type_name().to_string(), @@ -317,15 +610,24 @@ fn native_error_from_caught<'js>(ctx: &Ctx<'js>, error: CaughtError<'js>) -> Nat .unwrap_or_else(|| "Non-Error JavaScript exception".to_string()), stack: None, }, - CaughtError::Error(error) => NativeErrorInfo { - code: "EVALUATION_ERROR".to_string(), - name: "Error".to_string(), - message: error.to_string(), - stack: None, - }, + CaughtError::Error(error) => { + let message = error.to_string(); + host_error_from_message(&message, error_marker).unwrap_or_else(|| NativeErrorInfo { + code: "EVALUATION_ERROR".to_string(), + name: "Error".to_string(), + message, + stack: None, + }) + } } } +fn host_error_from_message(message: &str, error_marker: &str) -> Option { + let marker_start = message.find(error_marker)?; + let payload = &message[marker_start + error_marker.len()..]; + serde_json::from_str(payload).ok() +} + fn exception_name(exception: &rquickjs::Exception<'_>) -> Option { exception .as_object() diff --git a/packages/llrt/npm/darwin-arm64/package.json b/packages/llrt/npm/darwin-arm64/package.json index 7a8965b..ca1d75e 100644 --- a/packages/llrt/npm/darwin-arm64/package.json +++ b/packages/llrt/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-darwin-arm64", - "version": "0.1.2", + "version": "0.2.0", "cpu": [ "arm64" ], diff --git a/packages/llrt/npm/darwin-x64/package.json b/packages/llrt/npm/darwin-x64/package.json index 0f086d9..d53038e 100644 --- a/packages/llrt/npm/darwin-x64/package.json +++ b/packages/llrt/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-darwin-x64", - "version": "0.1.2", + "version": "0.2.0", "cpu": [ "x64" ], diff --git a/packages/llrt/npm/linux-arm64-gnu/package.json b/packages/llrt/npm/linux-arm64-gnu/package.json index d762e8a..9d99615 100644 --- a/packages/llrt/npm/linux-arm64-gnu/package.json +++ b/packages/llrt/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-linux-arm64-gnu", - "version": "0.1.2", + "version": "0.2.0", "cpu": [ "arm64" ], diff --git a/packages/llrt/npm/linux-x64-gnu/package.json b/packages/llrt/npm/linux-x64-gnu/package.json index 4017988..b6d73dd 100644 --- a/packages/llrt/npm/linux-x64-gnu/package.json +++ b/packages/llrt/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-linux-x64-gnu", - "version": "0.1.2", + "version": "0.2.0", "cpu": [ "x64" ], diff --git a/packages/llrt/package.json b/packages/llrt/package.json index ae4d388..7292c1d 100644 --- a/packages/llrt/package.json +++ b/packages/llrt/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt", - "version": "0.1.2", + "version": "0.2.0", "description": "TypeScript-friendly Node bindings for AWS LLRT.", "type": "module", "main": "./dist/index.js", diff --git a/packages/llrt/src/index.ts b/packages/llrt/src/index.ts index 89c84bc..5affdef 100644 --- a/packages/llrt/src/index.ts +++ b/packages/llrt/src/index.ts @@ -16,6 +16,8 @@ export type { LlrtExecutionErrorCode, LlrtExecutionErrorInfo, LlrtHostCallContext, + LlrtHostFunction, + LlrtHostManifest, LlrtResult, LlrtRuntimeOptions, LlrtStats, diff --git a/packages/llrt/src/native.ts b/packages/llrt/src/native.ts index c1788ed..01230c6 100644 --- a/packages/llrt/src/native.ts +++ b/packages/llrt/src/native.ts @@ -26,6 +26,10 @@ export interface NativeRuntimeOptions { wallTimeMs?: number; cpuTimeMs?: number; maxStackBytes?: number; + maxHostPayloadBytes?: number; + maxResultBytes?: number; + errorMarker?: string; + hostPaths?: string[]; } export interface NativeBinding { diff --git a/packages/llrt/src/runtime.ts b/packages/llrt/src/runtime.ts index af864e9..b4c33ee 100644 --- a/packages/llrt/src/runtime.ts +++ b/packages/llrt/src/runtime.ts @@ -1,15 +1,27 @@ +import { randomUUID } from "node:crypto"; import { emptyStats, errorInfo } from "./errors.js"; import { loadNativeBinding } from "./native.js"; import type { LlrtCallOptions, + LlrtCallFailure, LlrtExecutionErrorInfo, LlrtHostCallContext, LlrtHostFunction, + LlrtHostManifest, LlrtResult, LlrtRuntimeOptions, LlrtStats, } from "./types.js"; +const DEFAULT_WALL_TIME_MS = 30_000; +const DEFAULT_MAX_HOST_CALLS = 100; +const DEFAULT_MAX_HOST_PAYLOAD_BYTES = 1024 * 1024; +const DEFAULT_MAX_HOST_RESULT_BYTES = 10 * 1024 * 1024; +const DEFAULT_MAX_RESULT_BYTES = 10 * 1024 * 1024; +const HOST_ERROR_PREFIX = "__LLRT_HOST_ERROR__"; +const SAFE_HOST_SEGMENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const UNSAFE_HOST_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); + function hasNativeLoadError(error: unknown): error is { llrtError: LlrtExecutionErrorInfo } { if (!(error instanceof Error) || !("llrtError" in error)) { return false; @@ -44,6 +56,39 @@ export class LlrtRuntime { source: string, input: TInput, options: LlrtCallOptions = {}, + ): Promise> { + return await this.callJsonInternal(source, input, { + ...options, + hostFunctions: options.functions, + hostMode: options.functions ? "legacyGlobal" : "none", + }); + } + + async callJsonWithHost( + source: string, + input: TInput, + manifest: LlrtHostManifest, + options: Omit = {}, + ): Promise> { + const validation = validateHostManifest(manifest); + if (!validation.ok) return validation; + const flattened = flattenHostManifest(manifest); + return await this.callJsonInternal(source, input, { + ...options, + hostFunctions: flattened.functions, + hostPaths: flattened.paths, + hostMode: "nativeArgument", + }); + } + + private async callJsonInternal( + source: string, + input: TInput, + options: LlrtCallOptions & { + hostFunctions?: Record; + hostPaths?: string[]; + hostMode: HostMode; + }, ): Promise> { if (this.disposed) { return { @@ -57,6 +102,12 @@ export class LlrtRuntime { }; } + const mergedOptions = this.mergeOptions(options); + const optionsValidation = validateOptions(mergedOptions); + if (!optionsValidation.ok) { + return optionsValidation; + } + const inputJson = this.stringifyInput(input); if (!inputJson.ok) { return inputJson; @@ -65,19 +116,34 @@ export class LlrtRuntime { try { const binding = loadNativeBinding(); const abortController = new AbortController(); - const hostDispatcher = options.functions - ? createHostDispatcher(options.functions, abortController.signal) + const errorMarker = `${HOST_ERROR_PREFIX}${randomUUID()}:`; + const hostDispatcher = options.hostFunctions + ? createHostDispatcher( + options.hostFunctions, + abortController.signal, + mergedOptions, + errorMarker, + ) : undefined; const result = await (async () => { try { return await binding.callJson( - hostDispatcher ? wrapSourceForHostFunctions(source) : source, + wrapSource(source, { + maxHostPayloadBytes: mergedOptions.maxHostPayloadBytes, + maxResultBytes: mergedOptions.maxResultBytes, + errorMarker, + hostMode: options.hostMode, + }), inputJson.value, { - memoryMb: options.memoryMB ?? this.options.memoryMB, - wallTimeMs: options.wallTimeMs ?? this.options.wallTimeMs, - cpuTimeMs: options.cpuTimeMs ?? this.options.cpuTimeMs, - maxStackBytes: options.maxStackBytes ?? this.options.maxStackBytes, + memoryMb: mergedOptions.memoryMB, + wallTimeMs: mergedOptions.wallTimeMs, + cpuTimeMs: undefined, + maxStackBytes: mergedOptions.maxStackBytes, + maxHostPayloadBytes: mergedOptions.maxHostPayloadBytes, + maxResultBytes: mergedOptions.maxResultBytes, + errorMarker, + hostPaths: options.hostPaths, }, hostDispatcher, ); @@ -139,41 +205,271 @@ export class LlrtRuntime { }; } } + + private mergeOptions(options: LlrtCallOptions): RequiredHostLimits & { + memoryMB?: number; + wallTimeMs?: number; + cpuTimeMs?: number; + maxStackBytes?: number; + maxResultBytes: number; + } { + return { + memoryMB: options.memoryMB ?? this.options.memoryMB, + wallTimeMs: options.wallTimeMs ?? this.options.wallTimeMs ?? DEFAULT_WALL_TIME_MS, + cpuTimeMs: options.cpuTimeMs ?? this.options.cpuTimeMs, + maxStackBytes: options.maxStackBytes ?? this.options.maxStackBytes, + maxHostCalls: options.maxHostCalls ?? this.options.maxHostCalls ?? DEFAULT_MAX_HOST_CALLS, + maxHostPayloadBytes: + options.maxHostPayloadBytes ?? + this.options.maxHostPayloadBytes ?? + DEFAULT_MAX_HOST_PAYLOAD_BYTES, + maxHostResultBytes: + options.maxHostResultBytes ?? + this.options.maxHostResultBytes ?? + DEFAULT_MAX_HOST_RESULT_BYTES, + maxResultBytes: options.maxResultBytes ?? this.options.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES, + }; + } +} + +type HostMode = "none" | "legacyGlobal" | "nativeArgument"; + +interface RequiredHostLimits { + maxHostCalls: number; + maxHostPayloadBytes: number; + maxHostResultBytes: number; +} + +function flattenHostManifest(manifest: LlrtHostManifest): { + functions: Record; + paths: string[]; +} { + const functions: Record = {}; + const paths: string[] = []; + + for (const [namespace, namespaceFunctions] of Object.entries(manifest.namespaces)) { + for (const [name, hostFunction] of Object.entries(namespaceFunctions)) { + const path = `${namespace}.${name}`; + functions[path] = hostFunction; + paths.push(path); + } + } + + return { functions, paths }; +} + +function validateHostManifest(manifest: LlrtHostManifest): { ok: true } | LlrtCallFailure { + for (const [namespace, namespaceFunctions] of Object.entries(manifest.namespaces)) { + const namespaceValidation = validateHostSegment(namespace); + if (!namespaceValidation.ok) return namespaceValidation; + for (const methodName of Object.keys(namespaceFunctions)) { + const methodValidation = validateHostSegment(methodName); + if (!methodValidation.ok) return methodValidation; + } + } + + return { ok: true }; +} + +function validateHostSegment(segment: string): { ok: true } | LlrtCallFailure { + if (!SAFE_HOST_SEGMENT.test(segment) || UNSAFE_HOST_SEGMENTS.has(segment)) { + return unsupported(`Invalid LLRT host capability name: ${segment}`); + } + return { ok: true }; +} + +function unsupported(message: string): LlrtCallFailure { + return { + ok: false, + error: { + code: "UNSUPPORTED", + name: "LlrtUnsupportedOptionError", + message, + }, + stats: emptyStats, + }; +} + +function validateOptions(options: { + memoryMB?: number; + wallTimeMs?: number; + cpuTimeMs?: number; + maxStackBytes?: number; + maxHostCalls: number; + maxHostPayloadBytes: number; + maxHostResultBytes: number; + maxResultBytes: number; +}): { ok: true } | LlrtCallFailure { + if (options.cpuTimeMs !== undefined) { + return unsupported("cpuTimeMs is not enforced by LLRT native bindings; use wallTimeMs"); + } + + for (const [name, value] of Object.entries(options)) { + if (value === undefined) continue; + if (!Number.isFinite(value) || value <= 0) { + return unsupported(`${name} must be a finite positive number`); + } + } + + return { ok: true }; } function createHostDispatcher( functions: Record, signal: AbortSignal, + limits: RequiredHostLimits, + errorMarker: string, ): (payloadJson: string) => Promise { + let hostCalls = 0; return async (payloadJson) => { - const { name, argsJson } = JSON.parse(payloadJson) as { - name: string; - argsJson: string; - }; - const hostFunction = functions[name]; - if (!hostFunction) { - throw new Error(`Unknown LLRT host function: ${name}`); - } + try { + hostCalls += 1; + if (hostCalls > limits.maxHostCalls) { + throw hostLimitError( + "HOST_CALL_LIMIT", + `LLRT host call limit exceeded: max ${limits.maxHostCalls} calls`, + errorMarker, + ); + } + if (Buffer.byteLength(payloadJson, "utf8") > limits.maxHostPayloadBytes) { + throw hostLimitError( + "HOST_PAYLOAD_LIMIT", + `LLRT host call payload exceeds limit of ${limits.maxHostPayloadBytes} bytes`, + errorMarker, + ); + } + const { name, argsJson } = JSON.parse(payloadJson) as { + name: string; + argsJson: string; + }; + if (Buffer.byteLength(argsJson, "utf8") > limits.maxHostPayloadBytes) { + throw hostLimitError( + "HOST_PAYLOAD_LIMIT", + `LLRT host call arguments exceed limit of ${limits.maxHostPayloadBytes} bytes`, + errorMarker, + ); + } + const hostFunction = functions[name]; + if (!hostFunction) { + throw new Error(`Unknown LLRT host function: ${name}`); + } - const args = JSON.parse(argsJson) as unknown[]; - const context: LlrtHostCallContext = { signal }; - const result = await hostFunction.apply(context, args); - const resultJson = JSON.stringify(result); - if (resultJson === undefined) { - return "null"; + const args = JSON.parse(argsJson) as unknown[]; + const context: LlrtHostCallContext = { signal }; + const result = await hostFunction.apply(context, args); + const resultJson = JSON.stringify(result); + if (resultJson === undefined) { + return "null"; + } + if (Buffer.byteLength(resultJson, "utf8") > limits.maxHostResultBytes) { + throw hostLimitError( + "HOST_RESULT_LIMIT", + `LLRT host call result exceeds limit of ${limits.maxHostResultBytes} bytes`, + errorMarker, + ); + } + return resultJson; + } catch (error) { + if (error instanceof Error && error.message.startsWith(errorMarker)) { + return error.message; + } + throw error; } - return resultJson; }; } -function wrapSourceForHostFunctions(source: string): string { - return `async ({ input }) => { +function hostLimitError( + code: "HOST_CALL_LIMIT" | "HOST_PAYLOAD_LIMIT" | "HOST_RESULT_LIMIT", + message: string, + errorMarker: string, +): Error { + return llrtLimitError(code, "LlrtHostLimitError", message, errorMarker); +} + +function llrtLimitError( + code: "HOST_CALL_LIMIT" | "HOST_PAYLOAD_LIMIT" | "HOST_RESULT_LIMIT" | "RESULT_LIMIT", + name: "LlrtHostLimitError" | "LlrtResultLimitError", + message: string, + errorMarker: string, +): Error { + const info = { + code, + name, + message, + } satisfies LlrtExecutionErrorInfo; + return Object.assign(new Error(message), { + message: `${errorMarker}${JSON.stringify(info)}`, + llrtError: info, + }); +} + +function wrapSource( + source: string, + options: { + maxHostPayloadBytes: number; + maxResultBytes: number; + errorMarker: string; + hostMode: HostMode; + }, +): string { + return `async ({ input, host: nativeHost }) => { + function utf8ByteLength(value) { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const codePoint = value.codePointAt(index); + if (codePoint === undefined) continue; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else { + bytes += 4; + index += 1; + } + } + return bytes; + } + + ${ + options.hostMode === "legacyGlobal" + ? ` const host = new Proxy({}, { get(_target, property) { if (typeof property !== "string") return undefined; - return async (...args) => JSON.parse(await globalThis.__llrtHostCall(property, JSON.stringify(args))); + return async (...args) => { + const argsJson = JSON.stringify(args); + if (utf8ByteLength(argsJson) > ${options.maxHostPayloadBytes}) { + throw new Error("LLRT host call arguments exceed limit of ${options.maxHostPayloadBytes} bytes"); + } + return JSON.parse(await globalThis.__llrtHostCall(property, argsJson)); + }; }, - }); + });` + : "" + } + ${ + options.hostMode === "nativeArgument" + ? ` + function wrapNativeHost(value) { + const wrapped = {}; + for (const [namespace, methods] of Object.entries(value ?? {})) { + const wrappedMethods = {}; + for (const [methodName, raw] of Object.entries(methods ?? {})) { + wrappedMethods[methodName] = async (...args) => { + const argsJson = JSON.stringify(args); + if (utf8ByteLength(argsJson) > ${options.maxHostPayloadBytes}) { + throw new Error("LLRT host call arguments exceed limit of ${options.maxHostPayloadBytes} bytes"); + } + return JSON.parse(await raw(argsJson)); + }; + } + wrapped[namespace] = wrappedMethods; + } + return wrapped; + } + const host = wrapNativeHost(nativeHost);` + : "" + } + ${options.hostMode === "none" ? "const host = undefined;" : ""} return await (${source})({ input, host }); }`; diff --git a/packages/llrt/src/types.ts b/packages/llrt/src/types.ts index a75cf13..e03b235 100644 --- a/packages/llrt/src/types.ts +++ b/packages/llrt/src/types.ts @@ -3,6 +3,10 @@ export interface LlrtRuntimeOptions { wallTimeMs?: number; cpuTimeMs?: number; maxStackBytes?: number; + maxHostCalls?: number; + maxHostPayloadBytes?: number; + maxHostResultBytes?: number; + maxResultBytes?: number; } export interface LlrtCallOptions { @@ -10,9 +14,17 @@ export interface LlrtCallOptions { wallTimeMs?: number; cpuTimeMs?: number; maxStackBytes?: number; + maxHostCalls?: number; + maxHostPayloadBytes?: number; + maxHostResultBytes?: number; + maxResultBytes?: number; functions?: Record; } +export interface LlrtHostManifest { + namespaces: Record>; +} + export interface LlrtHostCallContext { signal: AbortSignal; } @@ -37,6 +49,10 @@ export type LlrtExecutionErrorCode = | "MEMORY_LIMIT" | "RUNTIME_DISPOSED" | "NATIVE_LOAD_ERROR" + | "HOST_CALL_LIMIT" + | "HOST_PAYLOAD_LIMIT" + | "HOST_RESULT_LIMIT" + | "RESULT_LIMIT" | "UNSUPPORTED"; export interface LlrtExecutionErrorInfo { diff --git a/packages/llrt/test/call-json.test.ts b/packages/llrt/test/call-json.test.ts index d5673e8..64b5d98 100644 --- a/packages/llrt/test/call-json.test.ts +++ b/packages/llrt/test/call-json.test.ts @@ -200,6 +200,83 @@ describe("LlrtRuntime.callJson native execution", () => { }); }); + it("does not expose a raw host bridge in data-only execution", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJson< + Record, + { host: string; raw: string } + >( + `async ({ host }) => ({ + host: typeof host, + raw: typeof globalThis.__llrtHostCall, + })`, + {}, + ); + + expect(result).toMatchObject({ + ok: true, + value: { host: "undefined", raw: "undefined" }, + }); + }); + + it("does not expose a raw host bridge in manifest capability execution", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJsonWithHost< + Record, + { response: { status: number; path: string }; raw: string; missing: string } + >( + `async ({ host }) => ({ + response: await host.api.request({ path: "/pets" }), + raw: typeof globalThis.__llrtHostCall, + missing: typeof host.api.secret, + })`, + {}, + { + namespaces: { + api: { + request: async (request: { path: string }) => ({ + status: 200, + path: request.path, + }), + }, + }, + }, + ); + + expect(result).toMatchObject({ + ok: true, + value: { + response: { status: 200, path: "/pets" }, + raw: "undefined", + missing: "undefined", + }, + }); + }); + + it("rejects unsafe host manifest names", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJsonWithHost( + `async () => null`, + {}, + { + namespaces: { + ["__proto__"]: { + request: async () => null, + }, + }, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("UNSUPPORTED"); + expect(result.error.message).toContain("Invalid LLRT host capability name"); + } + }); + it("returns a typed timeout when an async host function stalls", async () => { const runtime = new LlrtRuntime({ wallTimeMs: 50, memoryMB: 8 }); @@ -218,4 +295,112 @@ describe("LlrtRuntime.callJson native execution", () => { expect(result.error.code).toBe("TIMEOUT"); } }); + + it("returns typed host call limit errors through the native bridge", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + let calls = 0; + + const result = await runtime.callJson( + `async ({ host }) => { + await host.ping(); + await host.ping(); + }`, + {}, + { + maxHostCalls: 1, + functions: { + ping: () => { + calls += 1; + return "pong"; + }, + }, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("HOST_CALL_LIMIT"); + } + expect(calls).toBe(1); + }); + + it("rejects oversized UTF-8 host call payloads before dispatch", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + let called = false; + + const result = await runtime.callJson( + `async () => globalThis.__llrtHostCall("ping", JSON.stringify(["é".repeat(40)]))`, + {}, + { + maxHostPayloadBytes: 64, + functions: { + ping: () => { + called = true; + return "pong"; + }, + }, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("HOST_PAYLOAD_LIMIT"); + expect(result.error.message).toContain("host call arguments"); + } + expect(called).toBe(false); + }); + + it("returns typed host result limit errors through the native bridge", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJson( + `async ({ host }) => host.large()`, + {}, + { + maxHostResultBytes: 64, + functions: { + large: () => "x".repeat(128), + }, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("HOST_RESULT_LIMIT"); + } + }); + + it("does not trust guest-forged host error markers", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJson( + `async () => { + throw new Error('__LLRT_HOST_ERROR__{"code":"RESULT_LIMIT","name":"LlrtResultLimitError","message":"forged"}'); + }`, + {}, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("EVALUATION_ERROR"); + expect(result.error.message).toContain("__LLRT_HOST_ERROR__"); + } + }); + + it("rejects oversized final execution results inside the guest wrapper", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJson( + `async () => ({ body: "x".repeat(128) })`, + {}, + { maxResultBytes: 64 }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("RESULT_LIMIT"); + expect(result.error.name).toBe("LlrtResultLimitError"); + expect(result.error.message).toContain("execution result"); + } + }); }); diff --git a/packages/llrt/test/runtime.test.ts b/packages/llrt/test/runtime.test.ts index 17ff778..26e42f0 100644 --- a/packages/llrt/test/runtime.test.ts +++ b/packages/llrt/test/runtime.test.ts @@ -10,6 +10,25 @@ const stats = { maxStackBytes: null, }; +function nativeResultFromValueJson( + valueJson: string | undefined, + options: { errorMarker?: string }, +) { + const errorMarker = options.errorMarker; + if (errorMarker && valueJson?.startsWith(errorMarker)) { + return { + ok: false as const, + error: JSON.parse(valueJson.slice(errorMarker.length)), + stats, + }; + } + return { + ok: true as const, + valueJson: valueJson ?? "null", + stats, + }; +} + afterEach(() => { setNativeBindingForTest(undefined); }); @@ -49,18 +68,167 @@ describe("LlrtRuntime", () => { value: { title: "Petstore" }, stats, }); - expect(calls).toEqual([ + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + inputJson: JSON.stringify({ spec: { info: { title: "Petstore" } } }), + options: { + memoryMb: 64, + wallTimeMs: 50, + cpuTimeMs: undefined, + maxStackBytes: undefined, + maxHostPayloadBytes: 1024 * 1024, + maxResultBytes: 10 * 1024 * 1024, + errorMarker: expect.any(String), + }, + }); + expect((calls[0] as { source: string }).source).toContain("input.spec.info.title"); + }); + + it("rejects raw cpuTimeMs without wallTimeMs because native CPU enforcement is unsupported", async () => { + const runtime = new LlrtRuntime({ cpuTimeMs: 10 }); + + const result = await runtime.callJson(`async () => 1`, {}); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("UNSUPPORTED"); + expect(result.error.message).toContain("wallTimeMs"); + } + }); + + it("rejects invalid numeric limits before calling native", async () => { + const calls: unknown[] = []; + const binding: NativeBinding = { + nativeSmoke() { + return "llrt-native-ok"; + }, + callJson() { + calls.push("called"); + return Promise.resolve({ + ok: true, + valueJson: "null", + stats, + }); + }, + dispose() {}, + }; + setNativeBindingForTest(binding); + const runtime = new LlrtRuntime({ wallTimeMs: Number.POSITIVE_INFINITY }); + + const result = await runtime.callJson(`async () => 1`, {}); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("UNSUPPORTED"); + } + expect(calls).toEqual([]); + }); + + it("limits host calls before dispatching to native callbacks", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000 }); + let dispatched = 0; + const binding: NativeBinding = { + nativeSmoke() { + return "llrt-native-ok"; + }, + async callJson(_source, _inputJson, options, hostDispatcher) { + await hostDispatcher?.(JSON.stringify({ name: "ping", argsJson: "[]" })); + const second = await hostDispatcher?.(JSON.stringify({ name: "ping", argsJson: "[]" })); + return nativeResultFromValueJson(second, options); + }, + dispose() {}, + }; + setNativeBindingForTest(binding); + + const result = await runtime.callJson( + `async () => null`, + {}, { - source: `async ({ input }) => ({ title: input.spec.info.title })`, - inputJson: JSON.stringify({ spec: { info: { title: "Petstore" } } }), - options: { - memoryMb: 64, - wallTimeMs: 50, - cpuTimeMs: undefined, - maxStackBytes: undefined, + maxHostCalls: 1, + functions: { + ping: () => { + dispatched += 1; + return "pong"; + }, }, }, - ]); + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("HOST_CALL_LIMIT"); + } + expect(dispatched).toBe(1); + }); + + it("limits UTF-8 host call payload bytes before dispatching host functions", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000 }); + let dispatched = false; + const binding: NativeBinding = { + nativeSmoke() { + return "llrt-native-ok"; + }, + async callJson(_source, _inputJson, options, hostDispatcher) { + const oversizedArgs = JSON.stringify(["é".repeat(40)]); + const valueJson = await hostDispatcher?.( + JSON.stringify({ name: "ping", argsJson: oversizedArgs }), + ); + return nativeResultFromValueJson(valueJson, options); + }, + dispose() {}, + }; + setNativeBindingForTest(binding); + + const result = await runtime.callJson( + `async () => null`, + {}, + { + maxHostPayloadBytes: 64, + functions: { + ping: () => { + dispatched = true; + return "pong"; + }, + }, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("HOST_PAYLOAD_LIMIT"); + } + expect(dispatched).toBe(false); + }); + + it("limits host call result bytes", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000 }); + const binding: NativeBinding = { + nativeSmoke() { + return "llrt-native-ok"; + }, + async callJson(_source, _inputJson, options, hostDispatcher) { + const valueJson = await hostDispatcher?.(JSON.stringify({ name: "large", argsJson: "[]" })); + return nativeResultFromValueJson(valueJson, options); + }, + dispose() {}, + }; + setNativeBindingForTest(binding); + + const result = await runtime.callJson( + `async () => null`, + {}, + { + maxHostResultBytes: 64, + functions: { + large: () => "x".repeat(128), + }, + }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe("HOST_RESULT_LIMIT"); + } }); it("returns a typed serialization failure when input cannot be JSON stringified", async () => {