ExSandbox.Hardening.Confinement (ExSandbox v1.0.1)

Copy Markdown View Source

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.

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

launch_spec()

@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

confine(arg, opts)

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

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

resolve_executable(command)

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