The ordered steps that put a tenant process inside a policed namespace
(005 T060a3, contracts/egress.md §Lifecycle).
The order, and why it inverted
The first version of this module created a named namespace, configured it,
and had the tenant join it — all before the tenant started. That order is
not reachable: pasta cannot join a namespace made by ip netns add
(measured, egress-path-measurements.md defect 3), and a tenant inside
pasta's namespace has no CAP_NET_ADMIN to configure it from within
(defect 4). The reachable order is:
- launch —
pastacreates the namespace, configures it, and starts the tenant inside it, - find the holder — the tenant's pid, not pasta's (see
ExSandbox.Egress.Pasta), - police — the host installs the redirect into that namespace.
⚠️ The tenant is running, unpoliced, between steps 1 and 3. That window
is real and cannot be closed by reordering, because the namespace does not
exist until the tenant is in it. It is closed instead by what pasta gives
the namespace: the tenant's only route out is pasta itself, and until the
redirect lands, Egress.Acceptor is not listening, so a connection in that
window reaches nothing. The window fails closed, and
ExSandbox.Egress.Verification exists to confirm that rather than assume it.
Why this is a plan rather than a launch
It composes commands and does not run them. The value of stopping here is that the ordering — the part that cannot be checked by inspecting any single command — becomes testable on a host where none of these commands can execute, which is every developer machine that is not Linux.
Why a missing --unshare-net is refused
This module replaces an existing confinement. Handed a command that never confined the network, it has no way to tell "already converted" from "never confined", and the second is a command that would launch a tenant with the host's own network. Refusing is the only answer that cannot be wrong.
⚠️ --unshare-net is removed, not supplemented. Keeping it would put the
tenant in a fresh empty namespace while pasta configured a different
one — isolation restored silently, policy discarded, and every denial check
still green, because an empty namespace denies everything too.
Summary
Functions
Builds the plan for one sandbox, or refuses.
Where pasta records its host-side pid for this sandbox.
The redirect steps for a plan, once the namespace holder is known.
Types
@type refusal() :: :no_network_confinement | :no_pool_port | :no_privilege_drop
Why a plan could not be built.
@type t() :: %ExSandbox.Egress.LaunchPlan{ pasta_command: [String.t()], pidfile: String.t(), pool_port: :inet.port_number(), resolver: ExSandbox.Egress.Netns.resolver(), source_key: ExSandbox.Egress.Policy.source_key(), tenant_command: [String.t()] }
Functions
@spec build( ExSandbox.Egress.Policy.source_key(), :inet.port_number(), [String.t()], keyword() ) :: {:ok, t()} | {:error, refusal()}
Builds the plan for one sandbox, or refuses.
tenant_command is the fully composed confinement command — the output of
ExSandbox.Hardening.Linux.build_command/2 — which this rewrites to run
under pasta instead of unsharing an empty namespace.
Options
:pidfile— wherepastarecords its host-side pid.:resolver—{address, port}a sandbox may send UDP to, ornilfor none. Defaults toExSandbox.Egress.Resolver.resolver_address/0. ⚠️nildrops all UDP, which includes DNS. That is default-deny and not a degradation, but it is not a resolver either: passnilonly when the sandbox is meant to have no name resolution at all. ⚠️ An address that cannot be read raises — see the note at the call site.
@spec default_pidfile(ExSandbox.Egress.Policy.source_key()) :: String.t()
Where pasta records its host-side pid for this sandbox.
⚠️ The file contains pasta's pid, not the tenant's. See
ExSandbox.Egress.Pasta for why the difference is a silent catastrophe
rather than a detail.
@spec redirect_steps(t(), pos_integer()) :: [[String.t()]]
The redirect steps for a plan, once the namespace holder is known.
⚠️ Deliberately not a field on the struct. The holder pid does not exist
when the plan is built — the namespace it names is created by running the
plan. A field would have to be nil at build time and filled in later, and
the failure mode of that shape is a plan whose steps were composed against
nil and quietly target the wrong namespace.
Requiring the pid as an argument means there is no way to ask for these commands without having one.