Linux confinement via bwrap (bubblewrap).
confine/2 wraps the caller's argv as
bwrap <base args> <mode args> -- <argv...>which is the invocation shape bubblewrap documents:
bwrap [OPTIONS...] [--] COMMAND [ARGS...].
The fence is on writes, not reads
The base binds / read-only over itself (--ro-bind / /), so the child can
read the entire host filesystem exactly as it could unconfined. What it
cannot do is write outside the mounts re-bound read-write below. Like
Nous.Sandbox.Seatbelt, this is a write fence, not a confidentiality
boundary.
Grades
grade/0 is :full on a host that can mount /dev and /proc and unshare
the pid namespace:
--ro-bind / / --dev /dev --proc /proc --unshare-pid --die-with-parentSome hosts — unprivileged containers, hardened kernels — run bwrap but
cannot create those mounts. probe/1 retries there with the reduced base and
memoizes :partial:
--ro-bind / / --die-with-parent--unshare-pid is deliberately absent from the reduced base: a host that is
not allowed to mount /proc is unlikely to be allowed to unshare pid either,
so keeping the two together lets one probe failure cover both.
Which namespaces are shared with the host
Two namespaces are ours: mount (implicit — it is what every --ro-bind,
--bind and --tmpfs operates in) and, at :full, pid. Bubblewrap also
creates a user namespace when it is not setuid, purely to obtain the
privilege to build the mount namespace; it maps our uid to itself, so it
isolates nothing on its own. net, ipc, uts and cgroup are shared with the
host: the child sees the host's interfaces and reaches the network, shares
abstract unix sockets and SysV IPC, and reports the host's hostname.
Sharing the network namespace is the deliberate one. This provider is a write
fence, and so is Nous.Sandbox.Seatbelt, which does not restrict network
either; unsharing net here would make the same curl succeed on macOS and
fail on Linux, which is the divergence the seam exists to prevent. ipc and
uts stay shared because nothing above the seam depends on them and every
extra --unshare-* is one more way for a restricted host to fail the probe
and drop to :partial.
Why --proc requires --unshare-pid
--proc /proc mounts a fresh procfs, but the pid namespace it describes is
still the host's unless pid is unshared. The child then sees every same-uid
process on the box — including the BEAM that spawned it — and resolution of
/proc/<pid>/root/... happens in that process's mount namespace, which is
the host's, where / is read-write. So
echo x > /proc/<beam-pid>/root/$HOME/.bashrc writes straight through the
fence: ptrace_may_access gates it on uid only, and the implicit user
namespace maps our uid to itself. /proc/self/root is not an escape (it is
our own namespace); another PID's is. Mounting --proc without
--unshare-pid is a documented bubblewrap footgun, not a subtlety of this
module.
--unshare-pid also repairs --die-with-parent, which sets
PR_SET_PDEATHSIG on bwrap itself rather than on the tree: with no
pid-namespace init, a backgrounded grandchild outlives both bwrap's death and
a SIGKILL of the BEAM. As namespace init, bwrap takes the whole tree with
it.
Honest divergence from Nous.Sandbox.writable_roots/1
Under :workspace_write this appends --tmpfs /tmp --bind <root> <root>.
That is deliberately not the same set as Nous.Sandbox.writable_roots/1:
/tmpis a fresh, empty tmpfs, not the host's/tmp. Writes there are writable and ephemeral, and the host's temporary files are invisible to the child.System.tmp_dir!/0is not separately bound. Where it differs from/tmp(it usually does not on Linux), it stays read-only.
Seatbelt's profile enumerates the canonical roots directly; bwrap's mount
namespace gives a stronger, differently shaped guarantee. Neither is wrong,
but the argv here cannot be compared element-wise against writable_roots/1.
Discovery lives in probe/1
confine/2 must stay pure, so it never calls System.find_executable/1.
probe/1 resolves the absolute path and the grade once and memoizes both in
:persistent_term; executable/0 and grade/0 are the pure reads, with
defaults ("/usr/bin/bwrap", :full) for a caller that confines before
anything probed — which is exactly what pinning :sandbox_backend in config
does, since that path never calls probe/1. See executable/0 for why that
default must be an absolute path.
Summary
Functions
Wrap argv in bwrap under policy. Pure — reads memoized discovery
results, builds a list, and returns.
The absolute path to bwrap discovered by probe/1, or "/usr/bin/bwrap"
before any probe has run. Pure :persistent_term read.
The grade discovered by probe/1, or :full before any probe has run. Pure
:persistent_term read.
Locate bwrap, determine its grade, and memoize both.
Types
Functions
@spec confine([String.t()], Nous.Sandbox.Policy.t()) :: {:ok, Nous.Sandbox.Confined.t()}
Wrap argv in bwrap under policy. Pure — reads memoized discovery
results, builds a list, and returns.
@spec executable() :: String.t()
The absolute path to bwrap discovered by probe/1, or "/usr/bin/bwrap"
before any probe has run. Pure :persistent_term read.
The default is absolute on purpose. A bare "bwrap" is resolved by the port
through the PATH the child inherits, and that PATH routinely carries
user-writable entries (~/.asdf/shims, ~/.bun/bin, ~/.cargo/bin,
~/.local/bin — 30-odd on a developer machine). A security runner that a
poisoned PATH can substitute is not a security runner, which is the same
reason Nous.Sandbox.Seatbelt pins /usr/bin/sandbox-exec. This default is
reachable in practice: pinning :sandbox_backend in config skips probe/1,
so nothing ever memoizes a resolved path. probe/1 still memoizes whatever
System.find_executable/1 finds, which is what a distro-packaged or Nix-store
bwrap needs.
@spec grade() :: grade()
The grade discovered by probe/1, or :full before any probe has run. Pure
:persistent_term read.
@spec probe(pos_integer()) :: {:ok, grade()} | {:error, :unusable}
Locate bwrap, determine its grade, and memoize both.
Each candidate base is probed with @probe_script, which asserts that a
permitted write to /dev/null succeeds and that a write to a path under
System.tmp_dir!/0 — which no base arg re-binds read-write — is refused. Exit
0 therefore means the mounts were actually applied, not merely that bwrap
accepted the flags.
Returns {:ok, :full} when the full base works, {:ok, :partial} when only
the reduced base does, and {:error, :unusable} when bwrap is absent or
neither base enforces.