Nous.Sandbox.Policy (nous v0.17.1)

Copy Markdown View Source

What a confined subprocess is allowed to do.

A policy is plain data: a mode, the workspace root the mode is relative to, and an optional session id for logging/telemetry. It is resolved per call (see resolve/2) and handed to Nous.Sandbox.confine/2, which turns it into argv. Nothing here spawns.

Modes

  • :read_only — the process may read, but every write is denied. No writable roots at all (Nous.Sandbox.writable_roots/1 returns []).
  • :workspace_write — writes are allowed under the workspace root and the temp directories, denied everywhere else.
  • :danger_full_access — no confinement. Nous.Sandbox.confine/2 does not call a provider at all and the argv is passed through untouched.

Note what no mode does: neither in-tree provider restricts reads. Seatbelt's profile is (allow default) (deny file-write*) and bwrap binds / read-only, so both are write fences. Read confinement remains Nous.Tools.PathGuard's job.

Where the mode comes from

Precedence, highest first (resolve/2):

  1. an explicit :mode passed to resolve/2
  2. the session policy carried on the context (ctx.sandbox), which is Nous.Agent's :sandbox option, overridable per run
  3. the application default (config :nous, :sandbox_mode, ...)

The default is permissive, on purpose, for now

With no configuration the default is :danger_full_access and a one-time warning is logged. Fail-closed confinement is a behaviour change even though it is not an API change: Nous.Tools.Bash would stop working on any host without bwrap installed. Opting in is one line:

config :nous, :sandbox_mode, :workspace_write

The default will flip in a later release.

What breaks when you turn it on

Measured, so you meet it here rather than in production. A fence people switch off is worse than a fence with a named exception.

Under :read_only there are no writable roots — not even the temp dir — so the shell cannot create the temp file a heredoc needs (cannot create temp file for here document), and mktemp fails. Heredocs are how a model writes multi-line content in shell, so :read_only is for genuinely read-only work (inspection, search, git log), not for "mostly reading".

Under :workspace_write, $HOME is read-only, which breaks any tool that writes a cache there: npm (EPERM ... ~/.npm/_cacache), pip's wheel cache, cargo's ~/.cargo, go build's cache, gh, docker's ~/.docker. git is fine — its writes live in the repo.

When that bites, widen deliberately rather than reaching for :danger_full_access: give the agent its own workspace_root and point the tool's cache into it (HOME=<root> npm ci, CARGO_HOME=<root>/.cargo), or drop to :read_only/:workspace_write per run with the :sandbox option instead of globally.

Summary

Functions

Canonicalise a path: absolute, with every existing symlink component dereferenced, via Nous.Tools.PathGuard.resolve_real/1.

The configured application-wide default mode.

The three valid modes, widest confinement first.

Build a policy from a mode, a keyword list, a map, or another policy.

Resolve the effective policy for a tool call.

Types

mode()

@type mode() :: :read_only | :workspace_write | :danger_full_access

t()

@type t() :: %Nous.Sandbox.Policy{
  mode: mode(),
  session_id: String.t() | nil,
  workspace_root: Path.t()
}

Functions

canonical(path)

@spec canonical(Path.t()) :: Path.t()

Canonicalise a path: absolute, with every existing symlink component dereferenced, via Nous.Tools.PathGuard.resolve_real/1.

This is why policy construction, not Nous.Sandbox.confine/2, is where the filesystem is touched. It matters for enforcement, not tidiness: on macOS /tmp is a symlink to /private/tmp, and an SBPL (subpath "/tmp") clause would never match a write the kernel sees as /private/tmp/....

Falls back to Path.expand/1 on a symlink loop.

default_mode()

@spec default_mode() :: mode()

The configured application-wide default mode.

Warns once per VM when nothing is configured, because the built-in default runs subprocesses unconfined (see the moduledoc).

modes()

@spec modes() :: [mode()]

The three valid modes, widest confinement first.

new(policy)

@spec new(t() | mode() | String.t() | keyword() | map()) :: t()

Build a policy from a mode, a keyword list, a map, or another policy.

:workspace_root defaults to the current working directory, matching Nous.Tools.PathGuard.

Examples

iex> Nous.Sandbox.Policy.new(:read_only).mode
:read_only

iex> policy = Nous.Sandbox.Policy.new(mode: :workspace_write, workspace_root: "/srv/ws")
iex> {policy.mode, policy.workspace_root}
{:workspace_write, "/srv/ws"}

resolve(ctx, opts \\ [])

@spec resolve(
  Nous.RunContext.t() | map() | nil,
  keyword()
) :: t()

Resolve the effective policy for a tool call.

ctx is a Nous.RunContext (or any map with :sandbox/:deps keys, or nil). opts may override :mode, :workspace_root and :session_id.

The workspace root falls back to ctx.deps[:workspace_root] — the same place Nous.Tools.PathGuard reads it from — and then to the current directory.