JS² Blog

Sandboxing untrusted JavaScript Modules

JS² is a compiler that turns JavaScript and TypeScript into WebAssembly with no JS engine in the output, so a dependency, a plugin, or code no one has read can run in a sandbox that reaches nothing it was not explicitly handed.

The problem

Every npm install is an act of trust. A typical application pulls in hundreds of packages, and each one runs with the full rights of the process: the filesystem, the network, environment secrets, every other module's objects. When one of them is compromised, and one is every few months, it owns everything. The goal here is to run untrusted JavaScript where that outcome is structurally impossible, not merely discouraged by policy.

In the age of generative AI this scales by orders of magnitude. A lot of the code we ship now was never reviewed, written faster than anyone can read it. "Someone probably reviewed this" was always optimistic; at machine speed it's wishful thinking. We need to contain the problem by limiting the blast radius of untrusted code at a granular level, and make code modular and reusable so that less code has to be generated, reviewed, and maintained in the first place. Small WebAssembly modules also make review, by a human or a machine, far easier: fine-grained modularization keeps each piece small and self-contained instead of one giant one-off monolithic codebase. That same fine granularity, along with portability across hosts and language-independent module APIs, makes these modules far more reusable: write a piece once and compose it anywhere, in any language. WebAssembly is a good substrate for this, being lightweight, portable, and sandboxed by default.

The idea in a nutshell

JS² compiles your JavaScript or TypeScript ahead of time to WebAssembly. Anything the compiler can resolve at build time becomes plain Wasm instructions, with no interpreter shipped alongside. (WebAssembly is, in effect, machine code for a portable virtual CPU that every browser and many server runtimes already run.)

The security property falls out of how Wasm works. A compiled module starts with zero ambient authority : it can reach a filesystem, a socket, or a global only if the host passes it in as an explicit import. Isolating one module from the next is the starting point, not something bolted on afterward.

Where it actually stands

Progress is measured against the full Test262 suite, the same conformance corpus the browser engines certify against, with nothing cherry-picked. There are two numbers, because they answer two different questions:

ECMAScript conformance over time

The project started at zero in early March and the numbers climb every week. Recomputed on every merge and published live, so these charts read from the current data.

Host mode is the number for sandboxing inside a browser or a Node app, where a JS runtime is around anyway. Standalone mode is the harder goal: pure Wasm, no JavaScript in the loop at all, for WASI, edge, and embedded targets. That is the real research frontier, and it is still incomplete. This is a research prototype, not production-ready.

What makes it different

Compiling JavaScript to Wasm has been tried before, and the recurring move is to give something up. Some approaches accept only a small, statically-typed subset of the language. Others define a new Wasm-shaped language that looks like TypeScript but drops the prototype chain, dynamic values, and closures, which means existing code and npm packages have to be rewritten rather than compiled. Others ship an entire JS engine inside every module; still others hand-write a garbage collector in linear memory, or skip garbage collection altogether and simply leak. And across all of them, none targets the full ECMAScript language, or comes close to passing it. JS² takes none of these shortcuts: it compiles the actual language, unchanged, so existing code and packages are the input rather than a porting target; it lowers objects to typed WasmGC structs so the host runtime owns the heap; and full ECMAScript conformance, measured against the complete Test262 suite, is the explicit target. What always made that direct route hard was scope: full ECMAScript is thousands of built-ins and edge cases that have kept engine teams busy for a decade.

Two things changed the calculus. WasmGC , which browsers shipped over the last two years, lets the compiler use the runtime's own garbage collector instead of simulating a heap, which keeps modules small and makes the approach quick to validate inside a JS host. And frontier language models make it feasible to work through the long tail of the spec at a pace that would otherwise be out of reach. That second point draws the most skepticism, so it is worth being explicit about.

There is an architecture goal underneath it. The compiler is built around a typed intermediate representation (IR) : a single checked layer between the source and the Wasm where it performs its own type analysis. The aim is to reason about the JavaScript that TypeScript cannot: plain code with no annotations, and the cases where TypeScript is unsound and its declared types do not match what actually runs. Annotations are treated as hints to be verified, not as ground truth; the IR recovers real, flow-sensitive types from the code itself, so each construct is lowered once and efficiently, and one description can target more than one Wasm backend (WasmGC today, a linear-memory backend in development). It is the difference between a principled core and a pile of per-feature special cases.

How it's built

The compiler is built day to day by a small team of AI agents, directed by me. One grooms the backlog, one writes an implementation spec before any code is written, one runs the sprint and the merge queue, and several write the code, each in an isolated git worktree. If that reads like an ordinary software team, that is the point: no special process was invented for AI. Spec-first issues, test-driven changes, code review, a merge queue. The process is what makes the output reliable, whoever or whatever writes it.

The bar for merging is deliberately high. Every pull request runs the full 48,088-test suite twice, once per mode, plus about 3,400 hand-written tests that compare compiled Wasm against real JavaScript behavior. That is roughly 100,000 checks before anything lands, and the merge queue re-runs them on the merged result so a green-looking PR cannot quietly break the tree. It is the same stance as the compiler itself: don't trust the author, verify the output.

Why not an existing sandbox?

It depends on the weight class:

Approach Isolation boundary Weight Runtime speed Per-dependency?
Container / microVM OS process / kernel Megabytes Native Too heavy
V8 isolate The engine itself Medium JIT (fast) Sometimes
Embedded engine
Javy, StarlingMonkey
Module, engine inside Engine in every binary Interpreted* Yes, but big
JS² (compiled) The Wasm module Kilobytes Compiled Wasm Yes

Containers and microVMs are the right tool for isolating whole tenants, but far too heavy to wrap around each of the hundreds of dependencies inside one app. V8 isolates are lighter, but the boundary is the engine itself, so every isolate shares one process and inherits its attack surface. Embedding QuickJS or SpiderMonkey (what Javy and StarlingMonkey do, and they are good tools) keeps the whole engine in the binary, and since Wasm has no JIT, that engine interprets the code, often an order of magnitude slower than compiled Wasm. *compute-heavy code pays the most.

How to contribute

Getting from research prototype to production compiler is the kind of work that scales with more hands, human or AI:

The playground compiles JavaScript and TypeScript to Wasm in the browser, and the issue queue is open. Contributions are welcome, human or agent.

Everything is Apache 2.0 with the LLVM exception.