ADR 0008 — A function may listen on its own loopback; a pool may not
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-26. Prompted by the n8n task-runner round, in which n8n’s own runner launcher could not start inside a sandbox because each runner listens on a local health-check port.
Context
Since Landlock’s network rules (ABI v4, Linux 6.7) Zygo has handled the
bind right in every sandbox with a network namespace and granted it
nowhere, so no sandbox could open a TCP listener — not on the network, and
not on its own loopback either. Under network = "none" the connect right
was handled and never granted too, so a sealed sandbox could not even connect
to itself. The reasoning was “no mode has ingress, so nothing should listen”.
Three things about that turned out to matter.
Ingress was never Landlock’s job. Nothing from outside reaches a port a
sandbox opens: pasta is started with --tcp-ports none --udp-ports none,
so it forwards no port in, and under none there is no interface but
loopback. That holds on every kernel. Landlock’s bind rule added nothing to
it — and only on 6.7 and later; a Debian 12 host (6.1) never had it.
The rule was TCP only. Landlock’s network rights cover TCP. A UDP listener and a unix socket were always allowed. So the rule was not “a sandbox does not listen”; it was “a sandbox does not listen on TCP”.
It broke programs that talk to themselves. Anything that uses a localhost
TCP port for its own plumbing — Jupyter kernels, Ray and Dask workers,
PyTorch’s distributed runtime, a headless Chrome’s DevTools port, a Java
debugger, n8n’s runner launcher, many test suites — failed inside a sandbox
on 6.7+ and worked on 6.1. The book described none as “an empty network
namespace with only loopback”, which a reader takes to mean loopback works.
What the rule did buy was in one place: a runtime pool. Its requests
belong to different tenants and share one network namespace, so a listener
on loopback there is a channel from one tenant’s request to another’s.
strict, the pool default, already removes the socket calls; Landlock’s
bind refusal is the layer under that, for a pool served with another
profile.
Decision
The bind right is handled — and never granted — only where the namespace
is shared between tenants: a runtime pool. A function’s own namespace,
and a zygo run sandbox, may bind and listen on TCP.
network = "none", function: Landlock handles neitherbindnorconnect. The empty namespace is the boundary. A program may listen on loopback and connect to itself; anything off loopback isENETUNREACH.network = "egress", function:bindis allowed;connectis limited to the allowlist’s ports as before, and that rule applies to loopback too. A function that talks to its own listener therefore names the port inallow(127.0.0.1:5681needs--allow-private-net), or it usesfullornone.network = "full", function:bindis allowed; Landlock handles nothing.- A pool, in every mode:
bindhandled and never granted, as before; undernoneconnecttoo.SandboxConfig::shared_namespacecarries the distinction from the resolved pool to the ruleset.
Nothing changes about ingress: pasta still forwards no port in, and under
none there is still nothing but loopback.
Consequences
- Stock software with local health-check or debugging ports runs inside a function’s sandbox. n8n’s runner launcher is the case that found this.
- A sealed function behaves the same on 6.1 and 6.8: its loopback works, the rest is unreachable. Before, it depended on the kernel.
- Two tenants’ requests in a pool still cannot open a channel to each other
through a port:
make escape-linuxcase 20 attempts it understrict(refused by seccomp) and underdefault(refused by Landlock, 6.7+). - Under
egress, self-connect is the one awkward corner; the book says so and gives theallowrule that opens it.
What would reopen this
- A function shape that runs several tenants’ requests in one namespace
without being a pool. Then
shared_namespaceis the flag to set, not the rule to revisit. - Landlock gaining an address scope for
bind, which would let a pool’s scripts listen on ports nobody else in the pool can reach.