Class: Nexo::Permissions

Inherits:
Object
  • Object
show all
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 to on_ask; a truthy return allows, anything else denies.
  • :approve — durable, cross-process sibling of :ask (Spec 16): with no decision it 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_only refuses. 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

Instance Method Summary collapse

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

Raises:

  • (ArgumentError)


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 authorize!(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 in mcp_allow (default [] ⇒ deny all).
  • :ask — defer to on_ask with (:mcp, {tool:, args:}); a truthy return allows, anything else denies.
  • :approve — durable sibling of :ask on the MCP axis: names in mcp_allow are pre-approved, anything else needs a decision — undecided raises Nexo::ApprovalRequired (→ Workflow#run_agent suspends), approved allows, approved: false Denies.

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 authorize_mcp!(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.

Returns:

  • (Boolean)


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