the if-statement you forgot to WRITE
Every permission system you've ever met enforces itself the same way: code asks, a runtime check answers. A guard clause before the dangerous call, a policy engine consulted on the hot path, an allow-list someone maintains. The whole security story rests on a hope — that every dangerous path has its check, that nobody added a new one and forgot, that the check itself can't be reasoned around. Audits exist because that hope is routinely wrong.
Then agents arrived and made the stakes personal. An agent doesn't run a fixed set of calls you reviewed — it writes its own calls, at runtime, steered in part by whatever text reaches it. Prompt injection means the workload itself is the adversary you plan for. In that world, one forgotten guard clause isn't a bug report. It's an exfiltration.
The parent lesson makes a one-sentence promise about this: no ambient file system, no ambient network — grants, not inheritance. And its security section gives you three assertions with no mechanism attached. This page is that sentence's machinery. The question it answers: what if the unauthorized call didn't get refused — what if it had no address at all?
the DEFINITION
1. the typed capability membrane between a docked workbook and its Nexus: the host hands the guest exactly the functions its grants permit — an ungranted power is not refused, it does not exist.
The word carries two senses, and the parent page already used the first. The Nexus lesson draws the docking diagram and captions it the dock — file in, living software out: the moment a workbook plugs into an engine. This page is about what that moment installs. After docking, everything the workbook ever does to the world — every file read, every byte of network, every command, every secret — crosses this one surface. The dock is the moment; the Dock is the membrane the docked thing lives against forever after.
Concretely, it's a WIT world. The canonical surface —
workbooks:engine — declares a handful of imports
(session-info, vfs-query,
run-command, browse-fetch,
run-command-many) and one export: run. The
workbook exports its entrypoint; the host decides which imports exist.
That asymmetry is the entire design.
gated by CONSTRUCTION
Here is the whole gate, and it's shorter than most permission
middleware's config file. When an Instance starts, the host folds the
policy's capability list into an imports map. The base map holds exactly
one entry — session-info, always granted, read-only
identifiers. Each granted cap merges exactly one more function. A cap
that isn't in the list adds nothing. An unknown cap is dropped on the
floor:
# runtime/host/instance/imports.ex — the whole gate
def for_caps(caps, vfs_conn, info, opts \\ []) do
base = %{"session-info" => {:fn, fn -> Jason.encode!(info) end}}
Enum.reduce(caps, base, &add(&1, &2, vfs_conn, ctx))
end
defp add("vfs", acc, vfs, _), do: Map.put(acc, "vfs-query", ...)
defp add("commands", acc, _, ctx), do: Map.put(acc, "run-command", ...)
defp add("browse", acc, _, _), do: Map.put(acc, "browse-fetch", ...)
defp add(_other, acc, _, _), do: acc # unknown caps: nothing
Then wasmtime links the component against that map — and linking is where enforcement happens. A component declares the imports it needs in its binary. If the host's map satisfies them, it instantiates. If the component hard-imports a function the map doesn't contain, it fails to link — before a single guest instruction runs. There is no code path to forget to guard, because the unauthorized call has no address to jump to:
flowchart LR policy["policy :minimal
caps: vfs · commands · exec · …"] fold["for_caps — fold the cap list"] map["imports map
session-info ✓ vfs-query ✓ run-command ✓
browse-fetch — absent"] link{"wasmtime
links the
component"} ok["instantiates — runs"] no["fails to link —
never ran at all"] policy --> fold --> map --> link link -- "imports satisfied" --> ok link -- "hard-imports browse-fetch" --> no style map fill:#aee5c2,stroke:#121316,stroke-width:2.5px style ok fill:#13d943,stroke:#121316 style no fill:#ffffff,stroke:#121316 style policy fill:#ffffff,stroke:#121316 style fold fill:#ffffff,stroke:#121316
This isn't an architecture diagram's aspiration — it's a passing test
in the suite. One component hard-imports browse-fetch and is
started under :minimal, which doesn't grant browse. The
other imports run-command, which :minimal does
grant:
# runtime/test/browse_dock_test.exs — the gate, as a test
test "a minimal-profile Instance cannot instantiate the browse component" do
bytes = File.read!("build/fixtures/engine-browse-probe.wasm")
# :minimal doesn't grant browse → the host omits browse-fetch →
# the component (which hard-imports it) fails to LINK.
assert {:error, _} =
Workbooks.Instance.Supervisor.start_instance(id, bytes, policy: :minimal)
end
# runtime/test/dock_component_e2e_test.exs — same profile, granted cap
{:ok, "HELLO WORLD"} = Instance.call(id, "run", ["hello world"])
Same profile, two components, opposite fates — and both fates were
decided before any guest code executed. One more detail worth a
sentence: session-info never leaks host context. The
Instance's working directory on the host is deliberately not folded into
the record the component can read. Even the always-granted import grants
only identifiers. (Profiles with the llm cap also get an
llm-complete import bound by the host — the key stays
host-side; the component never sees it.)
the grant VOCABULARY
So what can a grant say? Capabilities come in named bundles — profiles — and each profile is three numbers and a list. These are the real values, from the policy module, not an illustration:
| profile | memory | wall-clock / call | capabilities |
|---|---|---|---|
| compute | 64 MiB | 5 s | vfs only — pure compute + an ephemeral disk |
| minimal | 64 MiB | 5 s | vfs commands exec kv secrets queue tcp udp tls |
| network | 128 MiB | 30 s | minimal + net llm browse |
| posix | 256 MiB | 60 s | network + posix parallel |
The memory number is a hard ceiling enforced by the store — a
component growing past it traps. The wall-clock number caps each
boundary call: component calls run on a separate thread, an overrun
comes back as {:error, :cpu_timeout}, and the engine stays
responsive. And a profile name nobody recognizes — a typo, a stale
config — fails closed, to compute: the
least-privileged row, not the most.
The names deserve one honest gloss. minimal means
minimal network, not minimal everything — it still grants
signing, durable storage, and the brokered raw sockets. If you want the
true sandbox, the row you want is compute. Notice also that
exec and kv are their own caps, distinct from
the broad commands and vfs — a profile can
grant durable storage or a socket without granting the ability to spawn
commands at all.
One switch in that table does more work than it looks like. Whether a
profile may use high-level HTTP is derived from its caps — true
only if net or browse is granted — and in the
runtime under this engine, that single flag gates the wasi:http linkage,
the inherited socket pool, and DNS lookup together. A
non-network profile doesn't get a filtered network. It reaches no
host network stack at all. The profile-by-profile mechanics get
their own lesson — capabilities — so this
page stops at the shape.
anatomy of one CALL
depth rung · skippable — what a granted import physically does
Suppose the import exists. What happens when the guest calls it? The
wire convention is deliberately primitive: signatures are integers only.
The guest writes its request into its own linear memory and
passes a pointer and a length. The host reads those bytes out, does the
privileged thing in the engine, writes the reply back into guest memory,
and returns the byte count — or -1 on denial or error.
The guest never holds a socket, a file handle, or a key. It holds
bytes it wrote and bytes it was handed. Even the clock crosses this way:
wasm has no wall clock, so host_now is a real capability,
not a convenience.
Here's the round trip for a brokered TCP request — the guest's half marshals pointers; the host's half is a broker running the full security cadence on every single call:
sequenceDiagram participant G as guest (wasm) participant D as dock import — host_tcp participant B as TcpBroker (in the engine) participant W as the world G->>D: (ptr, len) — host:port + payload, in its own memory D->>B: host reads the bytes out of guest memory B->>B: revoked? rate-limited? then resolve once —
internal address? denied. else PIN the ip B->>W: connect to the pinned ip — one shot W-->>B: response — capped at 1 MiB B-->>G: reply written back into guest memory — or -1
Read the broker's box twice — that resolve-then-pin step is the detail that closes the DNS-rebind window. The hostname is resolved once, checked against the internal-address deny list, and the connection goes to the pinned IP, not the name. A second, malicious resolution never gets consulted. And note what the Instance does not have: WASI stdio is not inherited — stdin, stdout, stderr are all cut. The typed Dock is the real surface, not a side door next to one.
the broker FLEET
Every import that exists is half a function. The other half lives in the engine — a broker that holds the dangerous resource and applies the same cadence on every call: revocation check, rate check, scope check, size cap, audit. One row per broker, with its real, in-the-source limits:
| broker | the privileged act | the named limits |
|---|---|---|
| exec | runs a registered wasm command in its own isolated instance — there is no OS exec to reach | default-deny · structural argv · depth 8 · 8 MiB output |
| tcp | one-shot request/response — resolve once, pin the IP | 1 MiB response · 10 s |
| udp | one datagram exchange — DNS, NTP, STUN | 64 KiB · 5 s |
| tls | the host does the handshake — verify_peer, SNI bound to the original hostname — so a crypto-less guest gets a secure channel | 1 MiB · 10 s |
| secrets | HMAC-signs guest data with a named secret — no read exists | tenant-scoped · revocation-checked |
| kv | durable storage; the tenant is captured from the Dock, never named by the guest | 1 MiB / value · 10k keys · 64 MiB / tenant |
| queue | host-owned FIFO topics between guests — no shared memory | 1,000 deep · 256 KiB / msg · 50k msgs / tenant |
| parallel | fans work across fresh instances — the host does the threading a single-threaded guest can't | 1,024 inputs · 16 concurrent · 30 s / task |
| http | engine-held egress through the SSRF guard | deny-internal floor · every redirect hop re-checked, max 5 · 64-URL batches |
Three rows reward a closer look. The exec broker's argv is
structural — a list handed straight to the command, never a shell
string. Its own documentation puts it best: "; rm -rf /" is
just a literal argument, never interpreted. What it executes are the
registry's sandboxed wasm commands — the same ones
toolkits ship — and a third-party command marked
untrusted runs one isolation tier further out, on a separate VM node
entirely.
The http row carries a named scar. The guard exists because a
mediated fetch with no destination check would have happily run
host_http_get("http://169.254.169.254/") and handed the
guest its cloud-metadata credentials. Now every resolved address is
checked against the full internal-range catalog — loopback, private
ranges, link-local, carrier NAT, IPv6 local forms, and the IPv4-mapped
smuggling trick — before any socket opens, on the first request and on
every redirect hop after it. The whole story is the
networking lesson's; this page just needs you
to know the floor exists and is always on.
And the kv row hides the quietest rule on the page: the tenant namespace is captured from the Dock itself. There is no parameter through which a guest names a tenant — so a guest can only ever touch its own persistent corner, not because a check said so, but because the question can't be asked. The VFS lesson tells the storage story this sits beside.
sign, never READ
One broker is the emblem of the whole design, so it gets its own section. The secrets broker holds named credentials per tenant — a Stripe webhook secret, an API key, a JWT signing key. The guest gets exactly one operation against them: sign. Hand over the secret's name and a payload; get back thirty-two bytes of HMAC-SHA256. The broker's own documentation states the invariant flatly: there is deliberately no function that returns a secret's value.
sequenceDiagram participant G as guest participant D as host_sign participant S as SecretBroker — holds the key G->>D: secret NAME (stripe_webhook) + payload D->>S: sign(tenant, stripe_webhook, data) S->>S: HMAC-SHA256 — inside the engine S-->>G: 32 bytes of signature Note over G,S: the key never crossed.
there is no function that reads it.
Walk the consequence through. A workbook can verify and sign Stripe webhooks forever — real authentication, production-grade. Now assume the worst case the agents lesson warns about: the guest is fully compromised, a prompt-injected agent actively trying to exfiltrate. What can it steal? Signatures of data it already had. The credential is not in its address space, not in its imports, not anywhere it can name. The parent page's claim that secrets stay in the engine isn't a policy the engine enforces. It's a function the engine never built. The full secret lifecycle — references, staging, delivery — belongs to the secrets lesson.
two kinds of GATE
depth rung · skippable — presence-gating vs behavior-gating across the three guest shapes
One membrane, three guest shapes — and they don't all gate the same
way, which is worth being honest about. Components and compiled-Rust
core modules gate by presence: the host omits the import, and a
guest needing it fails at link. The JS lane can't — QuickJS commands
share one fixed harness that imports every host function
unconditionally, wired into its bindings. So the JS lane gates by
behavior: the function exists, and refuses. A denied call returns
-1, which surfaces to the script as
Javy.Net.get(...) === null.
| lane | the gate | when denial lands | what the guest sees |
|---|---|---|---|
| components (WIT) | presence — import omitted | at link — before any instruction | fails to instantiate |
| Rust core modules | presence — env import omitted | at the call that instantiates | instantiation error |
| JS (QuickJS harness) | behavior — import wired, function refuses | at call time | null / -1 |
The verdict of the table: presence-gating fails early and loudly; behavior-gating fails late and quietly. Both end at the same place — the privileged operation runs in the engine or not at all, and zero native code executes either way — but only the first two make the denial a structural fact. The third is a runtime check, the very thing this page opened by distrusting, confined to the one lane whose fixed wiring leaves no alternative. That trade-off is real, and it's restated in the honesty section below.
the paper TRAIL
A membrane you can't watch is a membrane you can't operate. Every crossing leaves a record, in three layers.
Steps. Every run-command invocation appends one
line to the Instance's step log — tool name, exit code, duration,
timestamp — in exactly the shape agent tool calls are logged, counted
by the same summary code. A workbook calling a command through the Dock
and an agent calling a tool are the same event to
the telemetry layer.
Denials. Every broker outcome bumps an atomic counter keyed by
broker, outcome, and reason — so the question after an incident is a
read: a stats map like %{{:net, :deny, :ssrf} => 3,
{:net, :allow} => 12}, where a spike in SSRF denials means a
guest is probing internal targets. Denials also land in a forensics
ring — the last 128, newest first, each with the target the guest tried
to reach. The target string is guest-controlled, so it's truncated to
512 bytes before storage; an attacker shouldn't get to fill host memory
with the evidence against them. And every outcome emits a standard
telemetry event, so Prometheus or a SIEM can watch the membrane without
touching its internals.
Revocation. The brokers consult a revocation set on every
privileged call — which means cutting off a principal takes effect on
the very next call, mid-flight, with no teardown. Revoke a
tenant and its next host_sign returns -1;
the denial is in the ring with a reason attached. Under all of it sits
a per-tenant rate floor — 120,000 broker calls per 60-second window,
about 2,000 per second. That's not a meter on legitimate throughput;
it's the tripwire for a guest spinning in a tight loop.
where the membrane ENDS
Honesty section — the claims above have edges, and you should know where they are.
The JS lane is a runtime check. The harness imports
everything, so a JS guest only discovers a denial at call time, as a
null. The by-construction story is true for components and
Rust core modules; for JS it's behavior-gating wearing the same
brokers. Same engine-side enforcement, weaker guest-side guarantee.
The CPU cap is a wall-clock trap, not an interrupt. An
overrunning call returns {:error, :cpu_timeout} and the
engine stays responsive — but the runaway worker thread itself is not
yet reclaimed; epoch-based interruption is open work, tracked and
unfinished. The caller is protected today. The thread is freed
tomorrow.
Presence-gating can fail late. A component fails at start — the best possible time. A lazily-instantiated core-module call fails at the first call that forces instantiation, which is later than you'd like to learn about a missing grant.
-1 tells the guest nothing. Deny and error look
identical from inside — deliberately, since a probe shouldn't get a
capability map for free. The cost is that you debug from the host side,
via the audit ring, not from the guest's error message.
The Dock governs crossings, not residence. Pure-compute mischief that never touches an import — burning its memory ceiling, spinning until the timeout — is the wasm sandbox's story, told in the parent's isolation section and the sandboxes lesson. The membrane only ever sees what tries to leave. Likewise the inert file: an undocked workbook has no Dock at all, which is exactly the boundary the workbook lesson draws.
The typed surface is stringly. Most imports take and return
JSON-in-strings, and errors come back as JSON too. The guest-side SDK
exists to paper over that — a small Rust crate of typed, fallible
wrappers that lifts the host's error JSON into a proper
Err, with each cap's wrapper behind a feature flag so only
granted caps are even linked. Honest reading: the membrane's safety is
structural; its ergonomics are a library.
questions people actually ASK
Why did my component fail to instantiate?
Almost always: it imports a capability its profile doesn't grant,
and the system is working. Read the component's imports, read the
profile's cap list, and match them — a browse import needs a profile
that grants browse. The error arrives before any of your
code runs, which is the design doing you a favor: the same mismatch
discovered in production would be a security finding.
Can a guest detect which caps it has?
Not by enumeration — there's no import that lists imports.
A component knows implicitly (it linked, so everything it imports
exists). A JS guest finds out empirically: call and check for
null. session-info tells a guest who it is,
not what it may do.
Does a granted net cap mean open internet?
No. The SSRF floor applies to every host-mediated fetch regardless
of grants — internal and non-routable destinations are denied before
a socket opens, on the first request and on each of at most five
re-checked redirect hops. An optional per-instance host allow-list
narrows it further. net opens the door to the public
internet, not to your engine's neighborhood.
A secret-holding tenant goes rogue — now what?
Revoke the principal. The brokers check revocation on every call,
so the very next host_sign — or exec, or fetch — returns
-1, mid-flight, no teardown required. And the worst case
was already bounded: the tenant only ever held signatures, never the
key.
Is -1 an error or a denial?
Yes. The guest can't tell, on purpose — distinguishable denials would let a hostile guest map its grants. The host can: the audit ring holds the last 128 denials with broker, reason, target, and timestamp. When in doubt, check the ring, not the guest's logs.
keep GOING
The Dock is the membrane; its neighbors each own a slice of what crosses it.