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

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.

# `mode`

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

# `t`

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

# `canonical`

```elixir
@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`

```elixir
@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`

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

The three valid modes, widest confinement first.

# `new`

```elixir
@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`

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

---

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