The host realm
The host realm is the one privileged realm your program runs in, holding all real authority (files, network, process, timers) and the only place that can create a Compartment or grant a capability into one. This page explains how authority flows down by explicit grant, how granted values cross by reference or by copy, and why the boundary holds against the code inside.
In Node, one global object carries all authority, and every module in the process shares it: any file can reach fetch, process, or require because they all sit on the same global. Cruft splits execution instead. Your program runs in one privileged host realm, and everything else runs in compartments: separate realms that start with the language and nothing else. The host realm is the only place real authority lives, and the only thing that can create a compartment or grant a capability into one.
Alpha (0.0.10). The host-realm boundary is a first-class part of the runtime, but it has not been independently audited or hardened against a determined attacker. For genuinely untrusted code, run Cruft inside an OS-level sandbox (container, VM, seccomp) as well; do not rely on the in-process boundary as your only defense.
The realm your program runs in
When cruft runs your entry file, your code executes in the host realm. It is an ordinary ECMAScript realm with the full runtime attached: require and import, fetch, process, node:fs, timers, all of it. Nothing is taken away by default, because the host realm is your own code and you are trusted.
new Compartment() builds a fresh realm alongside it. The new realm gets its own globalThis and its own copies of the intrinsics (Object, Array, Promise, and the rest), and its global holds the language and nothing more.
// host realm: full authority is present
import { readFile } from "node:fs/promises";
typeof fetch; // "function"
const c = new Compartment();
c.evaluate("typeof require"); // "undefined"
c.evaluate("typeof fetch"); // "undefined"
c.evaluate("typeof process"); // "undefined"
Authority only flows down
The relationship between the host realm and a compartment is fixed in one direction. Authority flows down, by explicit reference, from the host realm to a child. It does not flow up, it does not flow sideways, and nothing crosses implicitly.
- Down, and only what you name. The single bridge into a compartment is the
globalsgrant on its constructor. A value you do not pass in is not present inside. - Not up. Code in a compartment cannot mutate the host realm's intrinsics or reach its globals.
- Not sideways. Two compartments created by the same host share nothing; neither can see the other's globals or state.
The host is the single point from which authority radiates. A capability the host realm does not hold, it cannot pass down, which is why the process-wide sandbox described below can constrain the host itself.
Grants, and the form each value takes
A grant is the globals object you hand the constructor. Each entry becomes an own property of the compartment's globalThis. What matters is that two kinds of value cross the boundary in two different forms.
A function you grant crosses by reference. It keeps its host-realm identity and runs in the host realm when the compartment calls it. This is the membrane: the tenant reaches through the host rather than holding the authority itself. The function is your code, so you decide exactly what it does and what it is scoped to.
let calls = 0;
const c = new Compartment({
globals: { bump: () => (++calls) },
});
c.evaluate("bump(); bump()");
calls; // 2 — the grant ran in the host and changed host state
A plain object or array you grant is copied. The compartment receives its own deep copy, in its own realm's ownership; mutations it makes are not visible to the host.
const items = [1, 2, 3];
const c = new Compartment({ globals: { items } });
c.evaluate("items.push(999)");
items; // [1, 2, 3] — the tenant mutated its copy, not yours
Within a single object you pass, the two rules combine: data fields are copied, and any function fields cross by reference as membranes. This is why the discipline is to grant narrow, purpose-built functions ("append a line to this one log file") rather than authority-bearing objects: handing over fs hands over its live methods. The completion value of evaluate crosses back into the host the same way, so narrow what you return as deliberately as what you grant.
The grant surface is therefore the whole audit surface. To review everything a tenant can reach, you read one object: its globals. There is no ambient set of powers to also account for.
Nothing crosses that you did not grant
A fresh compartment has no ambient authority, and it gets there by construction rather than by a deny-list. The compartment's global is built as a keep-list: the ECMAScript intrinsics, plus the entries you granted, and nothing else. require, fetch, process, console, and the node:* builtins are simply never installed on it. There is no list of dangerous names to maintain, because the inside starts empty and authority is added, never subtracted.
The boundary holds against the code inside
Each compartment has its own intrinsics, distinct objects from the host realm's. A tenant that reassigns a prototype writes to its own copy; the host's is untouched.
const c = new Compartment();
c.evaluate("Array.prototype.hacked = 1");
[].hacked; // undefined in the host
Even where a compartment might otherwise obtain a handle to a shared built-in, the first write is transparently redirected onto a realm-local copy, so a child realm can never get a mutable reference to one of the host's intrinsics. From the host realm's side, this is the guarantee that running untrusted code cannot poison the realm that spawned it, and cannot leak into a sibling.
The host is trusted; compartments are not
The trust model is asymmetric on purpose. The host realm is where your own code lives and where the boundary is decided: every choice about what a compartment may do is a line of host code that grants it. A compartment cannot grant itself more than it was given.
This means the soundness of a boundary is a property of the host, not the child. The runtime enforces the channel, that the only things crossing are grants and return values. It does not decide narrowness for you. A host that grants a scoped function has handed over one lawful operation; a host that grants fs has handed over the filesystem. Both use the same channel; the difference is a judgment the host code makes.
The time budget runs beneath the language
A compartment's timeout_ms is enforced below JavaScript by Exegesis, the runtime's bytecode interpreter. The budget is a fuel meter checked on every loop back-edge and call, backed by a watchdog: when it expires the interpreter stops at the next check. The interrupt is not a JavaScript exception, so there is no catch inside the language that can trap it, and a runaway or hostile loop with no surrounding try is stopped just the same.
const c = new Compartment({ timeout_ms: 50 });
try {
c.evaluate("try { while (true) {} } catch (e) { 'swallowed' }");
} catch (e) {
// the host catches it as an ordinary error;
// the tenant's inner catch never ran
}
Uncatchable inside, catchable outside: the tenant cannot defeat the deadline, and the host caller gets a normal error it can handle. A compartment running under a budget stays interpreted rather than JIT-compiled, so there is always an interpreter checkpoint at which to stop it.
The host realm is not unbounded
Holding all authority does not make the host realm all-powerful from the outside. Cruft treats I/O as a capability, and the process-wide modes constrain the host realm's own access without changing a line of source. --audit records every capability a program touches. --sealed-deps keeps your first-party code's normal access but denies I/O to everything under node_modules, so a compromised dependency cannot quietly reach the disk or network. --sealed denies all I/O until a program declares what it needs, granted through a cruft-caps.json beside it. So the boundary works at two scales: a compartment isolates a region of code from the inside, and the sandbox modes confine the whole process, host realm included, from the outside. See the capability gateway.
On another thread
A compartment created with { worker: true } runs on its own OS thread with its own realm and its own heap. Because nothing mutable is shared across the thread, values move between the host realm and a worker by structured copy, the same message-shaped transfer postMessage uses, rather than by shared reference. Functions and other non-cloneable values do not cross; a worker holds only the data it was sent and a channel back to the host. The Imogen workers page covers the threaded model in full.
Limitations
- Alpha, not independently audited. The one-way boundary here is the design and the enforced behavior, but it has not been reviewed by external security researchers or hardened against a determined attacker. For adversarial code, add an OS-level sandbox around the process; do not rely on the in-process boundary alone.
- A data grant is a copy, not a channel. Because a granted object or array is deep-copied into the compartment, the host does not see the tenant's changes to it, and the tenant does not see the host's. When you need shared, observable state, pass a function (a membrane), not an object.
- The host decides scope. The runtime guarantees the only bridge is
globalsand return values; whether a particular grant is narrow enough is a judgment that stays in host code. - Across threads, only cloneable data crosses. A worker compartment receives structured copies; functions and non-cloneable values cannot be sent to it.