# `ExSandbox.Egress.LaunchPlan`
[🔗](https://github.com/FoundryStack/ex_sandbox/blob/v1.2.0/lib/ex_sandbox/egress/launch_plan.ex#L1)

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:

1. **launch** — `pasta` creates the namespace, configures it, and starts the
   tenant inside it,
2. **find the holder** — the tenant's pid, *not* pasta's (see
   `ExSandbox.Egress.Pasta`),
3. **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.

# `refusal`

```elixir
@type refusal() :: :no_network_confinement | :no_pool_port | :no_privilege_drop
```

Why a plan could not be built.

# `t`

```elixir
@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()]
}
```

# `build`

```elixir
@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` — where `pasta` records its host-side pid.
  * `:resolver` — `{address, port}` a sandbox may send UDP to, or `nil` for
    none. Defaults to `ExSandbox.Egress.Resolver.resolver_address/0`.
    ⚠️ **`nil` drops all UDP**, which includes DNS. That is default-deny and
    not a degradation, but it is not a resolver either: pass `nil` only 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.

# `default_pidfile`

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

# `redirect_steps`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
