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

OS-level confinement for subprocesses spawned by tools.

`Nous.Tools.Bash` hands the model a shell. `Nous.Permissions` decides *whether*
it may run and `Nous.Tools.PathGuard` fences the *file tools*, but neither
constrains what the shell itself touches once it is running. This module is
that constraint: a provider behaviour that **wraps argv** so the OS enforces
the policy.

## The shape

    {:ok, confined} = Nous.Sandbox.confine(["/bin/sh", "-c", cmd], policy)
    {output, status} = NetRunner.run(Nous.Sandbox.merge_stderr(confined.argv))
    Nous.Sandbox.classify(confined, status, output)

Three properties are load-bearing:

  * **`confine/2` builds argv and nothing else.** It never spawns, never
    mutates, and performs no filesystem access once the canonical temp roots
    are memoized (warmed at application start; see `writable_roots/1`). Path
    canonicalisation happens when the policy is built, not per call. Only
    `c:probe/1` (backend selection) and the caller's own spawn touch the OS.
  * **Fail closed.** With no usable provider, `confine/2` returns
    `{:error, {:sandbox_unavailable, mode, detail}}`. Silently running the
    command unconfined is never legal. `:danger_full_access` is the *explicit*
    way to ask for no confinement.
  * **Runner failure is classified before denial.** "bwrap could not create a
    namespace, so the command never ran" must not read as "confinement
    worked". See `Nous.Sandbox.RunnerFailureRule`.

## Providers

  * `Nous.Sandbox.Seatbelt` — macOS, `sandbox-exec` + an SBPL profile
  * `Nous.Sandbox.Bwrap` — Linux, `bwrap` from bubblewrap
  * `Nous.Sandbox.Unavailable` — the fail-closed default when nothing else
    probes clean (including all of Windows, where `chain/0` is `[]`)

`backend/0` probes `chain/0` once and memoizes the winner in
`:persistent_term`. Set `config :nous, :sandbox_backend, Module` to pin one
and skip probing entirely.

## Scope: subprocesses only

This confines processes Nous spawns. It is deliberately *not* the isolation
story for in-process evaluation — that is bounded by its own runtime guest.
The two are siblings, not nested.

`Nous.Tools.FileGrep` is a documented exemption: it spawns `ripgrep`, but
neither provider restricts reads (Seatbelt's profile is `(allow default)
(deny file-write*)`, bwrap binds `/` read-only), so confining a process that
only ever reads adds no enforcement — while fail-closed refusal would take a
working search tool away on hosts with no provider. See the note in that
module.

## What this is not

It is not a kernel boundary you may lean on for multi-tenant isolation, and
the TOCTOU window between `Nous.Tools.PathGuard.resolve_real/1` and the
child's own syscalls is exactly as open as it is in `PathGuard` today. It
raises the cost of a prompt-injected `rm -rf`; it does not make the host safe
to hand to an adversary.

# `backend`

```elixir
@type backend() :: module()
```

A provider module implementing this behaviour.

# `error`

```elixir
@type error() ::
  {:sandbox_unavailable, Nous.Sandbox.Policy.mode(), String.t() | nil}
  | {:runner_failed, Nous.Sandbox.Confined.enforcement(), String.t()}
  | {:sandbox_denied, Nous.Sandbox.Policy.mode(),
     Nous.Sandbox.Confined.enforcement()}
```

Return values — **not** raises. `Nous.Errors` exceptions are for the agent
loop; confinement failures are ordinary data a tool decides what to do with.

  * `{:sandbox_unavailable, mode, detail}` — no provider can confine this
    mode on this host. The caller MUST refuse to spawn.
  * `{:runner_failed, enforcement, fatal_line}` — the sandbox runner errored
     out; the command never ran.
  * `{:sandbox_denied, mode, enforcement}` — the command ran and the OS denied
    it something.

Every variant has a producer and a consumer. Speculative vocabulary is
deliberately absent: an unused tuple in a security type reads as an enforcement
point that exists, and the next caller will assume it does.

# `confine`

```elixir
@callback confine(argv :: [String.t()], policy :: Nous.Sandbox.Policy.t()) ::
  {:ok, Nous.Sandbox.Confined.t()} | {:error, error()}
```

Wrap `argv` so the OS enforces `policy`. Pure: MUST NOT spawn.

# `probe`

```elixir
@callback probe(timeout_ms :: pos_integer()) ::
  {:ok, :full | :partial} | {:error, :unusable}
```

Check whether this provider actually works on this host, within
`timeout_ms`. This is the one callback that may spawn.

`{:ok, :full}` means the provider enforces everything it claims;
`{:ok, :partial}` means it runs but with reduced fencing (a bwrap that cannot
mount `/proc`, say); `{:error, :unusable}` means do not select it.

# `backend`

```elixir
@spec backend() :: backend()
```

The selected provider.

Resolution order: the `:sandbox_backend` application setting, then the memoized
probe result, then a fresh probe of `chain/0`. Falls back to
`Nous.Sandbox.Unavailable`.

A **pinned** provider is never probed — pinning is the operator asserting the
host is capable, which also means the provider's `c:probe/1` side effects
(`Nous.Sandbox.Bwrap` discovers and memoizes its absolute path there) do not
happen. Pin for tests and for hosts you control; otherwise let the probe run.

# `chain`

```elixir
@spec chain() :: [backend()]
```

Candidate providers for this platform, best first. `[]` where no provider
exists (Windows), which resolves to `Nous.Sandbox.Unavailable`.

# `classify`

```elixir
@spec classify(Nous.Sandbox.Confined.t(), integer(), String.t()) :: :ok | error()
```

Interpret a finished confined process. Pure.

Returns `:ok`, `{:runner_failed, enforcement, line}`, or
`{:sandbox_denied, mode, enforcement}`.

Order is deliberate: runner failure first, denial second — "the runner broke,
so the command never ran" must never read as "confinement worked". Three
constraints keep that ordering from misfiring, all of them learned from real
output:

  * **Exit 0 is never anything but `:ok`.** A denial fails the command, so a
    successful command that merely *mentions* a signature — `cat`ting a log,
    grepping this very file — is not a denial.
  * **A fatal signature only counts at the start of a line.** A runner
    prefixes its own name (`bwrap: `, `sandbox-exec: `); a quoted mention
    inside a message does not sit at column 0.
  * **A line that matches both a fatal and a denial signature is a denial.**
    `sandbox-exec: sandbox_apply: Operation not permitted` — macOS refusing a
    nested-sandbox escape — is definitionally both, and it is a denial: the
    escape was *prevented*.

Within a runner failure rule, `informational_lines` are dropped by exact
case-insensitive full-line equality before `fatal_signatures` are matched, and
the **original** line is returned so the caller reports what the runner
actually printed.

Signatures come from the `Nous.Sandbox.Confined` itself, so one backend's
wording can never classify another backend's output.

## `:ok` means "no denial observed"

It does not mean "no denial occurred". The scan is over process output, and
denial wording is per-tool: `curl` says `Failure writing output to
destination`, `tar` says `Failed to open`, `sqlite3` says `unable to open
database file`, and anything the model writes with `2>/dev/null` says nothing
at all. Enforcement still held in every one of those cases — only the report
is silent. Treat the verdict as advisory, never as proof a write landed.

`output` is what the caller captured. With `NetRunner` that means spawning
through `merge_stderr/1`; its `stderr: :redirect` option does not exist.

# `confine`

```elixir
@spec confine([String.t()], Nous.Sandbox.Policy.t()) ::
  {:ok, Nous.Sandbox.Confined.t()} | {:error, error()}
```

Confine `argv` under `policy` using the selected backend.

`:danger_full_access` never reaches a provider: it returns the argv untouched
with `enforcement: :none` and no signatures, so `classify/3` on the result can
only ever answer `:ok`.

# `merge_stderr`

```elixir
@spec merge_stderr([String.t()]) :: [String.t()]
```

Wrap `argv` so the child's stderr is merged into its stdout.

`NetRunner` documents a `stderr: :redirect` option that does not exist: no
code branches on it, and passing it also disables the `:consume` drain, so a
child writing more than a pipe buffer to stderr would block forever. Without
a merge, `NetRunner.run/2` returns stdout only and every denial signature is
invisible — `classify/3` would answer `:ok` for a command the kernel refused.

The wrapper is a fixed literal script with the real argv passed as positional
arguments, so it adds no injection surface. `exec` replaces the outer shell,
which means fd2→fd1 is inherited by the *sandbox runner itself* — that is what
makes `{:runner_failed, _, _}` observable, since the runner prints its own
errors to its own stderr, not the confined command's.

## Examples

    iex> Nous.Sandbox.merge_stderr(["echo", "hi"])
    ["/bin/sh", "-c", "exec \"$@\" 2>&1", "sh", "echo", "hi"]

# `reset_backend_cache`

```elixir
@spec reset_backend_cache() :: :ok
```

Drop the memoized probe result. For tests and for hosts that install a
provider while the VM is running.

# `warm`

```elixir
@spec warm() :: :ok
```

Canonicalise and memoize the temp roots.

Called from the application's own `start` callback so the one filesystem read
this module needs happens at boot rather than inside `confine/2`. Idempotent.

# `writable_roots`

```elixir
@spec writable_roots(Nous.Sandbox.Policy.t()) :: [Path.t()]
```

The canonical directories a confined process may write to.

`[]` under `:read_only`. Otherwise three sources — the workspace root,
`/tmp`, and `System.tmp_dir!/0` — canonicalised through
`Nous.Tools.PathGuard.resolve_real/1` and deduplicated, so hosts where they
coincide (`/tmp` on Linux) return fewer than three entries.

No `$HOME`, no git directory, no session state directory. This is the **one**
owner of that set: the Seatbelt profile builder and any future filesystem
fence both call it, so they cannot drift apart.

The workspace root arrives already canonical from
`Nous.Sandbox.Policy.new/1`, and the two temp roots are canonicalised once per
VM (see `warm/0`), which is what keeps `confine/2` off the filesystem.

---

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