
The permission mode is *what* an agent's tools may do. Two seams compose the execution environment: the **sandbox** is *where* tools act ([Sandboxes](../sandboxes/)); the **permission mode** is *what* they may do.

> **Safe by default:** agents start `:read_only` — read and glob are auto-allowed; every write, shell, fetch, and search is denied until you explicitly opt in. A denied capability returns `{ error: ... }` and the agent loop continues — it does not raise. A path that escapes the workspace raises `SecurityError`; an agent built with no resolvable model raises `Nexo::ConfigurationError`.

---

## The four modes

| Mode | `:read`/`:glob` | `:write`/`:shell`/`:fetch`/`:search` | When to use |
|---|---|---|---|
| `:read_only` (default) | Yes | No `{error}` | Untrusted models, the safe baseline |
| `:auto` | Yes | Yes | Fully trusted local dev/CI |
| `:ask` | Yes | per `on_ask` | A human at the keyboard during a synchronous run |
| `:approve` | Yes | per `decision` | Durable, cross-process human-in-the-loop (see [Durable workflows](../durable-workflows/)) |

`:read`/`:glob` are auto-allowed under **every** mode (they sit in the default `allow` list), so `:ask`/`:approve` never prompt for them — only `:write`/`:shell`/`:fetch`/`:search` reach the gate.

You can also grant individual capabilities without changing the mode — `Permissions.new(mode: :read_only, allow: %i[read glob fetch])` lets fetch through while keeping write/shell denied. This is how the [Web](../web/) tools are typically enabled.

---

## The gate

A denied capability returns `{ error: ... }` to the model (recoverable) and never raises into the loop — identical to a sandbox tool failure. The gate is a *first-class capability check*, not a coarse mode switch, so a `:read_only` agent that never calls `fetch_allow` gets no fetch tool at all (see [Web](../web/)).

Escalation is always explicit in your code: `:auto`, an `allow:` list, a populated `mcp_allow`, or `:ask` with a real `on_ask`.

---

## The MCP gate — a second, fail-closed axis

MCP tools obey a **second permission axis**, separate from the sandbox capability axis, because an MCP tool executes inside the server, outside the sandbox. `mcp_allow` is the exact-match allow-list:

| Mode | MCP tool behavior |
|---|---|
| `:read_only` (default) | allow **only** tool names listed in `mcp_allow`; everything else denied |
| `:ask` | call `on_ask.call(:mcp, {tool:, args:})`; truthy allows, else deny |
| `:approve` | names in `mcp_allow` are pre-approved; any other tool needs a human decision — undecided **suspends the run**, `approved: true` allows, `approved: false` denies |
| `:auto` | allow every MCP tool |

`mcp_allow` defaults to `[]`, so attaching a powerful server under `:read_only` with no allow-list denies **every** tool — a misconfigured agent fails closed, not open. Matching is exact tool-name only — no globs or regexes. See [MCP](../mcp/) for the full detail.

---

## Human-gated writes (`:ask`)

`:ask` mode defers every write/shell action to your callback. Build a `Permissions` with an `on_ask` hook and pass it in:

```ruby
gate = Nexo::Permissions.new(mode: :ask, on_ask: ->(cap, detail) {
  $stdout.print("Allow #{cap} #{detail}? [y/N] "); $stdin.gets.strip == "y"
})

class Editor < Nexo::Agent
  model   ENV.fetch("NEXO_MODEL")
  sandbox :local
end

Editor.new(cwd: ".", permissions: gate).prompt("Fix the typo in README.md")
```

The bare `:ask` symbol resolves to `Permissions.new(mode: :ask)` with **no** callback, so writes/shell are denied — pass a pre-built `Permissions` with `on_ask` for a real gate.

### Scope which actions prompt — `ask_when`

Under `:ask`, `ask_when` scopes *which* actions actually prompt a human. When the predicate returns falsey the action is auto-allowed **without** calling `on_ask`; truthy (or when `ask_when` is unset) falls through to `on_ask` exactly as before. Unset = ask for everything. It only ever *narrows* what is auto-allowed — it never widens authority.

```ruby
# Only prompt for writes under /protected; auto-allow everything else.
perms = Nexo::Permissions.new(
  mode: :ask,
  on_ask:   ->(cap, detail) { ask_the_human(cap, detail) },
  ask_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") }
)
```

---

## Durable approval (`:approve`)

`:approve` is the durable, cross-process sibling of `:ask` for the case where a `run_agent`-driven agent hits a permission gate **mid-loop** and you want that to **pause the run for a human**, not run unchecked and not block a worker.

The loop is: **`:approve` gate with no decision → `Nexo::ApprovalRequired` → `run_agent` suspends → host renders the pending call → `resume(approved:)` threads the decision back through the gate.**

- **`Nexo::ApprovalRequired` is a signal, distinct from `Permissions::Denied`:** `Denied` means "no, adapt" (tools rescue it into `{error:}`); `ApprovalRequired` means "pause and ask a human", so tools must **not** rescue it.
- **Undecided ⇒ suspend, `approved: false` ⇒ deny.** An unresolved approval never silently allows; a denial on resume makes the tool return `{error:}` (the model adapts) — the run still finishes `"done"`, **without** the gated effect, never `"failed"`.
- **Scope which actions need approval** with the same `ask_when` predicate (aliased `approve_when:` for readability).

```ruby
Nexo::Permissions.new(mode: :approve,
  approve_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") })
```

`:ask` (in-process `on_ask`) is the right choice with a human at the keyboard during a synchronous `run`; `:approve` is its durable, cross-process sibling for `run_later`/`resume_later`. See [Durable workflows](../durable-workflows/) for the suspend/resume mechanics and the honest caveats (re-entry not replay, one approval per suspend cycle, needs the ActiveRecord store + ActiveJob for cross-process).

### Live example

The `:approve` mode bridged to a durable suspend/resume is exercised by a live example in the repo:

```sh
NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/approval_agent.rb
```

> [View `examples/approval_agent.rb` on GitHub →](https://github.com/maquina-app/nexo/blob/main/examples/approval_agent.rb)


---

## Next steps

<div class="not-prose mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
  <a href="../sandboxes/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Sandboxes
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      The other safety axis — where an agent's tools act.
    </p>
  </a>

  <a href="../durable-workflows/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Durable workflows
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      Take the :approve gate cross-process with suspend and resume.
    </p>
  </a>
</div>
