Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 neither bind nor connect. The empty namespace is the boundary. A program may listen on loopback and connect to itself; anything off loopback is ENETUNREACH.
  • network = "egress", function: bind is allowed; connect is 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 in allow (127.0.0.1:5681 needs --allow-private-net), or it uses full or none.
  • network = "full", function: bind is allowed; Landlock handles nothing.
  • A pool, in every mode: bind handled and never granted, as before; under none connect too. SandboxConfig::shared_namespace carries 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-linux case 20 attempts it under strict (refused by seccomp) and under default (refused by Landlock, 6.7+).
  • Under egress, self-connect is the one awkward corner; the book says so and gives the allow rule that opens it.

What would reopen this

  • A function shape that runs several tenants’ requests in one namespace without being a pool. Then shared_namespace is 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.