Class: Nexo::Permissions
- Inherits:
-
Object
- Object
- Nexo::Permissions
- Defined in:
- lib/nexo/permissions.rb
Overview
The authorization gate for a sandbox's capabilities. Each tool asks
authorize! before it touches the sandbox, so gating is provider-independent
and does not rely on any framework callback.
Modes:
:auto— allow everything.:read_only— allow:read/:glob, deny:write/:shell/:fetch/:search(the default).:ask— defer toon_ask; a truthy return allows, anything else denies.:approve— durable, cross-process sibling of:ask(Spec 16): with nodecisionit raises Nexo::ApprovalRequired (→ Workflow#run_agent suspends the run); with{approved: true}it allows, with{approved: false}it Denied denies.
Capabilities are :read, :glob, :write, :shell, :fetch, :search.
Anything listed in allow: is permitted regardless of mode.
Defined Under Namespace
Classes: Denied
Constant Summary collapse
- MODES =
The recognized permission modes:
:auto,:read_only,:ask,:approve. i[auto read_only ask approve].freeze
- PRIVILEGED =
The capabilities
:read_onlyrefuses. Named once so #authorize! and #never_allows? cannot drift apart: the whole value of the predicate is that it reports what the gate will actually do, so both must read the same list. i[write shell fetch search].freeze
Instance Attribute Summary collapse
-
#decision ⇒ Object
The approval decision under
:approve(Spec 16):nil(undecided ⇒ suspend) or a{approved: true|false}Hash. -
#mode ⇒ Object
readonly
The configured Nexo permission mode (one of MODES).
Instance Method Summary collapse
-
#authorize!(capability, detail = nil) ⇒ Object
Authorizes
capability(with optionaldetailpassed to an:askhook). -
#authorize_mcp!(tool_name, args = {}) ⇒ Object
Authorizes an MCP tool call by name.
-
#initialize(mode: :read_only, allow: %i[read glob],, mcp_allow: [], on_ask: nil, ask_when: nil, approve_when: nil, decision: nil) ⇒ Permissions
constructor
ask_when:is an optional->(capability, detail)predicate that scopes which actions actually prompt under:ask(and, unchanged, under:approve): when it returns falsey the action is auto-allowed without callingon_ask/ requiring a decision; truthy (or when unset) falls through toon_ask/ the approval gate exactly as before. -
#never_allows?(capability) ⇒ Boolean
Whether
capabilitycan NEVER be authorized by this gate, for any call. -
#with_decision(decision) ⇒ Object
Returns a copy of this gate carrying
decision(a{approved: …}Hash ornil), leaving the receiver untouched (Spec 16).
Constructor Details
#initialize(mode: :read_only, allow: %i[read glob],, mcp_allow: [], on_ask: nil, ask_when: nil, approve_when: nil, decision: nil) ⇒ Permissions
ask_when: is an optional ->(capability, detail) predicate that scopes
which actions actually prompt under :ask (and, unchanged, under
:approve): when it returns falsey the action is auto-allowed without
calling on_ask / requiring a decision; truthy (or when unset) falls
through to on_ask / the approval gate exactly as before. It only ever
narrows what is auto-allowed from the "ask/approve for everything"
baseline — it never widens authority. Applies to #authorize! only, not
#authorize_mcp!. approve_when: is an alias that maps onto the same
predicate (there is one predicate, not two — Spec 16 Q4).
decision: (default nil) seeds the :approve decision (see #decision).
55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/nexo/permissions.rb', line 55 def initialize(mode: :read_only, allow: i[read glob], mcp_allow: [], on_ask: nil, ask_when: nil, approve_when: nil, decision: nil) raise ArgumentError, "unknown mode #{mode}" unless MODES.include?(mode) @mode = mode @allow = allow @mcp_allow = mcp_allow.map(&:to_s) @on_ask = on_ask @ask_when = ask_when || approve_when @decision = decision end |
Instance Attribute Details
#decision ⇒ Object
The approval decision under :approve (Spec 16): nil (undecided ⇒
suspend) or a {approved: true|false} Hash. Writable after construction so
Workflow#run_agent can thread a resume decision into an
already-resolved :approve gate without rebuilding it. Ignored by every
other mode.
42 43 44 |
# File 'lib/nexo/permissions.rb', line 42 def decision @decision end |
#mode ⇒ Object (readonly)
The configured Nexo permission mode (one of MODES). Read by the agent to
map onto an opt-in backend's own permission vocabulary (see
Agent#permission_mode).
35 36 37 |
# File 'lib/nexo/permissions.rb', line 35 def mode @mode end |
Instance Method Details
#authorize!(capability, detail = nil) ⇒ Object
Authorizes capability (with optional detail passed to an :ask hook).
Returns true when allowed; raises Denied otherwise.
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/nexo/permissions.rb', line 77 def (capability, detail = nil) return true if @allow.include?(capability) case @mode when :auto true when :read_only raise Denied, "#{capability} denied in read_only mode" if PRIVILEGED.include?(capability) true when :ask # Scoped-ask: when ask_when says this action doesn't need a prompt, # auto-allow without calling on_ask. Unset ask_when = ask for everything. return true if @ask_when && !@ask_when.call(capability, detail) unless @on_ask&.call(capability, detail) raise Denied, "#{capability} (#{detail}) denied by user" end true when :approve # Durable approval (Spec 16). Scoped-approve: when ask_when says this # action doesn't need approval, auto-allow without a decision. Unset # ask_when = approve for everything. return true if @ask_when && !@ask_when.call(capability, detail) if @decision.nil? # Undecided ⇒ pause the run for a human (Branch A): the signal # propagates out of the tool loop; run_agent turns it into a suspend. raise Nexo::ApprovalRequired.new(capability, detail) elsif @decision[:approved] true else # Explicitly denied on resume ⇒ never auto-allow; the tool rescues # Denied into {error:} and the model adapts. raise Denied, "#{capability} (#{detail}) not approved" end end end |
#authorize_mcp!(tool_name, args = {}) ⇒ Object
Authorizes an MCP tool call by name. A deliberate sibling of #authorize!
on a separate capability axis: an MCP tool runs inside the MCP server,
outside the sandbox, so this gates the authority to invoke it — a different
guarantee than sandbox capability. Fails closed under :read_only (nothing
allowed unless the exact tool_name is listed in mcp_allow).
:auto— allow every MCP tool.:read_only— allow only names inmcp_allow(default[]⇒ deny all).:ask— defer toon_askwith(:mcp, {tool:, args:}); a truthy return allows, anything else denies.:approve— durable sibling of:askon the MCP axis: names inmcp_alloware pre-approved, anything else needs a decision — undecided raises Nexo::ApprovalRequired (→ Workflow#run_agent suspends),approvedallows,approved: falseDenies.
Returns true when allowed; raises Denied otherwise. The else is a
fail-closed backstop: a future mode that forgets to extend this gate denies
by default rather than silently allowing (the bug this replaced).
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/nexo/permissions.rb', line 153 def (tool_name, args = {}) name = tool_name.to_s case @mode when :auto true when :read_only return true if @mcp_allow.include?(name) raise Denied, "mcp tool #{name} denied in read_only mode (not in mcp_allow)" when :ask unless @on_ask&.call(:mcp, {tool: name, args: args}) raise Denied, "mcp tool #{name} denied by user" end true when :approve # Pre-approved read tools pass; everything else routes through the same # decision gate as #authorize!'s :approve branch. return true if @mcp_allow.include?(name) if @decision.nil? raise Nexo::ApprovalRequired.new(:mcp, name, args) elsif @decision[:approved] true else raise Denied, "mcp tool #{name} not approved" end else raise Denied, "mcp tool #{name} denied (unhandled mode #{@mode})" end end |
#never_allows?(capability) ⇒ Boolean
Whether capability can NEVER be authorized by this gate, for any call.
True only under :read_only, for a PRIVILEGED capability absent from
allow: — that is the one case knowable ahead of time. Every other mode
decides per call and must be reported as possible: :auto allows, :ask
consults its hook, and :approve has to reach the gate so it can raise
ApprovalRequired and suspend the run.
Agent#chat uses this to skip ATTACHING a tool the model could never successfully call, so a guaranteed failure stops occupying the tool schema on every turn. That makes this a cost and description-accuracy measure, not a security boundary: #authorize! remains the gate and still denies at call time whether or not the tool was advertised.
129 130 131 132 133 |
# File 'lib/nexo/permissions.rb', line 129 def never_allows?(capability) return false if @allow.include?(capability) @mode == :read_only && PRIVILEGED.include?(capability) end |
#with_decision(decision) ⇒ Object
Returns a copy of this gate carrying decision (a {approved: …} Hash or
nil), leaving the receiver untouched (Spec 16). Used to thread a per-run
resume decision into a user-supplied, class-level :approve Permissions
without mutating the shared instance.
71 72 73 |
# File 'lib/nexo/permissions.rb', line 71 def with_decision(decision) dup.tap { |copy| copy.decision = decision } end |