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
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:
| profile | memory | timeout | caps |
|---|---|---|---|
| :minimal | 64 * 1024 * 1024 — 64 MB | 5_000 ms | vfs commands exec kv secrets queue tcp udp tls |
| :network | 128 * 1024 * 1024 — 128 MB | 30_000 ms | minimal + net llm browse |
| :posix | 256 * 1024 * 1024 — 256 MB | 60_000 ms | network + posix parallel |
| :compute | 64 MB | 5_000 ms | vfs — only |
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:
| lane | who runs there | gating style | what denial looks like |
|---|---|---|---|
| component lane | wasm components against the engine's WIT world | presence — imports built from caps | fails to instantiate, before execution |
| rust dock | compiled-Rust core modules | presence — host fns merged per cap | requests an ungranted import → instantiation fails |
| js dock | QuickJS/Javy commands | behavior — all fns bound, flags per profile | denied 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:
| cap | binds | the guarantee |
|---|---|---|
vfs | vfs-query | SQL against the instance's own SQLite VFS — nobody else's |
commands | run-command | trust-aware exec — third-party commands run at :node tier, a separate VM; every call logs one step to _steps.jsonl |
exec / kv | dedicated host fns | least-privilege splits from commands/vfs — durable storage without command-spawn is grantable |
secrets | host_sign | HMAC-SHA256 with a named secret — the guest can sign with it, never read it |
llm | llm-complete | the host holds the key — the component never sees it |
browse | browse-fetch | host owns egress — the component never opens a socket |
net | ambient http (core lanes) | SSRF floor per URL; batch fetch hard-capped at 64 URLs per call, 16 concurrent, 15 s each |
tcp udp tls | brokered one-shots | resolve-then-pin, internal addresses refused, 1 MB responses, rate-limited, revocable, audited |
parallel | run-command-many | fans one command over N inputs on the BEAM — the parallelism single-threaded wasm can't have |
posix | dock lanes only | the 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.