# `Nous.Sandbox.Bwrap`
[🔗](https://github.com/nyo16/nous/blob/v0.17.1/lib/nous/sandbox/bwrap.ex#L1)

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-parent

Some 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`:

  * `/tmp` is 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!/0` is **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.

# `grade`

```elixir
@type grade() :: :full | :partial
```

How much fencing this host's `bwrap` can actually apply. See the moduledoc.

# `confine`

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

# `executable`

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

# `grade`

```elixir
@spec grade() :: grade()
```

The grade discovered by `probe/1`, or `:full` before any probe has run. Pure
`:persistent_term` read.

# `probe`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
