From bca469284a4dd3a1cc4c33f1130ded14d9b73bae Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 17 Jun 2026 10:23:47 +0200 Subject: [PATCH 1/2] docs: specify llrt capability boundary hardening [skip ci] --- ...6-17-llrt-capability-boundary-hardening.md | 837 ++++++++++++++++++ ...rt-capability-boundary-hardening-design.md | 301 +++++++ 2 files changed, 1138 insertions(+) create mode 100644 internal/superpowers/plans/2026-06-17-llrt-capability-boundary-hardening.md create mode 100644 internal/superpowers/specs/2026-06-17-llrt-capability-boundary-hardening-design.md 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. From 2be5ea5fe6bf02e69aa1844546ca9c052bb942e7 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 17 Jun 2026 11:10:35 +0200 Subject: [PATCH 2/2] feat: harden llrt capability execution Rationale: LLRT is becoming the default codemode runtime, so host access must be explicit and manifest-bound instead of flowing through loose function globals or raw bridge globals. This adds data-only and capability executor contracts, validates data-only inputs, binds LLRT host capabilities by declared namespace/method, and versions LLRT/codemode for release. Rejected: Keeping function globals as the main capability path was rejected because guest code could observe the raw LLRT host bridge and synthesize callback names. Keeping CNAP on implicit codemode executor selection was rejected because capability-capable execution should be explicit. Risk: Legacy execute() with function globals is still supported, but now routes through a private manifest namespace. CNAP integration cannot be fully verified until @robinbraemer/llrt@0.2.0 and @robinbraemer/codemode@0.4.0 are published and consumed. Tested: mise exec -- pnpm run ci Not-tested: CNAP focused check against the new package version; blocked until the npm releases are available or locally linked into that worktree. --- README.md | 31 +- packages/codemode/package.json | 4 +- packages/codemode/src/codemode.ts | 85 +++- packages/codemode/src/executor/auto.ts | 54 ++- packages/codemode/src/executor/data-only.ts | 75 ++++ packages/codemode/src/executor/isolated-vm.ts | 165 +++++--- packages/codemode/src/executor/llrt-native.ts | 223 +++++++++- .../codemode/src/executor/llrt-process.ts | 30 +- packages/codemode/src/executor/quickjs.ts | 270 +++++-------- packages/codemode/src/index.ts | 7 + packages/codemode/src/limits.ts | 5 + packages/codemode/src/mcp.ts | 5 +- packages/codemode/src/request-bridge.ts | 138 ++++--- packages/codemode/src/types.ts | 98 ++++- packages/codemode/src/types/llrt-peer.d.ts | 28 +- packages/codemode/test/auto-executor.test.ts | 21 +- packages/codemode/test/codemode.test.ts | 90 ++++- packages/codemode/test/data-only.test.ts | 24 ++ packages/codemode/test/executor-contract.ts | 350 +++++++++++++--- .../test/isolated-vm-executor.test.ts | 24 ++ .../test/llrt-native-executor.test.ts | 69 +++- .../test/llrt-process-executor.test.ts | 12 + .../codemode/test/package-publication.test.ts | 2 +- packages/codemode/test/petstore-limit.test.ts | 37 +- .../codemode/test/quickjs-executor.test.ts | 61 ++- packages/codemode/test/request-bridge.test.ts | 62 +++ packages/llrt/native/Cargo.toml | 2 +- packages/llrt/native/src/runtime.rs | 382 ++++++++++++++++-- packages/llrt/npm/darwin-arm64/package.json | 2 +- packages/llrt/npm/darwin-x64/package.json | 2 +- .../llrt/npm/linux-arm64-gnu/package.json | 2 +- packages/llrt/npm/linux-x64-gnu/package.json | 2 +- packages/llrt/package.json | 2 +- packages/llrt/src/index.ts | 2 + packages/llrt/src/native.ts | 4 + packages/llrt/src/runtime.ts | 348 ++++++++++++++-- packages/llrt/src/types.ts | 16 + packages/llrt/test/call-json.test.ts | 185 +++++++++ packages/llrt/test/runtime.test.ts | 186 ++++++++- 39 files changed, 2564 insertions(+), 541 deletions(-) create mode 100644 packages/codemode/src/executor/data-only.ts create mode 100644 packages/codemode/src/limits.ts create mode 100644 packages/codemode/test/data-only.test.ts 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/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 () => {