Sandbox Package System — JS/WASM packages for the QuickJS sandbox

Design for letting NodeTool packs ship code that runs inside the QuickJS sandbox: JS guest modules the Code node and CodeAct steps can import, and WASM modules bridged in from the host. Distribution rides the existing package manager — the nodetool manifest in a pack’s package.json, the npm install flow, and the registry index.

Status: design, revised after two review rounds. Nothing in this document is implemented yet. The first review killed four assumptions (corrected under “Facts”); the second pinned the contracts that were still loose — CodeAct session consent, the untrusted-doc policy, exact scalar WASM signatures and instance ownership, memory-manifest semantics, generated facades, cycle-safe catalog placement, capability-free admission probes, exact-artifact installation, and the M1 parity flag. M-1 is approved to start; M0 onward implements this text.

Problem

Every library the sandbox offers today is a host bridge: papaparse, cheerio, exceljs, fflate and friends run on the host, lazily imported inside their bridge in packages/agents/src/js-sandbox.ts, and the guest sees only plain-data functions. That keeps the guest surface identical across dev, packaged Electron, and the in-browser runner — but it has a hard ceiling:

  • Adding a library means editing js-sandbox.ts, the sandbox manifest, and the drift tests, then shipping a NodeTool release. Third parties cannot extend the sandbox at all.
  • A pack that ships nodes has no way to ship helper code for Code nodes in its example workflows.
  • Compute-heavy pure functions (parsers, codecs, geometry, hashing) are exactly what WASM is for, and there is no way to bring one.

The goal: a pack installs through the normal package manager and its sandbox modules become importable in the guest — without weakening the sandbox’s security contract or breaking surface parity across the three runtimes.

Facts the design must respect

  1. The engine is quickjs-ng (@sebastianwessel/quickjs ^3 over quickjs-emscripten-core). A fresh runtime/context per invocation; 64 MB memory limit, 512 KB stack, interrupt handler on a deadline; eval/Function deleted so the guest cannot self-generate code.
  2. quickjs-ng has no WebAssembly global. WASM cannot execute inside the guest. “WASM in the sandbox” therefore means: WASM instantiated on the host (Node and browsers both have WebAssembly), reached from the guest through a bridge — the same pattern as every existing library.
  3. wrapCode() cannot contain an import. User code is emitted as the body of an async IIFE inside the module (js-sandbox.ts:2194: export default await (async () => { ... })();), so an import statement in user code is a syntax error where it lands. Supporting imports requires an AST transformation that hoists static imports out of the IIFE — touching implicit returns, Code-node and CodeAct preludes, streaming rewrites, source locations, and validation. And because an ES module’s dependencies evaluate before the entry body, the timer-deletion hardening wrapCode emits would run after imported modules execute. Hardening must be applied before any loaded module evaluates, by the loader or an equivalent mechanism — not by the entry module.
  4. The wrapper’s virtual FS is not empty. createVirtualFileSystem in @sebastianwessel/quickjs unconditionally mounts Node-compat modules (buffer, fs, path, process, timers, url, …) into the guest’s node_modules, and its normalizer maps node:* specifiers onto them — await import("node:buffer") succeeds under the wrapper’s defaults. Passing nodeModules does not mean “nothing else resolves”. The allowlist must therefore be enforced by a custom module loader and normalizer at runtime; static validation alone cannot hold it, because a computed dynamic import (import("node:" + "buffer")) bypasses any declaration check.
  5. The pack system already carries manifests. Third-party packs are npm packages with a nodetool field in package.json, discovered by packages/node-sdk/src/pack-loader.ts, gated by a trust allowlist, installed by Electron into <userData>/optional-node, and listed in the registry index. Loading a pack with a register export means running it in-process with full server privileges, which is why untrusted packs are skipped.
  6. npm install executes lifecycle scripts. The Electron installer (electron/src/nodePackManager.ts:112) runs npm install <spec> without --ignore-scripts, so preinstall/install/postinstall run before discovery ever reads a manifest. “Installing a sandbox-only pack runs no pack code” is only true if sandbox-only installs pass --ignore-scripts. This is part of the trust model, not later hardening.
  7. Host-side value marshaling is settled. Typed arrays serialize guest→host natively; host→guest bytes travel as tagged base64 revived by a guest prelude. Async bridge errors return tagged objects, never rejected promises. New bridges must follow these rules.
  8. Packaged Electron flattens paths. Data files a package loads at runtime must be registered so bundle-backend.mjs stages them and verify-backend-bundle.mjs checks them.

Design

Two module kinds

Kind Runs Capability model Risk
JS guest module inside QuickJS, in the same context as the node’s code everything the node’s code can reach: fetch, workspace, getSecret, asset bridges, injected globals; it can also mutate globals and prototypes for the rest of the invocation cannot escape QuickJS, but shares the node’s granted bridges — importing a module means trusting it with that node’s capabilities
Host WASM module on the host, behind a bridge no imports: no I/O, no syscalls, no host references; memory bounded by binary validation host CPU and memory, bounded by worker budgets

A guest JS module is the default answer. WASM is for compute a JS module cannot do at acceptable speed. A pack can pair them: a WASM module plus a guest JS wrapper that gives it an ergonomic API.

The first draft claimed guest modules are capability-free and need no trust. That was wrong — a malicious dependency can exfiltrate through fetch, read what getSecret grants the node, act at module initialization, and poison prototypes for the invocation. The corrected statement: a guest module cannot escape QuickJS directly, but it shares the node’s granted bridges.

v1 adopts explicit dependency consent:

  • Importing a module means trusting it with that node’s sandbox capabilities. The Package Manager and the Code node UI say this in those words at install time and in the package picker.
  • Sandbox-only packs (no register) still clear a far lower bar than register packs — no host execution, no lifecycle scripts (below), and the sandbox’s hard limits — so they install without the register-pack allowlist. But the UI presents them as “runs inside your workflows with the node’s capabilities”, never as “no trust needed”.
  • Capability attenuation (packages in a separate QuickJS context with data-only arguments) is the stronger model and stays on the table as a later opt-in (isolation: true on a declaration); it is not v1 because it abandons normal in-context ESM semantics.

Installation must not execute pack code, and classification must read the exact artifact. Registry metadata can describe a different artifact than what a tag resolves to at install time, and a package can change from sandbox-only to register between versions. The flow is therefore:

  1. Every initial install runs with --ignore-scripts.
  2. The installed manifest — the exact resolved artifact — is inspected.
  3. Sandbox-only: done.
  4. A register pack needing lifecycle scripts: request trust, then run scripts against the artifact already on disk (rebuild flow), or — if a reinstall is unavoidable — record the lockfile’s resolved URL and integrity from step 1 and verify the refetched artifact’s integrity is identical before scripts run. A version number is not integrity: a mutable private registry can serve a different artifact for the same version.

Note that --ignore-scripts also suppresses dependency lifecycle scripts, not only the top-level pack’s — the rebuild flow must cover those too. The install UI shows which mode applied. The security-sensitive decision never rests on a registry summary. This lands in M0.

SKILL.md is third-party prompt content, and quoting is risk reduction, not isolation. A model can follow malicious instructions inside quoted documentation; no delimiter changes that. The v1 policy:

  • Trusted pack: its skill registers through the normal skill system.
  • Untrusted pack: docs are visible in the UI; the agent sees the body only as untrusted tool output, and only after the package has been chosen for the session.
  • The ambient one-line tier is derived from manifest fields under strict length and character limits — never arbitrary pack text — so a description cannot smuggle instructions into every prompt.

CodeAct requires session consent, not installed-equals-authorized. Code nodes carry a persisted declaration a user saved; CodeAct parses model-generated imports, and auto-mounting anything installed would let the model grant an unrelated pack the action’s fetch, workspace, getSecret and tool capabilities. CodeAct therefore mounts only packs on the session’s package allowlist, sourced from agent/session configuration, user approval on first use, or the task/workflow’s own declarations — defaulting to trusted packs only. The prompt advertises only session-allowed packages, not the installed catalog.

Package unit: manifest + SKILL.md

A pack’s sandbox configuration is two declarative files: the nodetool.sandboxModules manifest in package.json and a SKILL.md at the package root. Neither is code. Manifest types are Zod-backed discriminated unions at the package boundary (per the repo’s untrusted-input rules), not bare TypeScript interfaces.

// package.json of @acme/nodetool-geo
{
  "name": "@acme/nodetool-geo",
  "version": "1.2.0",
  "nodetool": {
    "apiVersion": 1,
    "sandboxModules": [
      {
        "name": ".",                        // root entry → specifier "@acme/nodetool-geo"
        "kind": "js",
        "file": "sandbox/geo.js"
      },
      {
        "name": "extra",                    // subpath → "@acme/nodetool-geo/extra"
        "kind": "js",
        "file": "sandbox/extra.js"
      },
      {
        "name": "simplify-wasm",
        "kind": "wasm",
        "file": "sandbox/simplify.wasm",
        "memoryPagesMax": 256,              // binary's max must be ≤ this and ≤ host ceiling
        "exports": ["simplify"]             // scalar-only in v1; see WASM section
      }
    ]
  }
}

Specifier rule (one rule, used everywhere): the import specifier is <packageName> when name is ".", else <packageName>/<name>. name is a single path segment (no separators, no .., no reserved forms), validated and normalized at discovery; duplicates are a discovery error. A single-module pack uses ".", which is what the migrated bridge packs do (import Papa from "@nodetool-ai/sandbox-csv").

Discovery-time rules (pack-loader.ts), enforced without executing pack code:

  • file resolves inside the package directory (containment + symlink check).
  • Size caps: 256 KB per authored JS source, 4 MB per WASM binary, 8 MB per pack; npm-bundled modules get 1 MB (below).
  • JS modules parse with acorn. Allowed: ES module syntax and static import of sibling files of the same pack. Rejected: all ImportExpression nodes (dynamic import — in package and user code alike, v1), require, and imports of anything outside the pack.
  • Helpers not meant for user code go in a pack-level internal list ("internal": ["sandbox/util.js"]): .js files, canonical id = the normalized pack-relative path, duplicates and cycles rejected at discovery. Internal files are mounted and importable by any public entry of the same pack (sharing is allowed), never valid in a node’s packages declaration, and count toward the pack size caps and the graph digest.
  • WASM binaries get a section-level parse; declared exports are checked against the export section, and the memory rule below is validated before compilation is ever attempted.
  • SKILL.md is validated (frontmatter, 16 KB cap) — but an invalid or missing skill only disables agent discoverability with a warning. It never disables the module: a frontmatter typo must not break a workflow that imports working code.

Guest import surface

User code imports a declared module by its specifier:

import { haversine } from "@acme/nodetool-geo";
const km = haversine(a, b);
return { km };

Mechanics, in the order that matters:

  • AST transform, not string wrapping. The entry is built by parsing user code, hoisting static ImportDeclarations to module top level, and wrapping the remainder in the async IIFE that gives return and top-level await their current meaning. Preludes, streaming rewrites, and source maps ride the same transform. This is proven by the M-1 spike before anything else is built.
  • The loader is the authority. runInSandbox installs a custom module loader and normalizer (via the underlying setModuleLoader) that resolve only the run’s declared specifiers and their intra-pack siblings. Everything else — node:*, the wrapper’s compat modules, absolute paths, encoded traversals, computed specifiers — fails at resolve with an error naming the node’s packages declaration. Static validation is a courtesy layer for early errors; the loader holds the boundary at runtime.
  • Hardening precedes evaluation. Timer deletion and global hardening are applied before any loaded module evaluates — not by the entry module’s first statements. The M-1 spike proves the mechanism (loader hook, per-module preamble, or engine-level init).
  • Adversarial tests are part of the contract: node:* in package and user code, computed dynamic imports, absolute and encoded paths, sibling escapes, and compat-module cache hits must all fail, on Node and in the browser runner.
  • Guest modules evaluate under the same interrupt handler and memory limit as user code. No new budget knobs.

Declaring usage on the node

Imports are explicit per node. The Code node’s packages property is a list of declarations, not bare strings:

interface SandboxModuleDeclaration {
  specifier: string;               // "@acme/nodetool-geo" or ".../extra"
  resolvedPackVersion?: string;    // stamped when the workflow is saved
  contentDigest?: string;          // digest of the resolved sources, same one delivery verifies
}

Resolution always uses the installed version; a mismatch between resolvedPackVersion and the installed pack is a validation warning (not a lock failure, not an auto-upgrade). contentDigest is stamped from day one, and it is a module-graph digest, not an entry-file hash: a canonical, sorted list of every transitive source with its normalized module id, generated facade source and generator version, compiler options and compiler version for npm modules, and WASM bytes where applicable. The workflow declaration, the delivery response, the bundle cache, and validation all use this same graph digest; a mismatch warns with a distinct message. CodeAct parses each step’s imports and mounts exactly those — but only after checking them against the session package allowlist from the trust model, never against the whole installed catalog.

Why explicit: nodetool validate checks a workflow offline (typo, missing pack, version drift) before anything runs; the loader mounts only what is declared, so the guest surface stays deterministic; and a shared workflow names its dependencies, so importing it elsewhere says exactly which packs to install.

One catalog, injected everywhere

The first draft left registry ownership implicit, which does not survive contact with the real wiring: bootstrapNodeRegistry() loads packs asynchronously, the CLI’s buildFullRegistry() is synchronous and loads no installed packs, validateGraph() sees only a narrow registry interface, and soft reload/uninstall differ from startup. The design therefore names one deep module with an injected interface:

Placement respects the dependency order — ProcessingContext lives in @nodetool-ai/runtime, and node-sdk already depends on runtime, so runtime cannot import an interface defined in node-sdk. Data types (declarations, summaries, statuses) go in @nodetool-ai/protocol; the runtime-facing interface goes in @nodetool-ai/runtime; node-sdk provides the discovery adapter that implements it without owning it.

The interface is split by consumer, so the general surface never hands out raw absolute paths or skill bodies. Delivery is asynchronous — entitlement checks may need database or remote state — and authorization and retrieval are one operation: the resolved AuthorizedSandboxModuleDelivery carries browser-safe content, media type, the graph digest, and dependency module ids, never a filesystem path, so the two checks cannot drift apart:

interface SandboxModuleCatalog {
  summaries(): readonly SandboxModuleSummary[];          // UI + prompt tier
  resolveForExecution(
    declarations: readonly SandboxModuleDeclaration[]
  ): SandboxModuleResolution;                            // sources for the loader
  authorizeDelivery(moduleId: string, principal: DeliveryPrincipal):
    Promise<AuthorizedSandboxModuleDelivery>;            // browser route
  diagnostics(): readonly SandboxModuleStatus[];         // doctor / packs.list
}

One catalog instance is constructed where packs are discovered and injected into every consumer: kernel execution (via ProcessingContext), validation, CodeAct prompt generation, the websocket delivery route, and the CLI (which constructs its own from the same discovery code). No second global snapshot next to pack-snapshot.ts; catalog statuses are their own set, separate from node-pack loading statuses. Soft reload swaps the catalog’s contents; in-flight runs keep the resolution they started with.

Config-only modules from npm packages

A pack does not have to author sandbox code. A manifest entry can point at an npm dependency, and NodeTool produces the guest module from it:

{
  "name": "@nodetool-ai/sandbox-yaml",
  "dependencies": { "js-yaml": "^4.1.0" },
  "nodetool": {
    "apiVersion": 1,
    "sandboxModules": [{ "name": ".", "kind": "js", "npm": "js-yaml" }]
  }
}

Mechanics, at pack discovery, in a dedicated compiler module (not esbuild concerns spread through node-sdk):

  • esbuild bundles the package: bundle: true, format: "esm", platform: "neutral", explicit conditions and mainFields (neutral alone does not pin which conditional export is chosen), no externals. An import of a Node builtin fails the bundle — that is the filter, surfaced as a named skip at install time.
  • The output goes through the authored-module static checks plus a scope-aware forbidden-global scan (process, Buffer, require, eval, Function, timers, WebAssembly, DOM names). Hard references error; feature-detected references warn — and a warning is a heads-up, not a compatibility guarantee. Only the QuickJS probe below establishes that initialization actually works.
  • Bundling proves resolution, not compatibility. Admission ends with a probe: the bundle is imported in the actual QuickJS engine — and the probe context is capability-free, because importing executes top-level code. No fetch, no workspace or secret bridges, no tools; a short deadline, normal memory limits, output/log caps. Discovery must never grant package code capabilities before a workflow imports it. The probe proves module initialization only — it cannot prove every export works — and runtime loading stays authoritative after admission. A module that fails the probe is skipped with the probe’s error.
  • Cache keys are digests, not versions. pack/name@version is wrong — output changes under linked packs, transitive updates, lockfile changes, esbuild upgrades, and option changes while the version stays put. The cache key is concrete: a hash over esbuild’s metafile input list (every input file’s content hash), the esbuild version, and the normalized build options. Writes are atomic; every path component is sanitized. Bundled modules cap at 1 MB — and M-1 measures real candidates (zod, date-fns, cheerio) before that number is promised anywhere.

What fits: pure-computation ESM (schema validation, functional utilities, date math, parsers). What cannot, regardless of tooling: Node builtins, DOM, network, timers, eval, WASM-shipping packages (no WebAssembly in the guest — those need the host-WASM path).

Host WASM modules

v1 is scalar-only, with exact signatures. The v1 call contract: arguments and returns are i32, f32, or f64 only; exactly one scalar return (or none — a void export resolves to undefined); argument count bounded (8). Binary validation rejects exports using i64 (maps to bigint, not number), v128, reference types, multi-value returns, or excess arity — an export outside the contract is named in the skip reason, not silently dropped — and verifies every named export is a function, not a memory, table, or global. Manifest export names must be valid, non-reserved JavaScript identifiers; a binary export that is not ("foo-bar") is mapped explicitly: { "wasm": "foo-bar", "as": "fooBar" }.

Conversion is validated host-side before dispatch — never left to implicit WebAssembly coercion, so Node and browser behave identically: an i32 argument must be a finite integer in int32 range (no wrapping — out-of-range rejects), f32/f64 accept any JS number including NaN and infinities, and f32 rounds by WebAssembly’s normal rules.

Byte-oriented calls wait for a future ABI, informed by the M-1 reference module from a documented toolchain. There is no interim workaround: guest JS cannot reach the host instance’s linear memory, so typed-array work is simply out of scope until that ABI exists.

Memory: a host cannot cap a module-defined memory at instantiation — with an empty import object the binary’s own limits rule. The v1 rule is therefore validation, not override. The manifest declares memoryPagesMax; the binary’s own declared maximum must be at or below both the manifest request and the host hard ceiling (4096 pages / 256 MB), every memory must declare a maximum, shared memories are rejected, and a module that imports its memory makes the import object non-empty and is rejected — all checked in the binary before compilation. A manifest cannot lower a larger baked-in maximum except by rejecting the module.

Instance ownership: instance per call, stateless. Each call instantiates fresh from the cached module inside the worker, runs, and discards the instance. Mutable globals and linear-memory contents do not persist between calls, and the docs say so — stateless semantics is the contract, not an accident. This is what makes pooling, concurrent calls, and timeout-terminate-replace coherent: a killed worker destroys nothing an invocation owns. Packs needing cross-call state keep it in guest JS and pass it in as scalars, or wait for the byte ABI.

The import surface is a generated facade. For a WASM entry, import { simplify } from "@acme/nodetool-geo/simplify-wasm" resolves to an ESM facade the catalog generates: named async exports matching the manifest’s exports, calling into a per-run dispatcher. Authored JS in the same pack may import a sibling WASM entry by the same specifier rules; it resolves to the same facade.

The security contract is enforceable behavior, not an unobservable handle — a facade needs some guest-visible binding to receive the host function, and other modules evaluate during dependency loading:

  • The dispatcher serves only WASM modules declared for the run.
  • Every call validates module identity, export allowlist, and argument count and scalar types before the worker runs.
  • The static analyzer and the runtime loader both deny direct imports of the private bridge module.
  • The dispatcher binding is removed before the user IIFE starts.
  • A module that discovers the temporary binding anyway gains nothing beyond the run’s declared WASM surface — the dispatcher checks, not the hiding, are the boundary.

M-1 proves the hiding mechanism before the design promises anything stronger.

Execution and budgets:

  • Compile once per process, cached (WebAssembly.Module is structured-cloneable, so it crosses to workers without recompiling).
  • Calls run on workers (Node worker_threads, browser Web Worker) with a hard per-call timeout; a timed-out worker is terminated and replaced.
  • Per-call timeout alone does not bound aggregate use, so the budgets are layered, with defaults fixed now rather than left open until M4: process-wide worker pool of 4; per-invocation call concurrency of 2; 256 calls per invocation; an aggregate WASM wall-clock budget of 30 s per invocation (matching the sandbox default timeout). A manifest may lower these, never raise them. Byte caps belong to the future byte ABI.

How agents learn a pack: SKILL.md, progressive disclosure

A sandbox pack may provide a SKILL.md — the compressed docs page for using the library inside the sandbox: the specifier, the main functions with one example each, and the gotchas that matter there (input caps, the 64 MB guest heap, no timers). A pack without one stays runnable; only agent discoverability suffers. Format and parser are the existing AgentSkill machinery (packages/agents/src/agent.ts), hoisted so node-sdk can call it.

Disclosure is two-tier so prompt size stays flat: one line per specifier (specifier + a description derived from manifest fields under strict length/character limits) always — and for CodeAct, only specifiers on the session allowlist; the full body on demand when the agent reaches for the pack, under the trust rules above (trusted: normal skill registration; untrusted: untrusted tool output after the package is chosen for the session). The Code node’s package picker renders the same file for the human. A pack exposing several specifiers carries one skill with a section per module.

Delivery and distribution

  • Install: the existing Electron npm installer, with the --ignore-scripts split described under the trust model.
  • Registry: index entries gain a sandboxModules: string[] summary for search; per-file hashes come with the ecosystem milestone.
  • Browser runner: module sources are fetched by opaque module idGET /api/sandbox-modules/:moduleId — never by path segments: scoped names contain /, and encoded slashes behave inconsistently across routers and proxies. The server resolves the id through the catalog (no route-to-filesystem translation), responses carry a content digest the client verifies, and the browser caches by digest. Whether private packs’ source may be delivered to a given browser client is an entitlement question the route must answer through the catalog, not assume from authentication alone.
  • Packaged Electron: installed packs arrive through the optional-node root as today. Only if a bundled builtin ever ships sandbox modules does bundle-backend.mjs stage them (under _sandbox/<pack>/), verified by verify-backend-bundle.mjs.

Validation and harnesses

  • nodetool validate / validate_workflow: unknown specifier (error, names the pack), declared-but-unused (warning), version mismatch (warning), import in code missing from packages (error).
  • nodetool node run / debug: resolve through the same catalog, so a failing import reproduces headlessly.
  • The sandbox manifest’s generated packages section and its drift test pin the one-line disclosure tier.
  • Node and browser run the same contract-test fixtures for loading, denial, hardening order, and WASM budgets.

Alternatives considered

  • Keep host bridges as the only extension point. Safest, but every library becomes a NodeTool core change. Bridges remain the right tool for real I/O and native code — this design does not replace them.
  • Inject packages as globals instead of imports. Avoids the AST work, but invents a second module system and still needs the same declaration, validation, loader, and delivery machinery.
  • Run WASM inside the guest. Impossible on quickjs-ng.
  • Arbitrary npm imports at runtime. Rejected; the config-only form — declared, bundled and vetted at install time, admission-probed — is the answer, not a live resolver over node_modules.
  • Trust-free guest modules. The first draft’s position; withdrawn. Guest modules share the node’s capabilities, so the model is explicit consent (v1) with capability attenuation as a later opt-in.

Limits summary

Bound Value Enforced
JS module source (authored) 256 KB pack discovery
JS module source (npm-bundled) 1 MB (validated in M-1) compiler + admission probe
WASM binary 4 MB pack discovery
Per-pack total 8 MB pack discovery
SKILL.md 16 KB pack discovery (warning-grade)
Guest eval of modules existing 64 MB / deadline / interrupt engine
WASM memory binary-declared max ≤ memoryPagesMax ≤ host ceiling (4096 pages); no shared, no imported memory (v1) binary validation
WASM signatures i32/f32/f64 only, ≤1 return, ≤8 args; no i64/v128/ref types/multi-value binary validation
WASM per-call wall clock 5 s default, pack may lower worker timeout + replacement
WASM aggregate worker pool size, per-invocation call count + concurrency + wall-clock budget host bridge
WASM imports none binary validation
WASM state none across calls (instance per call) execution model
Dynamic import() rejected everywhere validation + loader

Non-goals

  • WASI, filesystem, network, host-function or memory imports for WASM.
  • require, Node builtins, the wrapper’s compat modules, or undeclared npm packages in the guest.
  • Typed-array WASM ABI in v1 (scalar-only until the reference-module spike pins a contract).
  • Per-workflow version pinning (recorded versions warn on mismatch; resolution uses the installed version).
  • Replacing existing host bridges.

Milestones

Revised order: prove the risky mechanics first, then build outward. Each milestone lands green on its own.

M-1 — Proof and threat model

  • AST transform spike: static imports + IIFE body semantics (implicit return, top-level await, streaming, source locations) on real Code/ CodeAct corpora.
  • Loader spike: custom loader/normalizer denying node:*, compat modules, computed imports, path escapes — on Node and browser. An explicit pass/fail criterion: the wrapper’s own prepareNodeCompatibility() bootstrap imports still work while the same modules are denied to guest code, including module-cache hits. If bootstrap and guest loading cannot be distinguished reliably, the answer is to bypass or patch the wrapper’s compatibility setup, not to ship a loader that cannot hold the line.
  • Hardening-order spike: prove globals are hardened before any module evaluates.
  • One scalar WASM and one byte-oriented WASM reference module from a documented toolchain (the byte one informs the future ABI, not v1).
  • Measure bundle sizes of the real npm candidates against the 1 MB cap.
  • Document exactly what capabilities imported code can reach; test npm lifecycle-script behavior with and without --ignore-scripts.

M0 — Catalog and safe discovery

  • Zod manifest schemas; the specifier + declaration model (versions included).
  • SandboxModuleCatalog interface with injected ownership (kernel, validation, prompts, delivery, CLI); statuses separate from node-pack statuses.
  • Sandbox-only installation with --ignore-scripts, the install-mode UI, and the direct-URL/unknown-manifest policy.
  • Discovery validation: containment, symlinks, sizes, import-expression rejection, collisions; SKILL.md as warning-grade.

M1 — Guest JS end to end (feature-flagged)

Authored modules through Code node, CodeAct, validation, CLI, and server execution together — import support is not claimed until the user-facing execution and validation paths agree. Prompt strings (“no module loader”) and their drift tests change here.

Two things belong to M1 that earlier drafts deferred:

  • The one-line catalog tier and the session allowlist. CodeAct cannot generate approved imports unless its prompt advertises the session-allowed specifiers, so the strict manifest-derived one-liner ships with import execution. M5 stays the documentation milestone (full SKILL.md retrieval, package picker, Package Manager presentation).
  • The parity feature flag. Without it, a Code node using imports runs on the server and fails in the browser runner. While flagged, validation reports a rollout issue — “Sandbox package imports are unavailable in the browser runner until module delivery is enabled” — and the browser runner refuses such nodes with the same message. Nothing persistent is written: no node platform metadata is rewritten, so a workflow saved during M1 carries no stale server-only classification after M2.

M2 — Delivery parity (removes the flag)

Browser delivery by opaque module id with digest verification; Electron staging for bundled builtins; the same loading/denial fixtures running on Node and browser. The M1 feature flag is removed here — parity restored is the exit criterion.

M3 — npm compilation

The dedicated compiler module: content-addressed cache, explicit resolver conditions, scope-aware scan, QuickJS admission probe.

M4 — WASM (scalar-only)

Binary validation including the memory and signature rules, worker pool with global and per-invocation budgets, timeout-terminate-replace, instance-per-call execution, and the generated facades over a scoped per-run bridge. The typed-array ABI waits for the M-1 reference results.

M5 — Agent and UI disclosure

The one-line tier and session allowlist already shipped with M1; this milestone is the documentation layer, landing only after the untrusted-content trust handling is settled: on-demand SKILL.md retrieval, package-picker rendering, Package Manager consent language.

M6 — Bridge packs

One config-only pack per migratable library — @nodetool-ai/sandbox-csv (papaparse), -yaml (js-yaml), -xml (fast-xml-parser), -diff (diff), -zip (fflate), -html (cheerio, if the bundle admits it) — living in the monorepo under packages/sandbox-packs/, consumed only via install. This is an added import path, not a migration: the data.* bridges stay, and bridge-specific safety limits stay with them — fflate’s 50 MB decompression cap is a zip-bomb policy the 64 MB guest heap does not replicate, so the bridge remains the hardened route. Disposition of the rest is unchanged from the bridge table in the CLAUDE.md: exceljs (Node streams), turndown (DOM), format.* (no Intl in the guest), image/canvas (native), and every capability bridge stay host-side permanently.

Presence is never assumed: a missing pack fails validation with “install

", prompts advertise only installed packs, and the registry marks the bridge packs recommended. ## Release invariants Each invariant binds from the milestone that introduces its subject onward — WASM invariants from M4, browser parity from M2. - Explicit per-node declarations; no ambient npm resolution. - The loader, not static analysis, is the enforcement boundary. - Fresh guest context per invocation; fresh WASM instance per **call**. - Static size caps on everything a pack ships. - Empty WASI/network/filesystem surface for WASM. - Existing bridges retained; their safety limits not weakened by the import path's existence. - The same fixtures and contract tests on Node and browser (once browser delivery exists). - Validation before execution.