Compartments and capabilities

Compartment runs code in a fresh JavaScript realm that starts with the language and nothing else, where the only authority is what you pass through its globals grant and a timeout_ms budget the code cannot catch. This page also covers the process-wide capability modes (audit, sealed, sealed-deps) and worker-hosted compartments.

Node ships vm, which runs code in a fresh vm.Context with its own globalThis. That context shares the host's intrinsics and prototype chains, has no time budget, and the Node docs say plainly it is not a security mechanism. Cruft's Compartment is the isolation primitive vm is often mistaken for: a full separate realm, an explicit capability grant as the only bridge in, and a wall-clock budget enforced beneath the language. This page is the deep dive on that primitive, the capability model beneath the sandboxing flags, and how the two compose with workers. It builds on the framing in isolation.

Alpha (0.0.10). The compartment boundary is a first-class part of the runtime and enforced, but it has not been independently audited or hardened against a determined attacker. For genuinely untrusted code, run Cruft inside an OS-level sandbox as well; do not rely on the in-process boundary as your only defense.

A compartment is a realm

new Compartment() constructs a fresh ECMAScript realm: its own globalThis, its own set of intrinsics (Object, Array, Promise, and the rest), its own global lexical scope. Code evaluated inside cannot reach the host realm's objects through prototype chains, because it shares none; its Object.prototype is a different object than yours.

This is why a compartment needs no deny-list. There is nothing to deny; the inside starts with the language and nothing else:

const c = new Compartment();
c.evaluate("1 + 1");            // 2 (the language works)
c.evaluate("typeof require");   // "undefined"
c.evaluate("typeof fetch");     // "undefined"

The grant: globals

Authority enters a compartment in exactly one way, the globals option:

const c = new Compartment({
  globals: {
    greet: (name) => "hi " + name,
    log: (msg) => console.log("[plugin]", msg),
  },
});
c.evaluate('greet("ada")');  // "hi ada"

Each entry becomes a global inside the compartment. Because the granted values are the only bridge between realms, the grant is also the audit surface: to review what tenant code can do, read the globals object. A function you pass in closes over your realm's authority, so the discipline is to grant narrow, purpose-built functions ("append a line to this one log file"), not authority-bearing ambient objects (fs).

evaluate and its results

compartment.evaluate(source) compiles and runs a string of source in the compartment's realm and returns the completion value. Evaluation is synchronous; the string is a full program (not an expression-only mini language), so tenants can declare functions, classes, and state that persist across evaluate calls on the same compartment. A compartment is a session, not a one-shot eval.

The time budget: timeout_ms

const c = new Compartment({ timeout_ms: 50 });
c.evaluate("while (true) {}");
// cruft: evaluation error: Error: Compartment evaluate exceeded its 50 ms timeout

timeout_ms sets a wall-clock budget per evaluate call. It is a fuel meter plus a watchdog interrupt: the execution loop of Exegesis, the runtime's bytecode interpreter, carries a fuel check, and a watchdog arms when evaluation begins. When the budget expires, the next check point terminates evaluation from beneath the language. The host sees it as Error: Compartment evaluate exceeded its 50 ms timeout, but that termination is not a JavaScript exception inside the tenant, which is the point: tenant code cannot try/catch its way past it, because it never surfaces as a catchable value inside the tenant's realm.

The interrupt is observed on loop back-edges as well as exception-frame boundaries, so bare while(true){} loops with no calls and no try frame are stopped at the budget. A compartment with timeout_ms: 50 running while(true){} terminates with a timeout, not a value the tenant can catch.

What compartments are for

The pattern is always the same: code you want to run but not trust.

  • Plugins and user-supplied extensions.
  • A dependency's callback you want fenced off.
  • Evaluating model-generated code.
  • Multi-tenant execution inside one process, where per-tenant processes are too heavy.

The capability model

Beneath the CLI flags sits a real capability system in the runtime. Understanding its three ingredients, modes, attribution, and capability families, explains everything the flags do.

Modes

The process runs in one capability mode:

ModeBehavior
open (default)Full ambient authority, like Node.
auditFull authority, but every capability use is recorded.
sealedAuthority denied unless granted; ungranted use throws.
sealed-depsApplication code open; dependency code sealed.

Attribution: every operation has a caller

Each I/O operation is attributed to a module identity carrying its provenance: application code, a dependency (under node_modules), or a built-in. This attribution is what makes the interesting modes possible:

  • In audit mode, the log line names which module touched which capability:

`` file:///app.mjs stdio write(stdout) 1781981319817637 ``

  • In sealed-deps mode, the enforcement decision is per-caller: the same fs.readFile call succeeds from file:///app.mjs and throws from file:///node_modules/leftpad/index.js. The boundary comes from the package graph the installer built, not from guessing by path string alone.

Capability families

Capabilities are typed, structured objects, not booleans. The families today:

  • Filesystem: carries a path policy: full access, none, restricted to a subtree (sub_dir), and optionally read-only. So a grant can express "read anything under ./data, write nothing" rather than just fs-on/fs-off. Every filesystem entry point routes through one check, with the operation and caller attached.
  • Stdio: stdout/stderr/stdin as separate grants. Sealed mode denies even console.log until stdio.stdout is granted; this sounds strict but is what makes the audit story complete, printing is I/O.
  • Clock: beyond on/off, the clock capability supports coarsened resolution, quantizing time reads. High-resolution timers are a classic side-channel ingredient; a tenant that only deserves second-granularity time can be given exactly that.
  • Scheduler: control over timers/task scheduling.
  • Network: sealed by default in sealed mode; --allow-net-loopback selectively re-grants loopback listen authority, the common "my app serves localhost but my deps stay boxed" case.

Declared grants

A sealed program declares what it legitimately needs; denials are designed to be self-explanatory, the error names the missing capability and a grant stanza:

TypeError: stdio.write(stdout): no stdio capability granted to
module 'file:///app.mjs' (mode: sealed).
hint: add to cruft-caps.json: { "stdio": { "stdout": true } }

Grants live in a cruft-caps.json file beside your program. It lists the capability families the sealed program is authorized to use, one entry per family from the table above:

{ "stdio": { "stdout": true }, "fs": [], "net": [], "env": [], "exec": [] }

A sealed program that only prints is authorized by granting stdio.stdout; filesystem grants match the path as written (grant "./" for a relative read). Under --sealed, a program that calls console.log throws the denial above until cruft-caps.json grants stdio.stdout, and then it prints normally. All three enforcing modes, --audit, --sealed-deps, and full --sealed, are usable in production.

The workflow: audit, then seal

The intended adoption path costs almost nothing:

  1. cruft --audit app.mjs, run normally, collect the program's true capability footprint (--audit-log caps.tsv to a file).
  2. Translate the observed footprint into a cruft-caps.json grant.
  3. Run with --sealed (or start with --sealed-deps, which needs no declaration for your own code at all).

After that, a newly-compromised dependency that tries to read ~/.ssh or open a socket does not get detected, it gets a TypeError, because the authority to act was never in its hands.

Workers: compartments on threads

Pass worker: true and the compartment's realm is built on a worker thread:

const c = new Compartment({
  worker: true,
  onMessageSource: `(e) => { console.log("got: " + JSON.stringify(e.data)); }`,
});
c.send({ a: 1 });

Worker-side console.log is not currently surfaced to the parent process's stdout, so the handler above runs but prints nothing visible. To observe a worker's result today, communicate it back over a shared transport (a SharedArrayBuffer synchronized with Atomics, shown below) rather than relying on the worker's console.

Details that reveal the model:

  • onMessageSource is a string, compiled on the worker. A main-thread function value cannot cross the thread boundary, so the API takes source text instead.
  • send payloads are structured-cloned; the handler receives a Web-style event with .data. Functions are rejected at send time.
  • A SharedArrayBuffer in a payload is the one thing that crosses by reference, explicit shared bytes, synchronized with Atomics.
  • Immutable strings can be shared without copying, by handle; frozen objects and arrays are copied like any other value.

That is Imogen, Cruft's shared-heap worker architecture, in miniature: local mutable heaps, explicit shared bytes, mediated immutable sharing. Each worker realm has its own heap and collects independently, which is why worker counts can scale to the thousands with bounded memory.

Limitations

  • Worker stdout is not wired through. A worker realm's console.log does not reach the parent process's stdout today; return results over a shared transport instead.
  • Alpha maturity. This is 0.0.10. The boundaries here are enforced and tested against escape attempts, but interop breadth and fuzz coverage are narrow. For adversarial code, pair the in-process boundary with an OS-level sandbox.