ExSandbox.Egress.Verdict (ExSandbox v1.0.1)

Copy Markdown View Source

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

answer(line, registry \\ ExSandbox.Egress.Registry)

@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.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

default_path()

@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.

path(server \\ __MODULE__)

@spec path(GenServer.server()) :: String.t()

The path this verdict server is bound to.

serve(socket, registry)

@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.