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/2builds 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; seewritable_roots/1). Path canonicalisation happens when the policy is built, not per call. Onlyprobe/1(backend selection) and the caller's own spawn touch the OS.- Fail closed. With no usable provider,
confine/2returns{:error, {:sandbox_unavailable, mode, detail}}. Silently running the command unconfined is never legal.:danger_full_accessis 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 profileNous.Sandbox.Bwrap— Linux,bwrapfrom bubblewrapNous.Sandbox.Unavailable— the fail-closed default when nothing else probes clean (including all of Windows, wherechain/0is[])
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.
Summary
Types
A provider module implementing this behaviour.
Return values — not raises. Nous.Errors exceptions are for the agent
loop; confinement failures are ordinary data a tool decides what to do with.
Callbacks
Wrap argv so the OS enforces policy. Pure: MUST NOT spawn.
Check whether this provider actually works on this host, within
timeout_ms. This is the one callback that may spawn.
Functions
The selected provider.
Candidate providers for this platform, best first. [] where no provider
exists (Windows), which resolves to Nous.Sandbox.Unavailable.
Interpret a finished confined process. Pure.
Confine argv under policy using the selected backend.
Wrap argv so the child's stderr is merged into its stdout.
Drop the memoized probe result. For tests and for hosts that install a provider while the VM is running.
Canonicalise and memoize the temp roots.
The canonical directories a confined process may write to.
Types
@type backend() :: module()
A provider module implementing this behaviour.
@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.
Callbacks
@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.
@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.
Functions
@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 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.
@spec chain() :: [backend()]
Candidate providers for this platform, best first. [] where no provider
exists (Windows), which resolves to Nous.Sandbox.Unavailable.
@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 —catting 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.
@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.
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"]
@spec reset_backend_cache() :: :ok
Drop the memoized probe result. For tests and for hosts that install a provider while the VM is running.
@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.
@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.