OS-level confinement for the BEAM mechanism on Linux (005 T004 – T012, contracts/hardening.md, research R2, R3, R9).
The security boundary is here, not in the BEAM
Nothing inside the Erlang VM can cap a process's memory or deny it a socket.
005 settled that the boundary is the operating system, so this module
composes five kernel-enforced constructions and refuses to launch when it
cannot build all of them.
systemd-run --scope -p MemoryMax=… -p CPUQuota=… # FR-008
setpriv --reuid=… --regid=… --clear-groups # FR-007
bwrap --ro-bind / --bind <storage> # FR-010
env -i <granted…> BINDIR=<…> # FR-004
<erlexec> <peer args>Refusal, never degradation
build_command/2 returns {:error, :hardening_unavailable} when any
capability is missing. It never emits a command with a layer omitted, because
that failure is invisible: the sandbox launches, the happy path is identical,
and the only difference is whether tenant code is confined.
This is why available?/0 requires all five capabilities rather than
most. Cgroup caps without privilege separation still permits reading platform
files — "mostly hardened" describes a host that is not hardened.
Probes attempt; they never infer
Every probe below performs the operation it reports on. Reading :os.type()
would report true on a Linux host with no cgroup delegation, no setpriv,
or an unprivileged process — which is exactly the silent-failure mode R9
exists to prevent, and exactly what an operator cannot see.
What verify_applied/1 can and cannot establish
It reads the limits in force on the running process — the cgroup's effective values — rather than the command that requested them. R9b measured a limiter invoked with correct arguments, present in the process tree, named in configuration, and silently not applied. Reading back the request would have reported success.
Inspection is still not proof that a limit stops a breach; establishing that
is the conformance suite's job (012-FR-012a). The two are complementary and
neither substitutes for the other.
Summary
Functions
True only when every capability is present.
Builds the command that launches a sandbox under full confinement.
Probes what this host can actually enforce.
The command build_command/2 would emit, without the availability gate.
Create this sandbox's writable storage, owned by the uid it will drop to (FR-009, FR-010).
The systemd scope unit a sandbox runs in.
Where a sandbox's writable storage is bound, on the host and inside the
sandbox alike — bwrap binds it at the same path on both sides.
Reads the confinement actually in force on a running sandbox process.
Types
Functions
@spec available?() :: boolean()
True only when every capability is present.
There is no partial state: see the moduledoc.
@spec build_command(ExSandbox.Sandbox.t(), [{String.t(), String.t()}]) :: {:ok, {String.t(), [String.t()]}} | {:error, :hardening_unavailable | :invalid_limits | {:forbidden_env, [String.t()]}}
Builds the command that launches a sandbox under full confinement.
granted_env is an allowlist — the sandbox receives these pairs and
nothing else. Returns {:error, :hardening_unavailable} when any capability
is missing, and {:error, {:forbidden_env, keys}} when granted_env carries
a platform-shaped secret.
@spec capabilities() :: capability_map()
Probes what this host can actually enforce.
Probed once at gateway startup rather than per provision — the answer is a property of the host, and probing per launch would put shell-outs on the provisioning path for a value that does not change.
@spec compose_for_inspection(ExSandbox.Sandbox.t(), [{String.t(), String.t()}]) :: {:ok, {String.t(), [String.t()]}} | {:error, term()}
The command build_command/2 would emit, without the availability gate.
Public for one reason: the command's shape must be testable on hosts where
hardening is unavailable, which is every developer machine that is not Linux.
The alternative is that env -i -- one word, easily dropped, and invisible
when missing because the sandbox still launches -- goes unverified until CI.
Callers must use build_command/2. This deliberately skips the availability
check, so a caller reaching for it directly would build exactly the degraded
command R9 forbids.
@spec prepare_storage(ExSandbox.Sandbox.t()) :: :ok | {:error, term()}
Create this sandbox's writable storage, owned by the uid it will drop to (FR-009, FR-010).
Called before build_command/2's output is spawned. bwrap refuses a bind
whose source does not exist -- "Can't find source path ..." -- so without this
every launch exits 1 having constructed a perfectly correct command.
Ownership matters as much as existence. Created as root and left that way, the
sandbox drops privilege and cannot write to its own storage: the launch
succeeds and every write fails, which is harder to diagnose than an outright
refusal. Mode 0o700 keeps it to the owning uid, since every sandbox on a
gateway shares this root under a different uid.
@spec scope_unit_name(ExSandbox.Sandbox.t()) :: String.t()
The systemd scope unit a sandbox runs in.
Public because provision_failure_reason/1 must name the same unit to read
its Result after death, and a mismatch between the two would silently report
every breach as a platform fault.
@spec storage_path(ExSandbox.Sandbox.t()) :: String.t()
Where a sandbox's writable storage is bound, on the host and inside the
sandbox alike — bwrap binds it at the same path on both sides.
Public so callers and tests name the real location rather than assuming one. A
test writing to a made-up path gets :enoent, which satisfies "the sandbox
could not write here" just as well as a quota would — so the assertion passes
while measuring nothing.
@spec verify_applied(integer()) :: {:ok, map()} | {:error, {:not_applied, atom()} | :not_applied | :unverifiable}
Reads the confinement actually in force on a running sandbox process.
{:error, :not_applied} means the process is running unconfined and the
caller must terminate it — a running unconfined sandbox is what Principle II
forbids. {:error, :unverifiable} means this host cannot answer, which is
never reported as :ok.