# `ExSandbox.Hardening.Confinement`
[🔗](https://github.com/FoundryStack/ex_sandbox/blob/v1.2.0/lib/ex_sandbox/hardening/confinement.ex#L1)

Confines a **control-plane** process to one filesystem path (015 T107, from
research R16 and R30).

## This is the mirror image of a sandbox, not a smaller one

`ExSandbox.Hardening.Linux` confines untrusted tenant code. This confines a
*trusted* process that holds a credential — the delegated Claude Code CLI —
and the difference is not degree but direction. R16 found that the sandbox
guarantee never covered it: `confinement_args/2` binds storage into the
**sandboxed process's** mount namespace (`linux.ex:316-345`), and a
control-plane process is not in that namespace, so every sandbox's storage is
an ordinary host path to it. `storage_path/1` makes them siblings under one
root (`linux.ex:1101`), so the next tenant along is a `..` away, and above
that sits the platform's own deployment unit — database, billing, and model
credentials, per `013`.

## ⚠️ The posture is INVERTED on both counts, which is why this is a separate module

A tenant sandbox strips credentials and denies the network. This profile must
do neither:

  * **The environment passes through**, credential included. The CLI cannot
    reach the model without it.
  * **Egress is permitted.** `--unshare-net` would deny the model endpoint.

⚠️ **It is a distinct entry point rather than a flag on the tenant profile,
and that is a safety property.** A boolean like `strip_credentials: false`
would put unstripping a *tenant* sandbox one wrong argument away. There is no
argument here that can widen `ExSandbox.Hardening.Linux`, because this code
never touches it.

⚠️ **`@forbidden_env_fragments` is NOT widened to admit the credential.** R14
requires "a deliberate separate channel, never a weakening of that list", and
that list is what keeps credentials out of every sandbox (`015-FR-005`). This
module is that separate channel.

## Both platforms enforce it — measured, not assumed (R30)

T107 named `bwrap` and `sandbox-exec` as though symmetric, and the honest
expectation was that macOS would have to refuse: `012` R9b measured
`sandbox-exec`'s *sibling* mechanism, `taskpolicy -m`, failing **open** across
an intervening exec.

⚠️ That expectation was wrong, and inheriting it would have shipped a needless
refusal. R30 measured a deny-by-default `sandbox-exec` profile on darwin
25.5.0: the permitted path readable and writable, a sibling denied, `..`
denied, the restriction surviving **three** nested execs, not widenable by
re-invoking `sandbox-exec` under itself, and not defeated by a symlink out of
the permitted subtree. A credential-shaped variable survived.

⚠️ **This does not contradict `capability.ex`'s macOS refusals, and must not be
gated on them.** `:filesystem_confinement` reports unavailable on macOS
because it means *the mount namespace the BEAM mechanism binds with*
(`capability.ex:182-199`) — a statement about the tenant profile's
construction, not about whether macOS can restrict paths. Requiring that
capability here would refuse on macOS for a reason that does not apply, which
is precisely the error its own comment warns about one capability over: *"a
mechanism that exists, looks applicable, and is not the one in the path being
taken."* `:network_restriction`'s macOS refusal stands and is irrelevant —
this profile **wants** egress.

## ⚠️ A profile that names too little kills the process instead of confining it

R30's first two attempted profiles exited **134 on every case, including the
control**: the runtime's own read set was not permitted, so the process died
before reaching any path. Every breach looked "denied". A suite asserting only
on denials would have reported a boundary that did not exist.

This is why `runtime_read_paths/0` below is generous about the *runtime* while
the *data* boundary stays a single path, and why `ExSandbox.Hardening.ConfinementTest`
runs a control first. The two are one mechanism: a permit list wide enough to
execute, a data boundary narrow enough to matter.

## A working directory is not a boundary (T109)

⚠️ `:cd` decides where a process **begins**, never where it can go. `015` T109
VERIFIED that no caller passes it at all, so today's CLI inherits the BEAM's
own working directory — the platform's deployment root — meaning it starts
*among* the files this module exists to put out of reach. Setting `:cd` to a
tenant's directory would improve where it starts and confine nothing; there is
no `--cwd` flag on the CLI (T045), so scope is decided at launch or nowhere.
Callers MUST treat `permit_path` as the boundary and `:cd` as a convenience.

# `launch_spec`

```elixir
@type launch_spec() :: %{
  cmd: String.t(),
  args: [String.t()],
  env: [{String.t(), String.t()}],
  cd: String.t()
}
```

How to launch the confined process.

Deliberately the same shape as `t:ExSandbox.Hardening.launch_spec/0`: the caller
launches **this**, not the command it asked about, because the returned `cmd`
is the confinement wrapper.

# `confine`

```elixir
@spec confine({String.t(), [String.t()]}, keyword()) ::
  {:ok, launch_spec()} | {:error, {:cannot_enforce, atom(), String.t()}}
```

Builds a launch specification confining `command` to `permit_path`.

## Options

  * `:permit_path` (required) — the one directory the process may read and
    write. Everything else is denied, including sibling directories and any
    path reachable by `..`.
  * `:env` — environment passed through **unmodified**, credential included.
    Defaults to `[]`.
  * `:cd` — working directory. A starting point, not a boundary; see above.
    Defaults to the resolved `:permit_path`, and the caller has to launch
    with it — see "⚠️ The working directory is part of the boundary".
  * `:permit_extra_subpaths` — additional directories the process may read and
    write, **on top of** `:permit_path`. Defaults to `[]`, which is the only
    value this library ever chooses for itself.

## ⚠️ The working directory is part of the boundary

`:cd` used to default to `nil`, leaving the child in whatever directory the
caller happened to be in. That is not neutral. The directory is denied by the
profile by construction — it is not `:permit_path` — so a confined process
started there resolves every relative path outside its own boundary, and on
darwin cannot read its own working directory at all.

MEASURED 2026-08-25, and the shape of the measurement is the interesting
part. A VM started in X that `chdir`s to Y before spawning gives the child a
cwd of Y that the child cannot `getcwd`:

    cwd=…/apps/ex_sandbox status=0 out=""                      # started there
    cwd=…/apps/ex_sandbox status=0 out="shell-init: error …"   # chdir'd there

Same directory, same profile, different result — so `sh` under confinement
wrote a diagnostic to stderr in an umbrella `mix test` (Mix `chdir`s into
each app) and wrote nothing at all when the same suite ran from the app's own
directory. `ConfinementExtraSubpathsTest` failed on exactly that, and reading
it as a flake was wrong: it was reproducible from one directory and
unreproducible from the other.

Defaulting to `:permit_path` puts the child inside the one directory the
profile fully grants. `Axonn.ModelAccess.Backend.DelegatedCli` already passed
`cd: storage_path` by hand, which is the same value — so this makes the
contract say what the only production caller had already worked out, rather
than leaving each caller to rediscover it.

## ⚠️ `:permit_extra_subpaths` is opaque here, and that is the whole design

This library does not know, and must not learn, which program is being
confined or why one of them needs a path outside its own storage. `012` is an
extraction boundary: naming a particular CLI's scratch directory here would
put an application's workaround inside a generic hardening module, where the
next reader cannot tell a measured necessity from an accident.

So the list arrives already decided. The caller is what reads its own
configuration, derives whatever the path depends on, and answers for the cost
of each entry. Every entry widens the boundary this module exists to draw —
`[]` is the default precisely so that widening is something a caller has to
write down.

Each entry is resolved the way `:permit_path` is, and is emitted **after** the
blanket deny, because later rules win in a `sandbox-exec` profile.

Returns `{:error, {:cannot_enforce, capability, detail}}` when this host
cannot build the profile, matching `ExSandbox.Hardening`'s contract. It never
returns a spec with the confinement omitted — that is the fail-open shape
`005` R9b measured, and it is indistinguishable from success at every layer
that does not attempt a breach.

# `resolve_executable`

```elixir
@spec resolve_executable(String.t()) :: String.t()
```

Resolves `command` the way the kernel will: a bare name through `PATH`, then
the symlink chain, component by component.

Public because the grant and the invocation **must agree**, and they are
written in two different places. MEASURED on Linux (D14a): binding the
symlink's *target* while invoking the *symlink* fails outright. Resolving
first is what deletes that failing case, and a second implementation of the
rule is how the two ends drift back apart.

⚠️ macOS makes the same demand from the other direction: SBPL **resolves
symlinks before matching a path filter**, so a grant written against a
symlink silently never matches. Nothing fails; the path is simply denied.

Returns `command` unchanged when it is not on `PATH` and cannot be resolved,
so an unlaunchable command still reaches the exec and earns the operating
system's own error rather than a substituted one.

---

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