Nous.Sandbox behaviour (nous v0.17.1)

Copy Markdown View Source

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

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

backend()

@type backend() :: module()

A provider module implementing this behaviour.

error()

@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

confine(argv, policy)

@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(timeout_ms)

@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

backend()

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

chain()

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

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

classify(confined, exit_status, output)

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

confine(argv, policy)

@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(argv)

@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()

@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()

@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(policy)

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