learn / 02·10 — under nexus · networking

the network is aSERVICEnot a permission

A workbook never opens a socket — in either direction. It hands the engine a destination and bytes; the engine, which holds the connections, the TLS stack, and the credentials, decides, connects, audits, and answers. That one inversion buys things a firewall can't: HTTPS without shipping crypto, signed API calls without holding the key, network you can revoke mid-flight, and standard WASI tools that think they have sockets while every connect is checked.

networking12 min read
A small lone operator standing at the base of a colossal bright switchboard tower, glowing patch-cables fanning out from a central engine to distant horizons while a single severed cable hangs dark — every connection routed through one monumental exchange — 1970s sci-fi style, luminous green and cream

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

net·work·ing /ˈnɛt·wɜːr·kɪŋ/ noun

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 lanewasi: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 classexampleverdict
cloud metadata169.254.169.254blocked — IAM credential theft
loopback127.0.0.1 · [::1]blocked — your control plane
private (RFC1918)10/8 · 172.16/12 · 192.168/16blocked — your LAN
link-local169.254/16 · fe80::/10blocked — metadata lives here
carrier NAT100.64/10blocked — shared infra
v4-mapped v6 / NAT64::ffff:127.0.0.1blocked — re-classified, then judged
publicexample.com · 1.1.1.1allowed — 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.

brokerimportdefault captimeoutcovers
TCPhost_tcp1 MB10 sHTTP/1 · Redis RESP · line protocols
UDPhost_udp65,535 B5 sDNS · NTP · STUN
TLShost_tls1 MB10 sHTTPS 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 #+URL and #+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_allow is 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.