1.2.0 — 2026-08-29
A mechanism can report where a sandbox is reachable
ExSandbox.Mechanism gains address/1, an optional callback returning
{:ok, String.t() | nil}. Every existing mechanism keeps compiling, and a consumer that derives
the required callback set from behaviour_info(:callbacks) -- behaviour_info(:optional_callbacks)
sees no change.
{:ok, nil} is the ordinary answer for a sandbox that is not reachable — it names no port, it is
not running, or the mechanism cannot publish one. It is deliberately not an error: a caller that
had to rescue in order to render a stopped sandbox would eventually render something else.
ExSandbox.Mechanism.Beam returns nil. It has a "peer:<id>" handle and that handle is not an
address; returning it would put a broken frame in front of a person instead of a clear absence.
ExSandbox.address/2 is the wrapper hosts call. A mechanism that does not implement the optional
callback answers {:ok, nil} through it, the same shape as one that implements it and has nothing
to report — so a host asking "where is this" never has to check first whether asking is possible.
ExSandbox.Sandbox gains service_port, and it decides the container's network posture
service_port is the port an application inside the sandbox listens on. It is not opaque: a
mechanism reads it and acts on it.
service_port: nil—ExSandbox.Mechanism.Dockerpasses--network none, exactly as before. A host that never sets the field keeps the posture it has today.service_port: <port>— the container joins the default bridge and that port is published to127.0.0.1on an ephemeral host port the daemon allocates.address/1reads back what was allocated, so two concurrent provisions cannot be handed the same host port.
⚠️ What the second posture gives up, stated rather than implied. A bridge network means
outbound access, so the deny-by-default posture does not hold for a sandbox that names a port:
code inside can install dependencies at runtime and can reach the internet, and
ExSandbox.Egress's allowlist does not apply to it. What still constrains it is unchanged —
filesystem and process confinement, and the memory and CPU caps applied at create time. Inbound
is narrower than deny-all suggests rather than wider: one port, bound to loopback, reachable from
the host running the platform and from no other machine.
1.1.0 — 2026-08-29
⚠️ On Linux, egress now needs a C compiler at build time instead of python3 at runtime
Mix.Tasks.Compile.NetnsNif builds c_src/netns_nif.c into priv/netns_nif.so in the
consumer's own tree. It is skipped off Linux, and a missing compiler is a warning, not a build
failure — but on a Linux host without one, ExSandbox.Egress.NetnsSocket.available?/0 is false,
the network_restriction capability is not constructed, and ExSandbox.Mechanism.Beam refuses to
launch any sandbox that requires it. The refusal names itself; it does not fail open.
CI installs gcc where it used to install python3. A deployment that pinned the runtime image's
package list should make the same swap.
The in-namespace acceptor is no longer a separate process
The enforcement point for egress has to be a socket inside the sandbox's network namespace: an
nft redirect is DNAT to the local machine as that namespace sees it, so it can only reach a
socket there. The BEAM runs in the host namespace and no option to :gen_tcp.listen/2 changes a
socket's namespace, so the listener was a Python helper entered with nsenter.
The third premise is true and irrelevant, which is what was missed. setns(2) with CLONE_NEWNET
affects only the calling thread, so the socket can be created in the sandbox's namespace on a
thread of its own and the descriptor adopted with {:fd, Fd}. Measured in the isolation image:
listener adopted from the namespace fd {:ok, {{0, 0, 0, 0}, 9200}}
connect from the HOST namespace {:error, :econnrefused}
connect from INSIDE the namespace received its bytes
SO_ORIGINAL_DST on the accepted socket readableThe econnrefused is the load-bearing half: that port does not exist in the host namespace, so
the socket demonstrably is not there.
Because the acceptor is now a process on this node, ExSandbox.Egress.Decision.decide/3 is an
ordinary function call. Everything that existed only to bridge the process boundary is gone rather
than simplified: the AF_UNIX verdict socket and its wire format, a second AF_UNIX socket and
length-prefixed frame for DNS, the sandbox's identity passed on argv, a readiness line parsed
off stdout, and the chmod widening whose absence silently dropped every datagram. Net −713 lines.
All private modules — none appeared in priv/boundary.md, so the package contract is unchanged.
ExSandbox.Egress.Pool is now ExSandbox.Egress.Decision, and holds no socket
The pool supervised a listener on 127.0.0.1 in the host namespace, which the paragraph above
explains can never receive a redirected connection. Its own moduledoc said so, and named the
condition for removing it: "If this comment outlives the tests that justify it, the listener
should go."
⚠️ It did, in the way that matters most. The two test files justifying the listener were driving
that copy of the accept-decide-relay path, while ExSandbox.Egress.Acceptor — the copy every
tenant connection actually reaches — had two tests. Correct tests over unreachable code: the same
defect species as the unsupervised pool and the unreferenced Binding, with the polarity
reversed. Both files now stand over the acceptor, which is a coverage increase rather than a move.
Deleted: the listener, the accept loop, port/1, handle_connection/3, relay/2, and the entry
in the application supervision tree. decide/3 remains, and is still the single implementation of
the allowlist question. The module was renamed because a module with one function and no socket
should not be called a pool. All private — nothing here appears in priv/boundary.md.
⚠️ Three supervision tests went with the listener: that it was a supervised child, that it started after the registry, and that it bound a real port. All three were true and none meant anything — they described a socket nothing could reach. What they were really asking is now answered at launch rather than at boot, and a new test pins that an acceptor which cannot enter its namespace binds nothing rather than falling back to the host.
A socket-ownership race in the acceptor, found by moving those tests
accept_loop/1 started the per-connection handler and then transferred socket ownership to it.
:gen_tcp.recv/3 on a passive socket is refused for any process that is not the controlling one,
so a handler that won the race tore the connection down before a byte moved. From inside the
sandbox that is a permitted destination behaving exactly like a denied one, leaving a single
:einval deep in the relay as its only trace.
The handler now waits to be told it owns the socket. The pool never hit this because it transferred ownership to the relay task rather than to the handler; the bug arrived with the acceptor and would not have been visible without repointing the tests onto it.
SO_MARK on the relay's upstream socket was failing open
The acceptor's own upstream connect is caught by the redirect it exists to serve, so it needs
meta mark 42 return to exempt itself. ExSandbox.Egress.Relay set that mark with
raw: {1, 36, _} on :gen_tcp.connect/4. Measured with CAP_NET_ADMIN and CAP_NET_RAW dropped:
the connect returns :ok, :inet.setopts/2 returns :ok, and reading the option back yields
<<0, 0, 0, 0>>. :inet swallows the kernel's EPERM. The Python helper failed closed only
because CPython raised OSError on the same call.
⚠️ The symptom of a lost mark is a permitted destination timing out, which reads as an unreachable network rather than as a broken enforcement point — and every denial check still passes, because denial is unaffected.
The mark is now written, read back, and compared inside the NIF before any descriptor is returned:
{:error, :setsockopt_mark, 1} under the same capability drop, and no fd. An unmarked upstream is
unrepresentable rather than merely unlikely. The test that guarded this previously asserted the
presence of the option that was the bug, so it passed in exactly the configuration where the
mark was silently dropped.
The citations in this library's comments now resolve, and a test keeps them resolving
This codebase's convention is that a claim about behaviour names the thing that measured it, so
comments cite probe scripts and contract documents by filename. The extraction from the Axonn
umbrella brought the code and not that tree, which left 78 backticked names pointing at nothing --
including contracts/egress.md, cited in the opening line of most ExSandbox.Egress moduledocs
and therefore on the published page for each of them.
docs/provenance.md now says where each family of names went and, for the umbrella ones, states plainly that nothing here reads them at build or run time. It ships in the package and is linked from the docs.
test/documentation_pointers_test.exs enforces it: a backticked filename must resolve in the tree
or be declared absent, and a declared absence must be explained in that document. Written because
renaming ExSandbox.Egress.Pool left three stale pointers that compile warnings and
mix docs --warnings-as-errors both passed over -- one of them a present-tense claim that a
renamed-away test file covers the decision.
A renamed module leaves the same wreckage, and ExDoc does not catch it
Renaming ExSandbox.Egress.Pool also left six present-tense claims that live code calls the
pool's decide function, in two library modules, the census baseline and three test files. ExDoc
autolinks a fully qualified module in backticks and fails the build when it cannot resolve one,
which is why the rename's qualified references were caught at the time. An unqualified alias is not
autolinked, so every one of these passed mix docs --warnings-as-errors.
All six corrected. test/documentation_pointers_test.exs now also bans the unqualified form. A
removed module is written with the qualifier it had, so Egress.Pool.relay/2 reads as history and
an unqualified mention is always a pointer at something gone. This paragraph is subject to the same
rule, which is how the rule was found to apply to prose about the rule.
ExSandbox.Capability.satisfied?/1 documented a role it does not have
Its docstring said "this is what an entry point calls before starting a sandbox". This library's
entry points do not call it. ExSandbox.provision/2 and ExSandbox.start/2 gate on a private
ensure_capable/2 built on missing/1, because a refusal has to name which capability was absent.
The function is unchanged and still public; only the docstring was wrong, and a reader following it
looked for the gate in the wrong place.
Also removed: ExSandbox.Conformance.Execution.long_line_bytes/0, a @doc false accessor for a
module attribute that nothing read.
Two conformance checks credited a refusal that distinguished nothing
reaching another sandbox over the network is refused passed against any mechanism whose published
address had nothing listening on it. A refusal at a dead port is what a mechanism with no
boundary produces, so the green tick reported an enforcement point that had never refused anything.
test/conformance_network_meta_test.exs required that pass, which is a test pinning the defect the
check exists to catch (029 T034d).
Both checks now probe the address from the platform before crediting a refusal, and report the third outcome when nothing answers there.
⚠️ The control runs after the attempt, not before it, and the order is the whole of it. Gating
first was measured here and is a real weakening. OpenNetworkMechanism declares an address and a
connect that reports success for every destination, and probing liveness first short-circuits
before connect is ever called, so a mechanism that declared a boundary and let everything through
is filed as unavailable instead of as the breach it is.
The same latent fault was measured in every published handle of another sandbox is refused from inside, which had gated first since it was written. A mechanism crossing a dead handle reported the
third outcome rather than a violation. Reordered, and a new meta-test pins it: a crossing is a
breach at a live handle and a dead one alike, and only a refusal has to prove it distinguished
something.
⚠️ Neither change moves the census. ExSandbox.Mechanism.Beam publishes "peer:" <> id rather than
a dialable tuple, so both checks already reported the third outcome against it and still do.
One guarantee is now demonstrated less well, and the census records it
ExSandbox.Mechanism.Beam published the verdict socket's path as context.policy_handle, and
FR-011b demonstrated "a tenant cannot widen its own allowlist" by attempting to write it from
inside a sandbox. There is no socket any more, and no filesystem artefact of any kind carries the
allowlist — it lives in ExSandbox.Egress.Registry, in this BEAM's memory, which a tenant has no
route to.
The guarantee is stronger and the evidence for it weaker. docker/census-baseline.txt was raised
from 8 to 9 with the reason recorded there, because a ceiling that moves without one is how a
suite reports fewer guarantees every release and stays green the whole way.
1.0.1 — 2026-08-28
boundary.md moved to priv/, so the documented lookup resolves
1.0.0 shipped docs/boundary.md and told consumers to read it with
Application.app_dir(:ex_sandbox, "docs/boundary.md"). That call cannot work. Mix links exactly
ebin and priv into an application's build directory, so a file shipped under any other
top-level directory is present in the tarball and absent from app_dir/2 -- and app_dir/2 is
the only path a consumer has at runtime.
Found the first time a consumer actually made the call: File.exists? on the documented path
returned false against an installed 1.0.0, while tar tzf on the same release listed the file.
A packaging check that stops at "is it in the tarball" cannot see this, because the tarball was
never the thing that was wrong.
The file is now priv/boundary.md. Its content is unchanged.
If you read the 1.0.0 path, update the call:
# before -- returns a path that does not exist
Application.app_dir(:ex_sandbox, "docs/boundary.md")
# after
Application.app_dir(:ex_sandbox, "priv/boundary.md")Nothing else changed: no module, function, behaviour or configuration key differs from 1.0.0.
1.0.0 — 2026-08-28
Extracted from the Axonn umbrella; first public release
This library was an application inside a larger umbrella project. It is now its own repository and
its own Hex package, with its own lockfile, config, CI and isolation harness. The extraction
preserved history (git subtree split, 143 commits) rather than re-creating the tree.
What changed for a consumer, as opposed to for the umbrella:
storage_rootnow defaults to/var/lib/ex_sandbox/sandboxes, was/var/lib/axonn/sandboxes. ⚠️ For an existing deployment this is a data migration, not a cosmetic rename — sandbox storage moves. Setconfig :ex_sandbox, :beam, storage_root: "/var/lib/axonn/sandboxes"to keep the old path.- The verdict socket's default prefix is
ex-sandbox-, wasaxonn-. - The dependency tree is
:telemetryand nothing else, enforced bytest/dependency_tree_test.exs, and the Elixir floor is~> 1.14rather than the platform's version. - The conformance suite's credentials group reports
capability_unavailablehere rather than passing, because this package has no data store to probe (012-FR-001). A consumer with a database instantiates the same suite with its own probe. The identifiers are explained in docs/requirement-ids.md.
No public function, callback, struct field or telemetry event changed in the move.
ExSandbox.Mechanism gained an optional callback, and the gate changed shape
ExSandbox.Mechanism.constructed_capabilities/0 declares the capabilities a
mechanism builds for whatever it runs — the opposite claim from
ExSandbox.Mechanism.required_capabilities/0, which says what it needs from
the host. ExSandbox's private ensure_capable/2 now subtracts the second list from the
first and asks the host probe only about the remainder.
⚠️ This is a change to a refusal, which is why it is a major version. The callback is optional and a mechanism that omits it is gated exactly as before, so nothing in the tree breaks — but a mechanism that declares one is now admitted on a host where it was previously refused, and a gate that admits more than it used to is a behavioural change to the thing this library exists to do. It is stated here rather than in the additive column.
The claim is not verified by the behaviour. ExSandbox.Conformance is what
establishes it, by observing a breach being stopped; until a mechanism is run
through the suite, what backs its list is whatever tests accompany it.
ExSandbox.Mechanism.Docker
A mechanism backed by a container runtime, for hosts whose own kernel cannot
construct the confinement ExSandbox.Mechanism.Beam requires — every macOS
host, where all five gating capabilities report unavailable and Beam is
therefore refused before it is reached.
It declares :resource_limits, :filesystem_confinement and
:network_restriction as constructed, each backed by an observed breach in
test/mechanism/. It deliberately claims neither
:disk_quota — MEASURED accepted-and-ignored on overlayfs — nor
:privilege_separation; both omissions are stated reductions and are documented
on the module.
ExSandbox.Sandbox gained workspace_path
An absolute host directory the sandbox's contents live in, supplied by the host
and made reachable from inside by whatever means the mechanism has. Additive:
nil means "no workspace", which a mechanism must read as mount nothing
rather than as mount somewhere sensible.