the boolean LIE
Every sandbox vendor says the same three words: no network by
default. Read the code and it's usually one boolean. Off, and the
sandbox is useless — your workbook can't call an API, can't reach Redis,
can't fetch a thing. On, and it inherits the whole host network: the
cloud metadata endpoint at 169.254.169.254 that hands out IAM
credentials, 127.0.0.1 where your control plane listens, and
every box on your LAN. There's no third setting, so people ship the
dangerous one and hope the prompt-injected agent never asks for the
metadata IP.
This isn't hypothetical for us — it's the exact hole the engine was
built to close. Stock WebAssembly tooling gates HTTP and sockets with a
single flag that, when flipped, calls inherit_network() and
turns on IP name lookup wholesale. One switch, both lanes, full host
stack. The question this page answers is the one that switch can't:
what exactly can a workbook reach, who decided, and where's the log?
the DEFINITION
1. in a workbook, a brokered service, not a permission: the guest never opens a socket in either direction — it hands the engine a destination and bytes, and the engine connects, checks every resolved address, audits the attempt, and returns the answer. Granted by derivation from a capability profile, never by default.
Default is no network — and not as a setting you could forget to flip.
A workbook with the minimal or compute profile
gets no HTTP import linked, no socket pool inherited, no DNS at all. Only a
profile that grants net or browse derives any
host network, and even then every byte still crosses through the engine.
The network is something the engine renders for the guest — the
way a kitchen renders a meal you never cooked.
the two egress LANES
There are exactly two paths a guest can take outbound, and both are
mediated. The first is the standard WASI lane — wasi:http
plus wasi:sockets, the seam unmodified tools already speak.
A program written for any WebAssembly host, calling plain fetch()
or std::net, lands here and gets safe brokered network without
knowing it. The second is the Dock lane — explicit host imports
(host_http_get, host_tcp, host_udp,
host_tls) that a guest written for Workbooks calls on purpose.
The lanes gate differently, and the difference is worth holding onto.
The Rust-side Dock gates by presence: a non-network profile simply
doesn't get the import merged into the guest — the function isn't there to
call. The JS-side Dock gates by behavior: the Javy harness always
imports every host function, but a denied capability returns -1,
which surfaces to JavaScript as Javy.Net.get(url) === null.
Two styles, one rule underneath. Whichever lane a request takes, it meets
the same security cadence before any packet leaves:
flowchart LR guest["a guest
any language"] subgraph lanes["two egress lanes"] direction TB wasi["standard lane
wasi:http · wasi:sockets
unmodified tools"] dock["dock lane
host_http_get · host_tcp
host_udp · host_tls"] end subgraph cadence["one shared cadence — every request"] direction TB floor["SSRF floor
deny internal targets"] allow["allow-list
per-instance scope"] rate["rate floor
per-tenant + per-instance"] audit["audit
counters · deny ring · telemetry"] end guest --> wasi guest --> dock wasi --> floor dock --> floor floor --> allow --> rate --> audit audit --> net["the public internet
only what survived the checks"] style lanes fill:#fbfaf6,stroke:#121316 style cadence fill:#fbfaf6,stroke:#121316 style wasi fill:#aee5c2,stroke:#121316 style dock fill:#a8d4f0,stroke:#121316 style floor fill:#f3c5a3,stroke:#121316,stroke-width:2.5px style allow fill:#f2ddb0,stroke:#121316 style rate fill:#f2ddb0,stroke:#121316 style audit fill:#f2ddb0,stroke:#121316 style guest fill:#ffffff,stroke:#121316 style net fill:#ffffff,stroke:#121316
Read the graph as one funnel with two mouths. A request enters from either lane, but they immediately merge onto the same four checks — SSRF floor, then allow-list, then rate floor, then audit — and only what survives all four reaches the public internet. There is no path that skips the cadence, because the guest never holds the socket that would let it.
the SSRF FLOOR
The floor is the part that's always on, under both lanes, even before
you scope anything. It's a deny set of address families that a guest has no
business reaching, and it's the same list in two implementations — the
Elixir NetGuard for brokered HTTP, and the Rust
wb_ip_allowed for the WASI lane — so neither lane is a hole in
the other. Loopback, the private ranges, link-local (which is where the
cloud metadata IP lives), carrier-grade NAT, multicast, broadcast, the IPv6
equivalents — and the two tricks attackers reach for: an
IPv4-mapped IPv6 address like ::ffff:127.0.0.1,
re-checked as the v4 address it really is, and NAT64 / 6to4 / Teredo
addresses with an internal v4 smuggled inside, re-classified before the
verdict. If a name won't resolve, the answer is deny, not "try anyway".
This isn't a spec we hope holds — a red-team end-to-end test fires a
real StarlingMonkey wasi:http guest at ten obfuscated
internal targets, calling standard fetch(), and every one must
come back blocked. Here's that probe matrix as the test runs it:
probe("http://169.254.169.254/") → BLOCKED (cloud metadata / IAM creds)
probe("http://127.0.0.1/") → BLOCKED (loopback / control plane)
probe("http://10.0.0.1/") → BLOCKED (RFC1918 private)
probe("http://172.16.0.1/") → BLOCKED (RFC1918 private)
probe("http://192.168.1.1/") → BLOCKED (RFC1918 private)
probe("http://100.64.0.1/") → BLOCKED (carrier-grade NAT)
probe("http://[::1]/") → BLOCKED (IPv6 loopback)
probe("http://[::ffff:127.0.0.1]/") → BLOCKED (v4-mapped-v6 smuggle)
probe("http://user:[email protected]/") → BLOCKED (userinfo@ trick)
probe("http://example.com/") → 200 "Example Domain" (public — allowed)
The same families, named once with a verdict, so the shape is legible at a glance:
| target class | example | verdict |
|---|---|---|
| cloud metadata | 169.254.169.254 | blocked — IAM credential theft |
| loopback | 127.0.0.1 · [::1] | blocked — your control plane |
| private (RFC1918) | 10/8 · 172.16/12 · 192.168/16 | blocked — your LAN |
| link-local | 169.254/16 · fe80::/10 | blocked — metadata lives here |
| carrier NAT | 100.64/10 | blocked — shared infra |
| v4-mapped v6 / NAT64 | ::ffff:127.0.0.1 | blocked — re-classified, then judged |
| public | example.com · 1.1.1.1 | allowed — the floor lets it through |
The honest read of that table: the floor's whole job is to stop a guest from reaching inward — at your infrastructure, your metadata, your LAN. It does not, by itself, stop a guest from talking to an arbitrary public host. That's a different concern with a different tool, and it's opt-in — the allow-list, a few sections down.
resolve-then-PIN
depth rung · skippable — how DNS rebinding gets closed
A deny list on IPs has a famous hole: DNS rebinding. The attacker's
hostname resolves to a public IP when you check it, then to
127.0.0.1 a millisecond later when your HTTP client
re-resolves it to actually connect. The fix is resolve-then-pin:
resolve the hostname once, check every resolved IP against the floor, and
then connect to the pinned IP — never to the hostname, which a
naive client would look up again. For HTTPS the SNI and the certificate
hostname-match stay bound to the original name, verified against the system
roots, so a rebind fails the handshake instead of reaching a private box.
Redirects get the same treatment: followed manually, max five hops, and
every hop re-checked — a public URL that 302s to an internal one is caught
on the second check.
sequenceDiagram participant G as a guest participant E as the engine participant D as DNS participant P as the pinned IP G->>E: fetch https://api.example.com/ E->>D: resolve once D-->>E: 93.184.x.x (and any others) E->>E: check every IP against the floor Note over E: any internal → deny, stop here E->>P: connect to the pinned IP
SNI + cert = api.example.com P-->>E: 302 → somewhere else E->>E: re-check the redirect target (hop 2 of 5) P-->>E: 200 — decrypted bytes E-->>G: the body
Walk that sequence as a story. The guest asks for a hostname. The engine resolves it exactly once and freezes the answer. It judges every address that came back; if any is internal, the story ends there. Otherwise it dials the frozen IP — but keeps the original name on the TLS handshake, so virtual hosts and certificate validation still work. A redirect doesn't reset the rules; it's just hop two, checked like hop one. The hostname the guest typed never gets a second, exploitable resolution.
TCP, UDP, TLS without a SOCKET
The Dock lane's headline is three one-shot brokers. Each takes a
destination and bytes, does the whole request-and-response on the host, and
hands back the answer — the guest never sees a file descriptor. The ABI is
real and tiny: host_tcp(host_ptr, host_len, port, req_ptr, req_len,
out_ptr, out_cap) -> i32, returning the response length, or
-1 for denied. host_tls and host_udp
share the same seven-argument shape.
| broker | import | default cap | timeout | covers |
|---|---|---|---|---|
| TCP | host_tcp | 1 MB | 10 s | HTTP/1 · Redis RESP · line protocols |
| UDP | host_udp | 65,535 B | 5 s | DNS · NTP · STUN |
| TLS | host_tls | 1 MB | 10 s | HTTPS from a guest with no crypto |
Two of those rows hide something better than a protocol. The UDP broker connects its socket to the pinned peer, so the kernel itself drops any datagram that arrives from a different source — and then checks the source address and port a second time anyway, belt and suspenders against reply-spoofing. The TLS broker is the standout: the host performs the handshake — verify-peer against the system roots, SNI pinned to the hostname, chain depth ten — the guest sends plaintext and gets decrypted bytes back. A guest that contains no TLS stack at all gets HTTPS. Here's that, with the real ABI, from a tiny C-or-Rust guest:
extern "C" {
fn host_tls(host_ptr: *const u8, host_len: i32, port: i32,
req_ptr: *const u8, req_len: i32,
out_ptr: *mut u8, out_cap: i32) -> i32;
}
// send: "GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n"
// the host does the handshake — verify_peer against system roots,
// SNI pinned to api.example.com — and you get decrypted bytes back.
// return -1 = denied (SSRF, revoked, or rate-limited).
// one error code — check the audit ring for the reason why.
The JS twin lands on the same import:
const body = Javy.Net.get("https://api.example.com/items"); // null = denied.
And the payoff that ties it to the toolkit world: an unmodified
fetch() in bundled npm JavaScript — through the
http / https shims and WHATWG fetch
— is backed by that exact Javy.Net.get seam. Node-style code
doesn't open a socket; it asks the engine, and it doesn't know the
difference.
One more import earns a mention because it does something the guest
can't do alone. host_http_get_many takes
newline-joined URLs — capped at 64 per batch, a deliberate floor against
DoS amplification — and the BEAM fetches them concurrently, sixteen
at a time, fifteen seconds each, then marshals the results back as one
buffer. A single-threaded WebAssembly guest gets real parallel fan-out it
could never produce on its own.
allow-lists and the DNS-off TRICK
depth rung · skippable — narrowing the floor to a list
The floor blocks internal targets. To bound a guest to a
specific set of public ones, an instance can carry
net_allow — patterns like "host",
"host:port", "*.suffix", case-insensitive — and
the moment that list is non-empty, it's default-deny: only what's
listed gets through. Crucially this sits on top of the floor, not
instead of it. Scope a guest to ["1.1.1.1"] and
8.8.8.8 — a perfectly public DNS server — is denied, because
it isn't on the list.
The sharp trick is what happens when the list is all IP literals. A
guest that can resolve hostnames can exfiltrate data by encoding it into
DNS queries to an attacker's subdomain — the lookup itself is the leak,
even if the connection never completes. So when net_allow is
all IPs, the engine disables IP name lookup entirely: an IP-scoped guest
cannot resolve names at all, which means it cannot leak through DNS.
The end-to-end test proves it bluntly — a cargo-component
std::net guest scoped to one IP:
net_allow: ["1.1.1.1"] connect 1.1.1.1:80 → OK (the one address on the list) connect 8.8.8.8:80 → ERR (public, but not listed — default-deny) connect example.com:80 → ERR (no DNS at all — names don't resolve)
One honest note on maturity. Today net_allow is set
programmatically, per instance — it's a real option the engine enforces,
exercised in the test suite, but there is no author-facing org syntax
for it yet. No #+NET: line you write at the top of a workbook.
The enforcement is solid; the declaration surface is still to come, and we
won't pretend otherwise.
the engine holds the CREDENTIALS
The Nexus lesson promises that secrets stay in the engine. This is the mechanism. There are four flavors of the same move — the guest acts, the engine authenticates — and none of them ever shows the workbook a secret:
- browse-fetch — a typed Dock import where the component sees only a JSON string back. The fetch provider is a pluggable slot: native by default, or Exa / Firecrawl / a proxy by config. The guest asks for a page; the engine fetches it with whatever it's configured to use.
- llm-complete — the host holds the
OPENROUTER_API_KEY. The workbook calls a model and never sees the key that paid for the call. - SecretBroker — and this one is the sharpest. There is
no read API. The only operation a guest can perform on a secret is
sign— an HMAC-SHA256 with the tenant's named key. The guest gets a signature back and authenticates a webhook or an API call without ever possessing the credential. You can't leak what you were never handed. - data-source plugins — a manifest declares
#+URLand#+ENV_KEYS; the bearer token is resolved host-side and the egress runs on the host's HTTP client. The plugin never holds the raw key and never opens a socket itself.
The pattern is the inversion again, applied to authentication. A normal program proves who it is by possessing a secret. A workbook proves who it is by asking the engine to prove it — and the secret stays on the side of the boundary that the prompt-injected text can't reach. Secrets get their own deep-dive; this section stays deliberately tight.
the server FLIP
Everything so far was outbound. Serving flips it, and the inversion holds: the host owns the listening socket. The guest never accepts a connection. Per request, the engine hands the guest the bytes over an in-memory channel and takes back the response — and the rate check fires before the guest runs, so a flood costs zero guest CPU. The request arrives HTTP-message-shaped as plain bytes — method, path, headers, a blank line, body — and a guest that returns plain bytes with no blank line is treated as a 200. A minimal handler still works.
sequenceDiagram participant C as a client participant H as the host listener participant G as the guest handle C->>H: HTTP request H->>H: rate-check FIRST (429 if flooding)
before any guest CPU H->>G: METHOD PATH\n headers\n \n body G->>G: handle — returns bytes Note over G: any outbound it makes mid-request
is still SSRF-brokered — no pivot G-->>H: response bytes H-->>C: HTTP response
The standard inbound seam drives a real wasi:http server
component, and it carries the same discipline the rest of the engine does:
a fresh instance per request for isolation, mid-flight revocation
that answers 503, a per-client-IP rate limit that answers
429, a 4 MB body cap that answers 413, and a
streaming / SSE mode with an epoch-deadline trap for compute-DoS. The line
on the diagram is the one that matters most: any outbound a serving guest
makes mid-request is still brokered through the SSRF floor. A
served app cannot be turned into an SSRF pivot — the test asserts exactly
that, the served body reading outbound=blocked.
revoke, rate, AUDIT
depth rung · skippable — what the operator sees
Three controls wrap every broker call, and they're the part an operator actually lives in. Revocation is an in-memory set checked on each call — revoke a tenant and the next broker call fails, mid-flight, with no teardown of the running guest. Rate limiting defaults to a generous floor — about 2,000 broker calls per second per tenant — tuned to catch a runaway, not to meter usage. And audit is the memory: every denial bumps a counter keyed by broker, outcome, and reason, lands in a 128-entry forensics ring, and fires a telemetry event you can route to Prometheus or a SIEM. The targets in that ring are truncated to 512 bytes, because the strings are guest-controlled and an un-capped string is a memory-DoS vector all by itself.
iex> Workbooks.BrokerAudit.stats()
%{{:net, :allow} => 412, {:net, :deny} => 3, {:net, :deny, :ssrf} => 3,
{:tcp, :deny, :ssrf} => 1}
iex> Workbooks.BrokerAudit.recent(2)
[{:net, :ssrf, "http://169.254.169.254/", 1765500000000}, ...]
iex> Workbooks.Revocation.revoke("tenant-acme")
# the next broker call from acme: {:error, :revoked}
The numbers that anchor the floors are worth saying out loud, because
they're the difference between "secure" and "secure under load": about
2,000 broker calls per second per tenant by default; 50,000 outbound
connect attempts per ten seconds per instance on the WASI lane — counted
even when denied, so a guest that hammers the floor still hits the ceiling;
64 URLs per host_http_get_many batch; and that 128-entry deny
ring with its 512-byte truncation. "What did it try, and where's the log"
has a literal answer: BrokerAudit.recent and
BrokerAudit.stats.
what it ISN'T
Honesty section. The model is strong because it's narrow, and the narrowness has edges worth naming.
- One-shot, not persistent. The brokers are request-and-response.
There's no long-lived connection a guest holds — no WebSocket client kept
open across many beats through
host_tcp. Streaming inbound exists for serving; persistent outbound from a guest does not. - Allow-lists aren't author-declarable yet.
net_allowis enforced per instance but has no org-mode surface — you can't write the scope at the top of a workbook today. The floor is always on; the list is a host option for now. - The floor stops inward, not outward. It blocks internal targets — metadata, LAN, loopback. It does not stop exfil to an arbitrary public host unless you add an allow-list, and the DNS-off defense only kicks in for an IP-only scope. Both are opt-in.
- Caps truncate silently. A response past the broker's cap is cut, not errored. Size your reads.
- It's not a VPN or a proxy layer. Reaching a proxy or a different fetch backend is configuration — the Browse provider slot — not something a guest negotiates. And brokered hops add latency a raw socket wouldn't.
None of these is a bug; each is the cost of the guest never holding a socket. The trade is deliberate — you give up a little reach and a little latency, and you get a network you can fully reason about.
questions people actually ASK
Can my workbook call any API?
On a network profile, yes — any public host. The SSRF floor applies,
so internal targets are off the table, but a normal third-party API over
HTTP or HTTPS goes through. The guest calls fetch() or a
broker; the engine resolves, pins, connects, and answers.
Can it reach localhost or my LAN?
No — and not by setting, by construction. The floor denies loopback (127/8, ::1), the private ranges (10/8, 172.16/12, 192.168/16), link-local 169.254/16 including the cloud metadata IP, carrier-grade NAT 100.64/10, and the IPv4-mapped-IPv6 and NAT64 tricks that try to smuggle those past a naive check. Both lanes enforce the same list.
How do I do HTTPS from a tiny C guest with no crypto?
host_tls. The host performs the handshake — verify-peer
against system roots, SNI pinned to the hostname — your guest sends
plaintext and receives decrypted bytes. You ship no TLS stack and still
speak HTTPS, because the crypto lives on the host side of the
membrane.
Where do my API keys go?
Into the engine, never the workbook. For model calls the host holds the
key. For your own secrets the rule is sign-don't-read: the
SecretBroker exposes only sign, so the guest gets a signature
and authenticates without ever possessing the credential. You can't leak
a key you were never handed.
Can I see what it tried?
Yes. BrokerAudit.stats() gives counters by broker,
outcome, and reason; BrokerAudit.recent(n) returns the last
denials with their targets and timestamps; and every deny fires a
telemetry event for Prometheus or a SIEM. "What did it try, and why was it
blocked" is a query, not a mystery.
What about serving — does the guest listen on a port?
No. The host owns the listening socket; the guest only handles. Per request the engine hands it HTTP-shaped bytes and takes back a response, rate-checking before the guest runs. And any outbound the serving guest makes mid-request is still SSRF-brokered — a served app can't be used as a pivot into your network.
keep GOING
This page is the mechanism behind two lines on the Nexus — "no ambient network" and "secrets stay in the engine". Start there, then go deeper.