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-netwould 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.
Summary
Types
How to launch the confined process.
Functions
Builds a launch specification confining command to permit_path.
Resolves command the way the kernel will: a bare name through PATH, then
the symlink chain, component by component.
Types
@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 ExSandbox.Hardening.launch_spec/0: the caller
launches this, not the command it asked about, because the returned cmd
is the confinement wrapper.
Functions
@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 chdirs 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 thereSame directory, same profile, different result — so sh under confinement
wrote a diagnostic to stderr in an umbrella mix test (Mix chdirs 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.
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.