Skip to main content

Permissions

What an agent's tools may do — read-only by default, with :auto, :ask, and :approve escalations. A denied capability returns an error, never raises. Every escalation is an explicit opt-in.

The permission mode is what an agent’s tools may do. Two seams compose the execution environment: the sandbox is where tools act (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)

: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 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).

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

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.

# 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::ApprovalRequiredrun_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).
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 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:

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

View examples/approval_agent.rb on GitHub →


Next steps