learn / 02·2 — under nexus · capabilities

what could thisDOat worst?

The Nexus lesson promised that question has a short written answer. This is the answer: a profile — three numbers and a word list — and a host that turns the word list into the only imports that exist. Capabilities here aren't checked at call time. They're linked, or absent, before a single instruction runs.

capabilities12 min read
A small auditor with a clipboard before three monumental bright walls — one of stone, one a colossal sundial, one a gate with exactly four keyholes — under a clear desert sky, 1970s sci-fi style

the worst-case QUESTION

Every security review of every plugin system eventually arrives at the same question: if this thing turns hostile, what's the worst it can do? And most sandboxes answer with a filter — an allowlist, a permission check, a proxy in front of a capability that still exists underneath. Filters have to be audited. Filters have bypasses. The history of sandbox escapes is mostly the history of someone finding the capability behind the check.

The Nexus lesson made two claims about this ecosystem: capabilities are granted, not inherited, and the worst-case question has a short written answer. This page is the mechanism behind both claims. The answer turns out to be genuinely short — the entire policy is one map literal in one file, four entries long, and everything else on this page is how the engine makes those four entries true by construction rather than by checking.

a profile is three WALLS

ca·pa·bil·i·ty /ˌkæ·pə·ˈbɪl·ə·ti/ noun

1. a named power the host grants a sandboxed workload at dock time — one word in a profile, turned into the only host imports that exist. An ungranted capability is not denied; it does not exist.

A profile bundles three values, and each one is a different kind of wall. Memory is space — grow past it and the store traps. Timeout is time — a wall-clock budget per component call, the runaway trap. Caps are reach — the words the host links imports from. That's the whole shape: %{memory, caps, timeout}, four times, in runtime/host/policy.ex. There is no other policy state — no DSL, no config service, no per-tenant override table. Here it is, with the real literals:

profilememorytimeoutcaps
:minimal64 * 1024 * 1024 — 64 MB5_000 msvfs commands exec kv secrets queue tcp udp tls
:network128 * 1024 * 1024 — 128 MB30_000 msminimal + net llm browse
:posix256 * 1024 * 1024 — 256 MB60_000 msnetwork + posix parallel
:compute64 MB5_000 msvfsonly

Read it as a ladder. Minimal to network adds exactly three words — net llm browse — plus 64 MB and 25 seconds. Network to posix adds exactly two — posix parallel — plus 128 MB and 30 seconds. And :compute is the restrictive demo rung: pure compute plus an ephemeral VFS, no exec, no durable kv, no net. Two details in the word lists are deliberate. exec and kv are dedicated least-privilege caps, split out from the broader commands and vfs — so a future profile could grant durable storage without command-spawn. And unknown profile atoms fail closed: ask for a profile that doesn't exist and the lookup silently resolves to :compute — the vfs-only rung, least privilege, not the broader :minimal — a behavior pinned by a test, not just intended.

A running engine will tell you which walls it's behind. The introspection shape is small on purpose:

Workbooks.Instance.info("my-workbook")
#=> %{id: "my-workbook", profile: :network,
#     caps: ["vfs","commands","exec","kv","secrets","queue",
#            "tcp","udp","tls","net","llm","browse"],
#     calls: 42}

That's the security review, pre-written: a profile name, a word list, and a call count. The rest of this page is what makes each word real.

granted means LINKED

Here's the headline idea, the one that separates this from an allowlist. When a component docks, the host doesn't install a guard in front of every capability. It builds the imports map — the set of host functions the component can name at all — directly from the cap list. The base map contains exactly one entry, session-info, always granted, read-only identifiers. Then one reduce over the caps merges in the rest: vfs brings vfs-query, commands brings run-command, llm brings llm-complete, browse brings browse-fetch, parallel brings run-command-many. Any cap word with no component-lane binding falls through and merges nothing.

flowchart LR
  caps["profile cap list
vfs · commands · llm · …"] base["base imports map
session-info — always"] reduce["one reduce
each cap merges its import"] map["the imports that exist
vfs-query · run-command · llm-complete"] ok["component imports ⊆ map
instantiates — then runs"] fail["component imports one more word
fails to instantiate — never runs"] caps --> reduce base --> reduce reduce --> map map --> ok map --> fail style map fill:#aee5c2,stroke:#121316,stroke-width:2.5px style ok fill:#13d943,stroke:#121316 style fail fill:#f3c5a3,stroke:#121316 style caps fill:#ffffff,stroke:#121316 style base fill:#ffffff,stroke:#121316 style reduce fill:#fbfaf6,stroke:#121316

Now the consequence, straight from the engine's own interface file: a component that imports a cap its profile doesn't grant fails to instantiate. The check happens at link time, before execution — there is no call to intercept, because the function the component asked for was never linked into its world. That's enforcement by construction: not an if-statement that says no, but an absence the workload cannot route around. The fork in the diagram is the entire gate.

Two smaller constructions ride along, same philosophy. The session info a component can read holds its id, tenant, and profile — the working directory is deliberately not folded in, so session-info can never leak a host path. And stdio is not inherited — stdin, stdout, stderr are all off. What the component can see is built, line by line, from what it was granted. Everything else isn't forbidden. It's missing from the membrane.

the wall, not the METER

The first number in the profile is space. Each profile's memory cap becomes a %Wasmex.StoreLimits{memory_size: cap} on the store — wired at instance start in all three dock lanes, the same constant from the same map. A workload that tries to grow its memory past the cap doesn't get throttled or swapped. The store traps.

What happens next is the part that matters for everyone else on the engine: the trap surfaces as an error tuple to the caller, and the supervisor decides — restart, retire, report. The BEAM survives; the neighbors never notice. A memory bomb in one workbook is a return value in one process, not an out-of-memory event on the machine. That's the two-walls story from the parent lesson doing its job at the resource layer.

One honesty note, because this page deals in evidence: the trap-on-grow behavior is asserted in the engine's own module docs — which cite a validation log this repo no longer carries — and wired in three places, but it does not have a dedicated regression test the way the network gate does. The claim is the design's; the test suite's strongest evidence lives one section down and two sections down.

the runaway TRAP

The second number is time. Component calls don't run on a BEAM scheduler — they run on a tokio thread inside wasmtime, which is exactly why an infinite loop in a component can't wedge the engine's event loop. The profile's timeout is a wall-clock budget for one component call, passed straight into stock Wasmex — no fork — and the overrun is trapped at the boundary. The entire mechanism is four lines:

defp capped(s, fun, args) do
  Wasmex.Components.call_function(s.pid, fun, args, s.timeout)
catch
  :exit, {:timeout, _} -> {:error, :cpu_timeout}
end

A timeout argument and one catch clause. The default budget comes from the profile — 5 seconds on :minimal, 60 on :posix — and is overridable per instance at start. The engine ships a live demo of the trap, and its numbers are worth reading:

{:ok, _} = Workbooks.Instance.Supervisor.start_instance("runaway", bytes,
             policy: :minimal, timeout: 800)
Workbooks.Instance.call("runaway", "run", ["spin"])
#=> {:error, :cpu_timeout}        # trapped in ~800ms

{:ok, _} = Workbooks.Instance.Supervisor.start_instance("healthy", bytes, policy: :minimal)
Workbooks.Instance.call("healthy", "run", ["after"])
#=> {:ok, ...}                    # the BEAM never wedged
sequenceDiagram
  participant C as caller — a BEAM process
  participant I as Instance GenServer
  participant W as wasmtime — tokio thread
  C->>I: run("spin") — budget 800ms
  I->>W: call_function(…, timeout: 800)
  Note over W: spins, forever if it could
  I--xC: {:error, :cpu_timeout} — at ~800ms
  C->>I: run("after") — a second, healthy instance
  I->>C: {:ok, …} — answered immediately
  

Read the sequence as a story: the runaway gets exactly its budget, the caller gets an error tuple instead of a hang, and the very next call on the engine is answered immediately. The misbehaving code never gets a veto over the host's responsiveness.

There is a second CPU mechanism, and the division of labor is a Wasmex 0.14 fact: fuel — deterministic instruction metering — is only surfaced on core-module stores, not component stores. So fuel-capped core modules are how the package manager caps tool modules (the demo sets set_fuel(store, 5_000_000) and an infinite loop traps), and the wall clock is the trap for components. Two mechanisms, one per lane, by constraint rather than preference.

the one network SWITCH

The third wall is reach, and its sharpest edge is the network. Whether a workload gets an ambient network stack at all is one derived boolean:

def allow_http?(profile) do
  caps = caps(profile)
  "net" in caps or "browse" in caps
end

Only :network and :posix qualify. And the punchline is what that boolean controls. In stock wasmex, the single WasiP2Options.allow_http flag gates three things at once in the store builder: the wasi:http import being linked at all, the store inheriting the host socket pool, and DNS lookups. False means no http import linked, no socket pool inherited, no name resolution — a non-network component cannot reach the host network stack at all. Same philosophy as the imports map: the switch doesn't filter requests, it removes the network from the universe.

flowchart TD
  q["allow_http?(profile)
= net in caps or browse in caps"] on["wasi:http linked
socket pool inherited
DNS allowed"] off["no http import
no socket pool
no DNS — no stack at all"] q -- ":network · :posix — true" --> on q -- ":minimal · :compute — false" --> off style q fill:#aee5c2,stroke:#121316,stroke-width:2.5px style on fill:#ffffff,stroke:#121316 style off fill:#fbfaf6,stroke:#121316

Now the war story, because this page's credibility rests on it. A security pass — finding number seven — caught that instance startup hardcoded allow_http: true. Every instance, even the most restrictive :minimal profile, got full outbound network. The fix is the derivation above, plus a regression suite that pins it from five directions: minimal refuted, network and posix asserted, an unknown profile shown to fail closed to the vfs-only :compute rung, a property test that the boolean always agrees with the cap list — and an instantiation smoke test that runs a real probe component under the tightened :minimal and confirms it still works, proving the fix isn't over-broad. That suite is why this section gets to use the word wall without blushing.

One precision note, and it matters. :minimal's cap list does include tcp udp tls. In the component lane those words bind nothing — a minimal component has zero network imports of any kind. In the core-module dock lanes, they bind brokered one-shot calls: the guest hands the host a destination and bytes; the host resolves the name once, refuses internal and non-routable addresses, and connects to the pinned IP — resolve-then-pin, which defeats DNS rebinding — under per-principal rate limits and revocation, a 1 MB response cap, a 10-second timeout, and an audit log of denials. Brokered is not ambient: the guest never opens a socket, never resolves a name, never sees the pool. A wall, plus a guarded mail slot — and the networking lesson owns the full story of the slot.

three docks, two gating STYLES

depth rung · skippable — how one policy maps onto three execution lanes

The profile table is the only policy, but three different kinds of workload consume it, and they gate in two different styles:

lanewho runs theregating stylewhat denial looks like
component lanewasm components against the engine's WIT worldpresence — imports built from capsfails to instantiate, before execution
rust dockcompiled-Rust core modulespresence — host fns merged per caprequests an ungranted import → instantiation fails
js dockQuickJS/Javy commandsbehavior — all fns bound, flags per profiledenied call returns −1 → null in JS

The two presence lanes are the construction story again. The rust dock merges its import map per cap word: an ambient pair — clock and log — always; the egress functions only when allow_http? passes; and vfs, exec, kv, secrets, queue, tcp, udp, tls each merged only when the matching word is granted. A non-net Rust program that asks for an ungranted import never starts.

The JS lane is the honest exception, and worth understanding rather than glossing. The Javy harness always links every host function — but each one consults a flag computed once per run from the profile (allow_http, exec, kv, secrets, tcp, udp, tls), and a denied capability returns −1 at the boundary, which the JS shim surfaces as null. Same policy, same walls — but gated by behavior at the host boundary rather than by absence at link time. The verdict of the table: one profile map, two enforcement styles, and in every lane the flag is computed by the host from the same four-entry literal.

what each word BUYS

depth rung · skippable — the cap list, word by word

Each cap word is a contract: what it binds, and what the host keeps. The pattern to watch for — in every row, the sensitive thing stays on the host side of the membrane:

capbindsthe guarantee
vfsvfs-querySQL against the instance's own SQLite VFS — nobody else's
commandsrun-commandtrust-aware exec — third-party commands run at :node tier, a separate VM; every call logs one step to _steps.jsonl
exec / kvdedicated host fnsleast-privilege splits from commands/vfs — durable storage without command-spawn is grantable
secretshost_signHMAC-SHA256 with a named secret — the guest can sign with it, never read it
llmllm-completethe host holds the key — the component never sees it
browsebrowse-fetchhost owns egress — the component never opens a socket
netambient http (core lanes)SSRF floor per URL; batch fetch hard-capped at 64 URLs per call, 16 concurrent, 15 s each
tcp udp tlsbrokered one-shotsresolve-then-pin, internal addresses refused, 1 MB responses, rate-limited, revocable, audited
parallelrun-command-manyfans one command over N inputs on the BEAM — the parallelism single-threaded wasm can't have
posixdock lanes onlythe word the :posix profile is named for; like every unwired word, it binds nothing in the component lane

Three rows deserve a second look because they're the same idea three ways. secrets lets a guest use a credential without ever holding it. llm lets it complete prompts while the API key never crosses the membrane. kv namespaces by a tenant the dock captured at start — the guest never names its tenant, so it can only ever touch its own keys. Grant the verb, keep the noun. The telemetry lesson picks up the _steps.jsonl shape; the networking lesson owns the brokers in depth.

profiles in the WILD

Who picks the profile? The host, at dock time — it's an option at instance start, never a property of the file. A workbook cannot grant itself anything:

Workbooks.Instance.Supervisor.start_instance(id, bytes, policy: :network)

In practice the rungs map to roles. Agents run under :network — they need llm and egress to do their jobs. Builds and resumed sessions start at :minimal. And :compute is the pure-function rung — a demo or an untrusted transform that needs a scratch disk and nothing else.

For toolkit authors, the cap list is a manifest line — #+CAPS: vfs commands net — and the verifier cross-checks it against the profile table two ways: every declared cap must be grantable (known to some profile), and at least one profile must grant the whole declared set — otherwise the toolkit can never instantiate anywhere. A pass reports the minimal granting profiles; a third-party consumer grants the declared set explicitly on install.

flowchart LR
  m["#+CAPS: vfs commands net"]
  v["wb toolkit verify"]
  ok["caps declared, all grantable
granted by profile(s): network, posix"] bad["no single profile grants the set
— can never instantiate"] m --> v v --> ok v --> bad style v fill:#aee5c2,stroke:#121316,stroke-width:2.5px style ok fill:#13d943,stroke:#121316 style bad fill:#f3c5a3,stroke:#121316 style m fill:#ffffff,stroke:#121316

One axis this page deliberately does not cover: profiles answer what can it reach; the isolation tier ladder answers where does it run — first-party trust runs in-instance, third-party at :node tier in a separate VM, surfaced by wb isolation. Two orthogonal dials. Don't conflate them — the sandboxes lesson owns the second one. And if you want to poke the walls yourself, run an engine and call Instance.info — the answer comes back as the shape you saw in the definition section.

HONESTY

Where the walls are thinner than the prose, in order of consequence.

The runaway thread isn't freed yet. The wall-clock cap traps the call — the caller gets its error, the BEAM stays responsive — but the worker thread the runaway was spinning on keeps spinning. Epoch interruption, the refinement that also frees the thread, is filed, open work. The trap protects the caller and the engine, not the thread pool. Relatedly, one stale module doc still says an instance carries no CPU cap at all — the four-line capped/3 beneath it disagrees, and the policy module's framing is current.

Fuel is core-module-only. Deterministic instruction metering exists, but Wasmex 0.14 only surfaces it on core-module stores — so components get the wall clock, and only package-manager tool modules get fuel. Two lanes, two mechanisms, by constraint.

Four fixed profiles, no custom ones. You cannot compose your own cap set — you pick a rung. That's a real limitation and also the point: the worst-case answer stays short because the menu does.

Fail-closed is silent. An unknown profile atom resolves to :compute — the vfs-only least-privilege rung, the safe direction, and tested — but nothing warns you that your typo'd profile name became the bare scratch-disk gate.

Minimal is not zero-network in every lane. Say it precisely: the wall removes the ambient stack — no http import, no socket pool, no DNS. Core-module guests under :minimal still get the brokered, pinned, capped, audited one-shots. No mediated bytes would be a different, stronger claim, and this page doesn't make it.

The evidence is uneven. The network gate has a five-way regression suite. The memory trap has module docs and three wiring sites. Both are real; one is proven harder than the other, and you deserve to know which.

questions people actually ASK

Can I define my own profile?

No — there are four, and they're a map literal in the engine, not a config surface. That's deliberate: the worst-case question has a short written answer precisely because the answer can't grow. If a toolkit's declared caps fit no profile, the verifier says so before anything runs.

Does :minimal really have zero network?

In the component lane — yes, literally: no http import linked, no socket pool inherited, no DNS. In the core-module lanes, minimal guests get the brokered one-shots only: host-resolved, pinned to the resolved IP, internal addresses refused, 1 MB-capped, rate-limited, audited. Ambient network: none. Mediated bytes: through a guarded slot.

What happens at the memory cap — error or kill?

A trap, not an OOM kill. The store refuses the growth, the call returns an error tuple, and the supervisor decides what happens to the instance — restart, retire, report. The machine and the neighbors never feel it.

Is the timeout per call or per lifetime?

Per component call. Each call gets the profile's wall-clock budget — 5, 30, or 60 seconds, overridable per instance at start — and an overrun becomes {:error, :cpu_timeout} for that call. The instance itself can live as long as its supervisor likes.

Who picks the profile — the workbook or the host?

The host, at dock time, as an option on instance start. The file never grants itself anything — a toolkit can declare what it needs in #+CAPS, but declaration is a request the verifier checks and the installer grants, not a power.

Why do exec and kv exist next to commands and vfs?

Least privilege at the word level. commands and vfs are broad; exec and kv are the dedicated, narrow versions — so a profile can grant durable storage or supervised execution without granting general command-spawn. Splitting the words is what makes the narrow grant expressible at all.

keep GOING

This page is the mechanism behind one sentence on the parent page. The neighbors carry what it deliberately deferred.