learn / 02·1 — under nexus · the dock

the call thatHAS NOaddress

The Dock is the typed capability membrane between a docked workbook and its Nexus. A permission here is not a rule checked at call time — it's a function that exists or doesn't. The host builds the import table from the grants; an ungranted power isn't denied, it's absent, and a component that needs it can't even finish linking.

the dock13 min read
A small vessel docking against a monumental harbor wall of sealed circular ports, only three ports glowing open under bright daylight — 1970s sci-fi style

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

dock /dɒk/ noun

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:

profilememorywall-clock / callcapabilities
compute64 MiB5 svfs only — pure compute + an ephemeral disk
minimal64 MiB5 svfs commands exec kv secrets queue tcp udp tls
network128 MiB30 sminimal + net llm browse
posix256 MiB60 snetwork + 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:

brokerthe privileged actthe named limits
execruns a registered wasm command in its own isolated instance — there is no OS exec to reachdefault-deny · structural argv · depth 8 · 8 MiB output
tcpone-shot request/response — resolve once, pin the IP1 MiB response · 10 s
udpone datagram exchange — DNS, NTP, STUN64 KiB · 5 s
tlsthe host does the handshake — verify_peer, SNI bound to the original hostname — so a crypto-less guest gets a secure channel1 MiB · 10 s
secretsHMAC-signs guest data with a named secret — no read existstenant-scoped · revocation-checked
kvdurable storage; the tenant is captured from the Dock, never named by the guest1 MiB / value · 10k keys · 64 MiB / tenant
queuehost-owned FIFO topics between guests — no shared memory1,000 deep · 256 KiB / msg · 50k msgs / tenant
parallelfans work across fresh instances — the host does the threading a single-threaded guest can't1,024 inputs · 16 concurrent · 30 s / task
httpengine-held egress through the SSRF guarddeny-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.

lanethe gatewhen denial landswhat the guest sees
components (WIT)presence — import omittedat link — before any instructionfails to instantiate
Rust core modulespresence — env import omittedat the call that instantiatesinstantiation error
JS (QuickJS harness)behavior — import wired, function refusesat call timenull / -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.