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 +4 -4
- data/CHANGELOG.md +34 -0
- data/docs/permissions.md +17 -2
- data/docs/sandboxes.md +13 -4
- data/docs/tools.md +16 -6
- data/lib/nexo/agent.rb +18 -8
- data/lib/nexo/permissions.rb +26 -3
- data/lib/nexo/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a372c4ac5c253a76cbf930d99b134bbdc62c596dc8bde4414da26f08c99ad39
|
|
4
|
+
data.tar.gz: 8329003fd163b8b4131f4608b18ecb5604717b7dd6995699328216cb22143320
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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) | ✅ | ✅ | ❌
|
|
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.
|
|
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
|
-
- **
|
|
58
|
-
a
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
|
|
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)
|
data/lib/nexo/permissions.rb
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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