Isolation

Cruft's three tools for containing code: Compartments that run a region in an empty realm with only the abilities you grant, the CLI flags --audit, --sealed, and --sealed-deps that constrain a whole process from outside, and Imogen workers that run in parallel with per-worker heaps instead of copying everything across.

Node's isolation toolkit has three pieces, each with a known limitation.

There's vm.createContext, which looks like a sandbox and is not one: the Node docs say so directly, because the context shares intrinsics with the outer realm and a determined script can walk back out. There's worker_threads, a real boundary but a heavy one, a separate heap you copy everything across. There's the process itself, child_process and Docker and seccomp, which is real isolation and coarse, all-or-nothing, and a pile of container config to constrain at all. Underneath all three sits the thing none of them fixes: a dependency three levels down runs with your full authority the moment it's imported.

Cruft's isolation covers the same three scales, with the gaps closed, because the engine is Cruft's own and can cooperate with isolation instead of leaking around it.

ScaleThe Node gapIn Cruft
A region of codevm is not a real boundaryCompartments
The whole processDocker's containment needs the containerSandboxing flags
Parallel executionworker_threads are expensiveImogen workers

Compartments

A Compartment evaluates code in a fresh realm with its own globalThis, and the difference from vm.createContext is the one that matters: it starts empty. Whatever the code inside can reach, you put there. Nothing else is present to escape to.

const c = new Compartment({ globals: { greet: (name) => "hi " + name } });
c.evaluate('greet("ada")');   // "hi ada"
c.evaluate('typeof require'); // "undefined": never granted, so simply not there

vm shares the outer globals and relies on you fencing them; a Compartment grants nothing. This is capability security in its plainest form: authority is only what you hand in. There is no "escape the sandbox" setting to get wrong, because there is no ambient authority to escape to.

A Compartment can also carry a wall-clock budget, timeout_ms. The interrupt fires beneath the language, so the code inside cannot try/catch its way past it, a hostile or runaway loop is stopped rather than negotiated with, which vm's timeout option has never reliably done.

Use Compartments where you'd want vm to be safe: plugins, tenant code, dependency callbacks you don't fully trust, or evaluating code a model just generated.

Sandboxing

Where Compartments isolate from inside the program, the CLI flags constrain the process from outside without touching a line of code, the containment Docker provides, at a finer grain and with no image to build:

  • --audit runs normally and records every I/O capability the program uses (down to a console.log showing up as a stdio write) along with the module that caused it. It answers the question about your own dependency graph: what does this actually touch?
  • --sealed withholds I/O entirely. An ungranted operation throws a descriptive error naming the capability. A program declares its legitimate needs in a grant file (fs/net/env/exec/stdio allow-lists) and Cruft grants exactly those, including the stdio streams a sealed program needs to print.
  • --sealed-deps is the pragmatic middle: your own code runs normally, and everything under node_modules is denied I/O. A compromised transitive dependency cannot read your files or phone home, no matter what its postinstall intended.

The order is the method: audit to learn the real footprint, then seal with the capabilities it legitimately needs. That turns the supply-chain problem inside out, from "detect the malicious behavior" (not reliably possible) to "the malicious behavior has no authority to act with" (which is structural).

Imogen workers

worker_threads forces a choice between two shapes: a separate isolate per worker (safe, expensive to spawn, and you copy everything across), or a single mutable heap shared by every thread (fast, and a data-race generator). Cruft's worker model, Imogen, takes a third shape. It is surfaced through the node:worker_threads module (new Worker(...), postMessage) rather than a global Worker or Imogen constructor:

Mutable object identity is local.
Shared bytes are explicit.
Immutable strings are shared through a handle; everything else is copied.

Each worker has its own heap and its own independent garbage collection. Precisely two things cross a worker boundary without copying:

  • Explicit shared bytes, SharedArrayBuffer and views over it, synchronized with Atomics. Mutable, but only bytes, and only where you asked for it, the same primitive as Node.
  • Immutable strings, interned in a shared arena and passed by handle. Safe to share for the reason that makes them safe: nothing can mutate them. Frozen objects and arrays are not shared this way; they are copied like any other object.

Everything else is rebuilt in the destination worker by structured clone, the same postMessage semantics as Node, no longer the only option. Per-realm heaps keep memory bounded and collections independent, so many workers can run at once, while ordinary objects stay data-race-free by construction.

They compose

The three stack. A sealed process can audit itself; a sealed process can spawn Compartments with per-evaluation grants and timeouts; worker Compartments inherit the same capability discipline. In Cruft, isolation is the ground everything runs on, with the defaults opened up for convenience. Node draws the line the other way: isolation is a layer added on top, and code runs with full authority until you add it.