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/1returns[]).:workspace_write— writes are allowed under the workspace root and the temp directories, denied everywhere else.:danger_full_access— no confinement.Nous.Sandbox.confine/2does 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):
- an explicit
:modepassed toresolve/2 - the session policy carried on the context (
ctx.sandbox), which isNous.Agent's:sandboxoption, overridable per run - 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_writeThe 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
Functions
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.
@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).
@spec modes() :: [mode()]
The three valid modes, widest confinement first.
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"}
@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.