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 raisesSecurityError; an agent built with no resolvable model raisesNexo::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::ApprovalRequired → run_agent suspends → host renders the pending call → resume(approved:) threads the decision back through the gate.
Nexo::ApprovalRequiredis a signal, distinct fromPermissions::Denied:Deniedmeans “no, adapt” (tools rescue it into{error:});ApprovalRequiredmeans “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_whenpredicate (aliasedapprove_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