nexo_ai 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8815820e94035d1d5c68defa4e862111c95572852098b735e885eaf98b6a3407
4
- data.tar.gz: 57ca3d98c0d1acd6877276e9322715a7e003928b2da258866b1e7ccabcf33b29
3
+ metadata.gz: 2a372c4ac5c253a76cbf930d99b134bbdc62c596dc8bde4414da26f08c99ad39
4
+ data.tar.gz: 8329003fd163b8b4131f4608b18ecb5604717b7dd6995699328216cb22143320
5
5
  SHA512:
6
- metadata.gz: 8ad1a55220bcddd2ea1e2d06ea2bc2c680ae4f2a52c1cd8f24e0942f66ad38e38c6b1dc992fcc766b720400083bb07b500feaae9285fbb671e203bfa3f46e0f4
7
- data.tar.gz: cea3ce8167dae3b4e44a5d87c84e42f04a333c7cd7e5aae8ad236133b821ba10fd3688e63851035d2c1b494b9c533807fa29bfbfd1c3439ed8c8ce6473fe1e52
6
+ metadata.gz: 3e3de8747793d6c7b052d9272f6e23345e33d0749e66cd17a4247429eec1999f1329830c1c21841ac399bc04743f2d6595126496d8a24d340b537ad5ee4de026
7
+ data.tar.gz: 271699e28b6c236f618250b06f75ed8c4fc5d0dec269d24d0f4bac145366e1cedc0f6fee37926117b9fbf2446a8d4d7b54054f2c73562f3d6a1bfd6ae41e21d5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.11.0] - 2026-08-20
4
+
5
+ An agent's tool schema now tells the truth about what it may do.
6
+
7
+ ### Changed
8
+
9
+ - **An agent no longer advertises a tool its permission mode can never authorize.**
10
+ Tool attach was gated on the sandbox (`Sandbox#supports?`) but not on the gate, so a
11
+ `:read_only` agent — the default — put `WriteFile` and `Shell` in its schema on every
12
+ turn even though `#authorize!` was guaranteed to deny them. The same held for `Fetch`
13
+ and `WebSearch`, which were gated on `fetch_allow` / `search_backend` being declared
14
+ but not on the capability being permitted. Models do try these tools, and each attempt
15
+ costs a full round trip.
16
+
17
+ `Agent#chat`, `#apply_fetch` and `#apply_search` now also consult
18
+ `Permissions#never_allows?`. Only `:read_only` is decidable ahead of time; `:auto`,
19
+ `:ask` and `:approve` decide per call and still attach — `:approve` in particular must
20
+ reach the gate so it can raise `ApprovalRequired` and suspend the run.
21
+
22
+ This is a cost and description-accuracy measure, **not** a security change:
23
+ `#authorize!` remains the boundary and still denies at call time.
24
+
25
+ **Upgrading:** an agent that is supposed to write, shell out, fetch or search needs the
26
+ capability in `allow:` (or a non-`:read_only` mode). If it does not have it today the
27
+ tool was already failing on every call — the tool now disappears from the schema instead
28
+ of erroring. Note that `fetch_allow` scopes hosts and `search_backend` names a backend;
29
+ neither is a capability grant.
30
+
31
+ ### Added
32
+
33
+ - `Nexo::Permissions#never_allows?(capability)` — true when a capability can never be
34
+ authorized for this gate, for any call. Derived from the new `Permissions::PRIVILEGED`
35
+ constant, which `#authorize!` also reads, so the predicate cannot drift from the gate.
36
+
3
37
  ## [0.10.0] - 2026-08-20
4
38
 
5
39
  Durable workflows without a database.
data/docs/permissions.md CHANGED
@@ -8,7 +8,7 @@ and the agent loop continues — it does not raise. A path that escapes the work
8
8
 
9
9
  | | `:read` | `:glob` | `:write` | `:shell` | `:fetch` | `:search` |
10
10
  | ------------------------ | ------- | ------- | ------------------- | --------------------------------- | ---------- | ---------- |
11
- | `:read_only` (default) | ✅ | ✅ | ❌ `{error}` | ❌ `{error}` | ❌ `{error}` | ❌ `{error}` |
11
+ | `:read_only` (default) | ✅ | ✅ | ❌ not attached ‡ | ❌ not attached ‡ | ❌ not attached ‡ | ❌ not attached ‡ |
12
12
  | `:auto` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
13
13
  | `:ask` | ✅ | ✅ | per `on_ask` | per `on_ask` | per `on_ask` | per `on_ask` |
14
14
  | `:approve` | ✅ | ✅ | per `decision` | per `decision` | per `decision` | per `decision` |
@@ -18,7 +18,22 @@ and the agent loop continues — it does not raise. A path that escapes the work
18
18
 
19
19
  `:read`/`:glob` are auto-allowed under **every** mode (they sit in the default
20
20
  `allow` list), so `:ask`/`:approve` never prompt for them — only
21
- `:write`/`:shell`/`:fetch`/`:search` reach the gate. **†** `:fetch` and `:search`
21
+ `:write`/`:shell`/`:fetch`/`:search` reach the gate.
22
+
23
+ **‡** Under `:read_only` these four capabilities can *never* be authorized, so their tools are
24
+ not attached at all — the model never sees `WriteFile`, `Shell`, `Fetch` or `WebSearch` in its
25
+ schema. Anything named in `allow:` is exempt and attaches normally, and `:auto`/`:ask`/`:approve`
26
+ always attach because they decide per call. Leaving a guaranteed failure in the schema is not
27
+ free: a model that reads the schema tries the tool, and each attempt costs a full round trip.
28
+ `Permissions#never_allows?` is the predicate, and it is derived from the same `PRIVILEGED` list
29
+ `#authorize!` uses so the two cannot disagree. This is a cost and description-accuracy measure,
30
+ **not** a security boundary — `#authorize!` is still the gate and still denies at call time.
31
+
32
+ Note that declaring `fetch_allow` or a `search_backend` is *not* a capability grant: `fetch_allow`
33
+ scopes which hosts are reachable, and both still need `:fetch` / `:search` permitted before the
34
+ tool is attached.
35
+
36
+ **†** `:fetch` and `:search`
22
37
  run in the **host process** (stdlib `net/http` / a host-injected backend), so **no
23
38
  sandbox constrains them** — not even a `--network none` container. They are bounded
24
39
  only by the capability gate above plus `fetch_allow` / the injected backend.
data/docs/sandboxes.md CHANGED
@@ -54,10 +54,19 @@ scope; none widens authority silently.
54
54
  instructions → sandbox instructions → skill instructions**. Provider-neutral, injected through
55
55
  the existing `with_instructions` path.
56
56
 
57
- - **Capability-gated tool attach (`Sandbox#supports?`).** A `:virtual` agent no longer advertises
58
- a `Shell` tool it can never run `Agent#chat` attaches `Shell` only when
59
- `@sandbox.supports?(:shell)`. `Local`/`Container` support all four capabilities; `Virtual`
60
- supports everything but `:shell`. `ReadFile`/`WriteFile`/`Glob` are always attached.
57
+ - **Gated tool attach, on two axes.** An agent does not advertise a tool it could never
58
+ successfully call, because a guaranteed failure in the schema costs a round trip every time a
59
+ model tries it. `Agent#chat` attaches a tool only when **both** hold:
60
+
61
+ 1. *The sandbox supports the capability* (`Sandbox#supports?`). `Local`/`Container` support all
62
+ four; `Virtual` supports everything but `:shell`, so a `:virtual` agent has no `Shell`.
63
+ 2. *The permission gate does not deny it statically* (`Permissions#never_allows?`). Under
64
+ `:read_only`, `:write` and `:shell` can never be authorized unless listed in `allow:`, so
65
+ neither `WriteFile` nor `Shell` is attached. `:auto`, `:ask` and `:approve` decide per call
66
+ and always attach — `:approve` in particular *must* reach the gate so it can suspend the run.
67
+
68
+ `ReadFile` and `Glob` are always attached. This is a cost and description-accuracy measure, not
69
+ a security boundary: `Permissions#authorize!` remains the gate and still denies at call time.
61
70
 
62
71
  - **Shell output truncation (`Nexo::OutputTruncator`).** Unbounded command output (`npm install`,
63
72
  `git log`) is truncated before it reaches the model, so a single command can't blow a small
data/docs/tools.md CHANGED
@@ -1,12 +1,22 @@
1
1
  # Tools
2
2
  Nexo attaches four sandbox-backed tools — `ReadFile`, `WriteFile`, `Shell`, and `Glob` — each gated by the [sandbox](sandboxes.md) and [permission](permissions.md) seams.
3
3
 
4
- Which tools attach depends on what the sandbox supports:
5
-
6
- - **Capability-gated tool attach (`Sandbox#supports?`).** A `:virtual` agent no longer advertises
7
- a `Shell` tool it can never run `Agent#chat` attaches `Shell` only when
8
- `@sandbox.supports?(:shell)`. `Local`/`Container` support all four capabilities; `Virtual`
9
- supports everything but `:shell`. `ReadFile`/`WriteFile`/`Glob` are always attached.
4
+ Which tools attach depends on what the sandbox supports *and* on what the permission mode could
5
+ ever allow:
6
+
7
+ - **Gated tool attach, on two axes.** An agent does not advertise a tool it could never
8
+ successfully call, because a guaranteed failure in the schema costs a round trip every time a
9
+ model tries it. `Agent#chat` attaches a tool only when **both** hold:
10
+
11
+ 1. *The sandbox supports the capability* (`Sandbox#supports?`). `Local`/`Container` support all
12
+ four; `Virtual` supports everything but `:shell`, so a `:virtual` agent has no `Shell`.
13
+ 2. *The permission gate does not deny it statically* (`Permissions#never_allows?`). Under
14
+ `:read_only`, `:write` and `:shell` can never be authorized unless listed in `allow:`, so
15
+ neither `WriteFile` nor `Shell` is attached. `:auto`, `:ask` and `:approve` decide per call
16
+ and always attach — `:approve` in particular *must* reach the gate so it can suspend the run.
17
+
18
+ `ReadFile` and `Glob` are always attached. This is a cost and description-accuracy measure, not
19
+ a security boundary: `Permissions#authorize!` remains the gate and still denies at call time.
10
20
 
11
21
  `Shell` truncates unbounded command output before it reaches the model:
12
22
 
data/lib/nexo/agent.rb CHANGED
@@ -285,14 +285,19 @@ module Nexo
285
285
  # One ReadTracker per chat, shared by ReadFile (records) and WriteFile
286
286
  # (enforces the read-before-write + stale guard) — R4.
287
287
  tracker = ReadTracker.new
288
- tools = [
289
- Tools::ReadFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker),
290
- Tools::WriteFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker),
291
- Tools::Glob.new(sandbox: @sandbox, permissions: @permissions)
292
- ]
293
- # Attach Shell only when the sandbox can actually run one (R2), so a
294
- # :virtual agent stops advertising a tool it can never run.
295
- if @sandbox.supports?(:shell)
288
+ # A sandbox tool is attached only when the agent could actually use it, on
289
+ # BOTH axes: the sandbox has to support the capability (R2 — a :virtual
290
+ # sandbox has no shell) and the gate must not deny it statically (a
291
+ # :read_only agent can never be authorized for :write or :shell). Otherwise
292
+ # a guaranteed failure sits in the tool schema on every turn and models do
293
+ # try it. This is the same attach-time gating apply_fetch/apply_search
294
+ # already apply; #authorize! stays the actual boundary either way.
295
+ tools = [Tools::ReadFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker)]
296
+ unless @permissions.never_allows?(:write)
297
+ tools << Tools::WriteFile.new(sandbox: @sandbox, permissions: @permissions, tracker: tracker)
298
+ end
299
+ tools << Tools::Glob.new(sandbox: @sandbox, permissions: @permissions)
300
+ if @sandbox.supports?(:shell) && !@permissions.never_allows?(:shell)
296
301
  tools << Tools::Shell.new(sandbox: @sandbox, permissions: @permissions)
297
302
  end
298
303
  c.with_tools(*tools)
@@ -570,6 +575,10 @@ module Nexo
570
575
  # allow-list only scopes hosts, it is not the capability grant.
571
576
  def apply_fetch(chat)
572
577
  return if self.class.fetch_allow.empty?
578
+ # The allow-list scopes hosts; it is not the capability grant. A gate that
579
+ # can never authorize :fetch gets no tool rather than a guaranteed failure
580
+ # in its schema (see Permissions#never_allows?).
581
+ return if @permissions.never_allows?(:fetch)
573
582
 
574
583
  chat.with_tools(
575
584
  Tools::Fetch.new(sandbox: @sandbox, permissions: @permissions, allow_hosts: self.class.fetch_allow)
@@ -585,6 +594,7 @@ module Nexo
585
594
  # through Permissions#authorize! at call time.
586
595
  def apply_search(chat)
587
596
  backend = self.class.search_backend or return
597
+ return if @permissions.never_allows?(:search)
588
598
 
589
599
  chat.with_tools(
590
600
  Tools::WebSearch.new(sandbox: @sandbox, permissions: @permissions, backend: backend)
@@ -20,6 +20,11 @@ module Nexo
20
20
  # The recognized permission modes: +:auto+, +:read_only+, +:ask+, +:approve+.
21
21
  MODES = %i[auto read_only ask approve].freeze
22
22
 
23
+ # The capabilities +:read_only+ refuses. Named once so #authorize! and
24
+ # #never_allows? cannot drift apart: the whole value of the predicate is that
25
+ # it reports what the gate will actually do, so both must read the same list.
26
+ PRIVILEGED = %i[write shell fetch search].freeze
27
+
23
28
  # Raised when a capability is not authorized. Tools rescue this and return
24
29
  # +{ error: ... }+ so the agent loop continues.
25
30
  class Denied < StandardError; end
@@ -76,9 +81,8 @@ module Nexo
76
81
  when :auto
77
82
  true
78
83
  when :read_only
79
- if %i[write shell fetch search].include?(capability)
80
- raise Denied, "#{capability} denied in read_only mode"
81
- end
84
+ raise Denied, "#{capability} denied in read_only mode" if PRIVILEGED.include?(capability)
85
+
82
86
  true
83
87
  when :ask
84
88
  # Scoped-ask: when ask_when says this action doesn't need a prompt,
@@ -109,6 +113,25 @@ module Nexo
109
113
  end
110
114
  end
111
115
 
116
+ # Whether +capability+ can NEVER be authorized by this gate, for any call.
117
+ #
118
+ # True only under +:read_only+, for a PRIVILEGED capability absent from
119
+ # +allow:+ — that is the one case knowable ahead of time. Every other mode
120
+ # decides per call and must be reported as *possible*: +:auto+ allows, +:ask+
121
+ # consults its hook, and +:approve+ has to reach the gate so it can raise
122
+ # ApprovalRequired and suspend the run.
123
+ #
124
+ # Agent#chat uses this to skip ATTACHING a tool the model could never
125
+ # successfully call, so a guaranteed failure stops occupying the tool schema
126
+ # on every turn. That makes this a cost and description-accuracy measure, not
127
+ # a security boundary: #authorize! remains the gate and still denies at call
128
+ # time whether or not the tool was advertised.
129
+ def never_allows?(capability)
130
+ return false if @allow.include?(capability)
131
+
132
+ @mode == :read_only && PRIVILEGED.include?(capability)
133
+ end
134
+
112
135
  # Authorizes an MCP tool *call* by name. A deliberate sibling of #authorize!
113
136
  # on a separate capability axis: an MCP tool runs inside the MCP server,
114
137
  # outside the sandbox, so this gates the authority to *invoke* it — a different
data/lib/nexo/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Nexo
4
4
  # The gem version (also used as +spec.version+ in the gemspec).
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nexo_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez