Answers "may this sandbox reach this destination?" for the per-namespace
acceptors (005 T060a1, contracts/egress.md).
Why the acceptor asks instead of knowing
ExSandbox.Egress.Acceptor runs inside a sandbox's network namespace,
which is where an nft redirect can land and the only place it can. Handing
that process a copy of the allowlist would work, pass every conformance check,
and put the policy one process away from the tenant — inside the blast radius
the acceptor exists to bound.
So the acceptor holds no policy. It reads SO_ORIGINAL_DST, asks here, and
relays or refuses. ExSandbox.Egress.Pool.decide/3 remains the single
implementation of the rule, so moving the listener did not fork the decision.
Why AF_UNIX
A network namespace isolates the network stack, not the filesystem. Measured
in the isolation image under unprivileged docker run: a socket bound on a
host path is reachable from inside the sandbox netns, while the tenant —
confined by bwrap to the runtime and its own storage — cannot see the path
at all. Its connect fails ENOENT, not EACCES: for the tenant the socket
does not exist rather than existing and being denied.
That is what makes FR-011b structural here. There is no file to edit and no
socket to reach, because neither is bound into the sandbox's mount view.
⚠️ Every failure is a refusal
A verdict that cannot be produced is DENY. An unparseable request, an
unknown source, a destination that does not resolve to a checkable pair — all
refuse. The alternative reading, "allow when the check could not run", makes a
malfunctioning platform indistinguishable from a permissive allowlist, and it
is the one bug in this subsystem that widens the boundary instead of
narrowing it.
Summary
Functions
The verdict for one request line, as the wire carries it.
Returns a specification to start this module under a supervisor.
Where the verdict socket lives.
The path this verdict server is bound to.
Answers one request on an open socket, then closes it.
Functions
@spec answer(binary(), GenServer.server()) :: binary()
The verdict for one request line, as the wire carries it.
The request is "<source-key> <host> <port>"; the reply is PERMIT or
DENY. ⚠️ Anything that does not parse into exactly that shape is DENY —
a malformed request is not a reason to widen a boundary.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec default_path() :: String.t()
Where the verdict socket lives.
⚠️ Deliberately not under a sandbox's storage or any path
Hardening.Linux binds into a tenant's mount view. The isolation of this
socket is the isolation of the whole policy — a path a tenant can see is a
control surface, and FR-011b requires there to be none.
Configurable because the default is only writable on a deployment host, and a developer machine that cannot bind it would otherwise be unable to start the application at all.
@spec path(GenServer.server()) :: String.t()
The path this verdict server is bound to.
@spec serve(:gen_tcp.socket(), GenServer.server()) :: :ok
Answers one request on an open socket, then closes it.
Public so the protocol is testable without a namespace, a redirect, or a tenant — every part of this that can be checked off Linux is checked off Linux, because the parts that cannot are already the expensive ones.