nexo_ai 0.8.0 → 0.9.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 +145 -0
- data/docs/concurrency.md +28 -0
- data/docs/loops.md +27 -0
- data/docs/mcp.md +26 -0
- data/docs/sandboxes.md +175 -39
- data/docs/skills.md +82 -3
- data/docs/workflows.md +47 -1
- data/lib/nexo/agent.rb +199 -3
- data/lib/nexo/configuration.rb +8 -0
- data/lib/nexo/loops/ruby_llm.rb +30 -2
- data/lib/nexo/sandbox.rb +106 -0
- data/lib/nexo/sandboxes/container.rb +52 -6
- data/lib/nexo/sandboxes/remote.rb +22 -1
- data/lib/nexo/skills.rb +78 -3
- data/lib/nexo/version.rb +1 -1
- data/lib/nexo/workflow.rb +125 -10
- data/lib/nexo.rb +6 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c12fe51de0643c44b5ede5f140d45a2c2c50b15b8f357ad0e40132bcd40034c
|
|
4
|
+
data.tar.gz: 6c98b8a0adab754ba7ea290ca2227df8f464f9a71407ad4956b6a985b5a40346
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 221c754bcd1d4d46bf8550c5411c95e194840b5c9de42dbf66a4035a7b5d95bdccb2fb8c5a5ade8eccf88e238e763cb59b9e97d2b40d7d92fc9aee273dfe8376
|
|
7
|
+
data.tar.gz: 337b96d65dcbfcb2d879ed2e6dc62867d1a27a768f0693e007db8a0f562de0a6e5d3d129f0092d804eb531cbac0b375b7b97731016e1551cd9d1c164bae94e05
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,150 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.9.0] - 2026-08-20
|
|
4
|
+
|
|
5
|
+
Skills and sandboxes learn to talk about the environment, and an agent's output finally
|
|
6
|
+
survives its sandbox. A skill can state what it needs (`compatibility:`, which was parsed
|
|
7
|
+
and then dropped), a sandbox can report what it has (`Sandbox#environment`), and an agent
|
|
8
|
+
can require the two to match before the first turn. Separately, an agent's declared output
|
|
9
|
+
is now collected before teardown — a workflow releases its sandbox on **every** terminal
|
|
10
|
+
path, `suspended` included, so pausing for a human approval used to destroy everything the
|
|
11
|
+
run had produced on a container tier while the identical code on `:local` kept it. Along
|
|
12
|
+
the way `Workflow#artifact` turned out never to have worked outside `:virtual`. Verified
|
|
13
|
+
end to end against Docker 29.4.0 and Apple `container` 1.2.2.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **A skill's `compatibility:` frontmatter now reaches the model.** `apply_instructions`
|
|
18
|
+
passed only `skill.content`, so `compatibility:` — the Agent Skills spec's own field for
|
|
19
|
+
stating what a skill needs in order to run — was parsed and then dropped. It is now
|
|
20
|
+
appended to the skill's body as a labelled `Compatibility: …` line. Skills that do not
|
|
21
|
+
set it contribute exactly their body, byte for byte, as before. `license:` and
|
|
22
|
+
`allowed-tools:` stay unsurfaced on purpose: the first is prompt noise, and the second
|
|
23
|
+
would be a second source of truth about what an agent may do, competing with
|
|
24
|
+
`Nexo::Permissions`.
|
|
25
|
+
- **`Sandbox#environment` — a sandbox can report what it actually provides.** One POSIX
|
|
26
|
+
`sh` round trip returns the commands on `PATH` (with versions) and the locale, memoized
|
|
27
|
+
for the sandbox's lifetime and extensible per call. It never raises: a shell-less
|
|
28
|
+
sandbox and a probe that could not run both answer empty, carrying the reason under
|
|
29
|
+
`:error`, because "there is no ruby" and "I never got to look" have different fixes.
|
|
30
|
+
Deliberately coarse — commands and locale, never packages.
|
|
31
|
+
- **`requires` on an agent, checked before the first turn.**
|
|
32
|
+
`requires commands: {"ruby" => ">= 3.1"}, locale: :utf8` raises `Nexo::EnvironmentError`
|
|
33
|
+
listing every unmet requirement at once, instead of letting the run reach
|
|
34
|
+
`sh: ruby: not found` several turns in. Declaring nothing is the default and costs no
|
|
35
|
+
probe. Motivating case: a container has **no locale even when it has a full toolchain**,
|
|
36
|
+
under which Ruby's default external encoding is `US-ASCII` and a bare `File.read` on a
|
|
37
|
+
UTF-8 file raises — measured on Docker and Apple `container` alike.
|
|
38
|
+
- **`Nexo::EnvironmentError`** (a `ConfigurationError`) for the above: the fix is in the
|
|
39
|
+
image or the sandbox wiring rather than in the Ruby.
|
|
40
|
+
- **`produces` on an agent, collected into the run before the sandbox dies.** An agent
|
|
41
|
+
declares the artifacts it writes (`produces "dashboard.html", "out/*.json"` — many per
|
|
42
|
+
agent, globs allowed) and `run_agent` copies them out and records them on the run the
|
|
43
|
+
moment the agent finishes, **including when it suspended for approval or raised**. A
|
|
44
|
+
workflow releases its sandbox on every terminal path and `Container#close` is `rm -f`,
|
|
45
|
+
so before this a durable-approval pause destroyed everything the run had produced, while
|
|
46
|
+
the identical code on `:local` kept it. Verified end to end through real ephemeral
|
|
47
|
+
containers on Docker 29.4.0 and Apple `container` 1.2.2.
|
|
48
|
+
- **`Workflow#artifact(name, path:)`** — a verbatim third mode. `from:` renders ERB and is
|
|
49
|
+
documented as trusted templates only, which agent output can never be; `path:` copies
|
|
50
|
+
sandbox bytes with no rendering. Non-UTF-8 bytes are Base64-wrapped so they survive a
|
|
51
|
+
JSON column; `Workflow.artifact_body(art)` decodes.
|
|
52
|
+
- **`Workflow#restore_artifacts`** — materializes recorded artifacts back into the run's
|
|
53
|
+
sandbox, so a later stage can read what an earlier one produced even on an ephemeral
|
|
54
|
+
tier. The missing counterpart to `Skills.materialize`.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **`Workflow#artifact` wrote to an absolute `/artifacts/<name>`, which every real sandbox
|
|
59
|
+
rejects.** `Local#absolute` and `Container#guard_path` both raise
|
|
60
|
+
`SecurityError: path escapes sandbox`, so the feature only ever worked on `:virtual`,
|
|
61
|
+
whose in-memory paths are unguarded. The copy is now workspace-relative
|
|
62
|
+
(`artifacts/<name>`) and resolves under the root on all four tiers.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **The in-sandbox copy of an artifact moved from `/artifacts/<name>` to
|
|
67
|
+
`artifacts/<name>`**, relative to the sandbox root. Visible only to `:virtual` users —
|
|
68
|
+
the only ones for whom `#artifact` worked at all — and only if they read the copy back
|
|
69
|
+
by absolute path. The recorded `run.artifacts` data is unchanged.
|
|
70
|
+
|
|
71
|
+
## [0.8.1] - 2026-08-19
|
|
72
|
+
|
|
73
|
+
Sandbox tiers made honest and skill resources made usable. A skill's `scripts/` and
|
|
74
|
+
`assets/` live outside every sandbox, so an agent could not reach them; `Skills.materialize`
|
|
75
|
+
stages them in through the sandbox's own `#write`, which is the one route that works on
|
|
76
|
+
`:local`, `:container` and `:remote` alike. Along the way the `:apple` runtime turned out
|
|
77
|
+
to be unable to start a container at all, and `Container#write` was reporting success
|
|
78
|
+
while writing nothing. Verified end to end against Apple `container` 1.2.2 and Docker
|
|
79
|
+
29.4.0.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **`Container#write` creates parent directories and raises on failure.** It ran
|
|
84
|
+
`sh -c 'cat > "$0"'` and discarded the exit status, so writing a nested path into a
|
|
85
|
+
fresh workspace failed with `Directory nonexistent` while reporting success to the
|
|
86
|
+
caller. `Local#write` has always done `mkdir_p` and raised; the two now match. Staging
|
|
87
|
+
anything with a directory in its path — a skill's `scripts/render.rb` — silently
|
|
88
|
+
produced nothing on a container before this.
|
|
89
|
+
- **The `:apple` runtime can start a container.** `#run_argv` passed
|
|
90
|
+
`--security-opt no-new-privileges` and `--pids-limit`, which Apple's `container` CLI
|
|
91
|
+
rejects with `Unknown option`, so `runtime: :apple` failed before any tool ran. Both
|
|
92
|
+
come from defaults, so no configuration avoided it. They are now omitted for `:apple`
|
|
93
|
+
and reported through `#hardening_gaps`.
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- **`Nexo::Skills.materialize(name, into:)`** — copies a skill's `scripts/`, `assets/`
|
|
98
|
+
and `references/` into a sandbox so an agent can actually reach them, through the
|
|
99
|
+
sandbox's own `#write`. Returns **sandbox-relative** paths so callers can build a
|
|
100
|
+
command without knowing which tier they are on. `kinds:` narrows the copy;
|
|
101
|
+
`overwrite: false` skips files already present, for images that bake the skill in.
|
|
102
|
+
- **`Container#hardening_gaps`** — hardening the caller asked for that the runtime
|
|
103
|
+
cannot honor, as human-readable strings; empty on `:docker`. Running with weaker
|
|
104
|
+
isolation than requested is now visible rather than silent. It also reports that
|
|
105
|
+
Apple's `--tmpfs` is not writable under `--read-only`, which makes the default
|
|
106
|
+
`readonly_rootfs: true` leave an `:apple` sandbox with no writable workspace.
|
|
107
|
+
- **`Agent#wrap_mcp_tool(tool)`** — a hook called once per MCP tool, after gating and
|
|
108
|
+
before attachment, for decorating every MCP tool an agent gets (capping an oversized
|
|
109
|
+
reply, timings, redaction). Default returns the tool unchanged; previously the only
|
|
110
|
+
way in was overriding the private `#apply_mcp`. Gating happens underneath, so a
|
|
111
|
+
wrapper cannot widen what the agent may do.
|
|
112
|
+
- **`Nexo.config.tool_concurrency`** — surfaces RubyLLM's concurrent execution of
|
|
113
|
+
multiple tool calls from one assistant turn (`:fibers` / `:threads` / `false`).
|
|
114
|
+
Defaults to `nil`, leaving RubyLLM's own setting alone, so behaviour is unchanged
|
|
115
|
+
until you opt in. Applied after every tool is attached, since each `with_tools`
|
|
116
|
+
resets it.
|
|
117
|
+
- **`Sandboxes::Remote#instructions`**, with an optional `instructions:` on the
|
|
118
|
+
constructor. `Local` and `Container` describe their environment to the agent;
|
|
119
|
+
`Remote` returned `nil`, leaving the tier most likely to surprise a tool-caller the
|
|
120
|
+
one it knew least about.
|
|
121
|
+
|
|
122
|
+
### Changed
|
|
123
|
+
|
|
124
|
+
- **`max_turns` is now counted and reported** on `Loops::RubyLLM` instead of being
|
|
125
|
+
accepted and ignored. It still cannot halt a run — ruby_llm executes the whole tool
|
|
126
|
+
loop inside `Chat#ask` and its callbacks are observation-only — but exceeding the
|
|
127
|
+
budget now emits `:turn_limit_exceeded` with `{turns:, max_turns:}`, once per prompt.
|
|
128
|
+
Previously the parameter read as a safety bound it has never been.
|
|
129
|
+
|
|
130
|
+
### Documentation
|
|
131
|
+
|
|
132
|
+
- **`docs/skills.md`: bundled files are not reachable until staged.** The docs stated
|
|
133
|
+
that a skill's `scripts/`/`references/` are reached through Nexo's sandbox-backed
|
|
134
|
+
tools. They are not — a skill lives under `skills_path`, outside every sandbox, and
|
|
135
|
+
nothing bridged the two. Now documents `Skills.materialize`, the relative-path rule,
|
|
136
|
+
the ambient-environment pitfall, and the writable-space tradeoff.
|
|
137
|
+
- **Apple `container` parity table filled from live runs**, replacing the previous
|
|
138
|
+
all-`unverified` placeholder. Records that `--network none` *does* work on Apple,
|
|
139
|
+
that `ps -aqf` does not exist there (Apple has `list`), and the
|
|
140
|
+
`--tmpfs`-under-`--read-only` divergence.
|
|
141
|
+
- **`docs/sandboxes.md`: path confinement on `:remote` is the client's job.** `Local`
|
|
142
|
+
and `Container` raise `SecurityError` on escape; `Remote` passes paths through
|
|
143
|
+
untouched. Deliberate, but it moves a guarantee callers may rely on.
|
|
144
|
+
- `docs/mcp.md` documents `wrap_mcp_tool`; `docs/concurrency.md` documents
|
|
145
|
+
`tool_concurrency` and that tools must be concurrency-safe before enabling it;
|
|
146
|
+
`docs/loops.md` documents what `max_turns` does and does not do.
|
|
147
|
+
|
|
3
148
|
## [0.8.0] - 2026-07-16
|
|
4
149
|
|
|
5
150
|
Durability enhancements for workflows: a suspended run can now wake itself on a
|
data/docs/concurrency.md
CHANGED
|
@@ -97,4 +97,32 @@ Note that DB work under a reactor is *offloaded/pooled*, not truly fiber-async
|
|
|
97
97
|
Nexo does not ship a fiber-native DB driver. For server setup (Falcon, the fiber
|
|
98
98
|
scheduler), see the [`async` guide](https://socketry.github.io/async/).
|
|
99
99
|
|
|
100
|
+
|
|
101
|
+
## Tool concurrency — several tool calls in one turn
|
|
102
|
+
|
|
103
|
+
`Nexo.config.concurrency` governs how **Nexo** runs blocking work. A separate setting
|
|
104
|
+
governs how **RubyLLM** executes multiple tool calls returned in a *single* assistant
|
|
105
|
+
turn:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Nexo.configure { |c| c.tool_concurrency = :fibers } # :fibers | :threads | false | nil
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- `:fibers` — via `async`, the same driver `Nexo.concurrent` uses.
|
|
112
|
+
- `:threads` — OS threads.
|
|
113
|
+
- `false` — one at a time.
|
|
114
|
+
- `nil` (**default**) — leave RubyLLM's own setting alone, so behaviour is unchanged
|
|
115
|
+
until you opt in.
|
|
116
|
+
|
|
117
|
+
It is applied to the chat after every tool is attached, because each `with_tools` call
|
|
118
|
+
resets the chat's concurrency.
|
|
119
|
+
|
|
120
|
+
This only matters when a model returns several tool calls in one turn. Tools that batch
|
|
121
|
+
their own work — one call that reads twenty messages rather than twenty calls — make it
|
|
122
|
+
largely moot, and are the better optimization where you control the tool.
|
|
123
|
+
|
|
124
|
+
**Your tools must be safe to run concurrently before you turn this on.** Anything sharing
|
|
125
|
+
a process-wide resource — a CLI that serializes on a keychain, a single connection, a
|
|
126
|
+
non-reentrant client — needs its own lock regardless of this setting.
|
|
127
|
+
|
|
100
128
|
← Back to the [README](../README.md)
|
data/docs/loops.md
CHANGED
|
@@ -90,4 +90,31 @@ NEXO_LIVE=1 NEXO_MODEL=gemma3:12b bundle exec rake test TEST=test/live_smoke_tes
|
|
|
90
90
|
If Gemma's tool-calling proves too weak, point `NEXO_MODEL` at a stronger model — the gem
|
|
91
91
|
stays provider-neutral; only the smoke target changes.
|
|
92
92
|
|
|
93
|
+
|
|
94
|
+
## `max_turns` is a budget, not a hard stop
|
|
95
|
+
|
|
96
|
+
`Agent#prompt`, `Session#prompt` and `Workflow#run_agent` all take `max_turns:`. On the
|
|
97
|
+
default `Loops::RubyLLM` it **cannot halt a run**: ruby_llm executes the entire tool loop
|
|
98
|
+
inside `Chat#ask`, and the callbacks Nexo wires are observation-only.
|
|
99
|
+
|
|
100
|
+
What it does do is count tool calls and tell you when the budget is passed:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
agent.prompt("Triage the inbox", max_turns: 10) do |type, payload|
|
|
104
|
+
warn "over budget: #{payload}" if type == :turn_limit_exceeded
|
|
105
|
+
end
|
|
106
|
+
# => :turn_limit_exceeded, { turns: 11, max_turns: 10 }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Emitted once per prompt, not per call.
|
|
110
|
+
|
|
111
|
+
If you need a **hard** ceiling, the options are:
|
|
112
|
+
|
|
113
|
+
- bound the work inside your own tools (a read tool that accepts at most N ids);
|
|
114
|
+
- use `Loops::AgentSDK`, whose engine enforces `max_turns` natively;
|
|
115
|
+
- stop the run yourself from the event block.
|
|
116
|
+
|
|
117
|
+
Treat `max_turns` on the default loop as telemetry. It was previously accepted and never
|
|
118
|
+
read, which made it look like a safety bound it has never been.
|
|
119
|
+
|
|
93
120
|
← Back to the [README](../README.md)
|
data/docs/mcp.md
CHANGED
|
@@ -141,4 +141,30 @@ deny-all). Attaching an authenticated server adds no permission surface.
|
|
|
141
141
|
server. Without it installed, `require "nexo"` still loads; building a server raises a clear
|
|
142
142
|
`Nexo::MissingDependencyError` telling you to add `gem "ruby_llm-mcp"`.
|
|
143
143
|
|
|
144
|
+
|
|
145
|
+
## Decorating MCP tools
|
|
146
|
+
|
|
147
|
+
`wrap_mcp_tool` is called once per MCP tool, after it is wrapped in a permission-gating
|
|
148
|
+
`MCP::GatedTool` and before it is attached. Override it to decorate every MCP tool an
|
|
149
|
+
agent gets — a third-party server's response shape is not yours to change, and its tools
|
|
150
|
+
are attached by the harness rather than by you:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
class MailAgent < Nexo::Agent
|
|
154
|
+
mcp :mail, transport: :stdio, command: "apple-mail-mcp"
|
|
155
|
+
|
|
156
|
+
# This server returns an entire message body with no cap; bound it.
|
|
157
|
+
def wrap_mcp_tool(tool)
|
|
158
|
+
CappedTool.new(tool: tool, max_chars: 4_000)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A wrapper must keep the duck type the chat relies on — `#name`, `#description`,
|
|
164
|
+
`#params_schema` and `#call`. `GatedTool` delegates the rest through `method_missing`, so
|
|
165
|
+
wrappers compose.
|
|
166
|
+
|
|
167
|
+
Gating happens **underneath** the wrapper, so it only ever sees an already-authorized
|
|
168
|
+
call. Wrapping cannot widen what an agent may do.
|
|
169
|
+
|
|
144
170
|
← Back to the [README](../README.md)
|
data/docs/sandboxes.md
CHANGED
|
@@ -94,6 +94,85 @@ scope; none widens authority silently.
|
|
|
94
94
|
)
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
## Ask a sandbox what it has — `#environment`
|
|
98
|
+
|
|
99
|
+
`#instructions` describes the execution environment *for the model*. `#environment`
|
|
100
|
+
answers the same question *for code*, in one `#shell` round trip:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
sandbox.environment
|
|
104
|
+
# => { commands: { "ruby" => { path: "/usr/local/bin/ruby", version: "4.0.0" } },
|
|
105
|
+
# locale: "C.UTF-8",
|
|
106
|
+
# error: nil }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
It exists because a skill's script runs wherever the sandbox is, which is not the machine
|
|
110
|
+
it was written on. Measured on three real sandboxes:
|
|
111
|
+
|
|
112
|
+
| Sandbox | Probe | `locale` | Commands found |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| `:local` (host) | 0.14s | `en_US.UTF-8` | ruby 4.0.0, python3 3.14.5, node 26.3.0 |
|
|
115
|
+
| `:docker alpine:latest` | 0.25s | **none** | sh only |
|
|
116
|
+
| `:docker` full image (~5 GB) | 0.50s | **none** | ruby 4.0.0, python3 3.12.3, node 26.7.0 |
|
|
117
|
+
| `:apple` full image | 1.84s | **none** | ruby 4.0.0, python3 3.12.3, node 26.4.0 |
|
|
118
|
+
|
|
119
|
+
Note the last three rows: **a container has no locale even when it has a full toolchain.**
|
|
120
|
+
Under an unset locale Ruby's default external encoding is `US-ASCII`, and a bare
|
|
121
|
+
`File.read` on a UTF-8 file raises `Encoding::InvalidByteSequenceError`. "Has the
|
|
122
|
+
interpreter" and "can read a UTF-8 file" are independent, so they are reported separately.
|
|
123
|
+
|
|
124
|
+
The probe is POSIX `sh` — no interpreter needed on the far side, so it works on busybox —
|
|
125
|
+
memoized for the sandbox's lifetime, and extensible per call:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
sandbox.environment(commands: %w[ruby convert])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**It never raises.** A sandbox with no shell (`Virtual`) and a probe that could not run
|
|
132
|
+
both report empty, with the reason under `:error`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
Nexo::Sandboxes::Virtual.new.environment[:error]
|
|
136
|
+
# => "sandbox has no shell"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
That distinction matters: "I looked and there is no ruby" and "I never got to look" have
|
|
140
|
+
different fixes. An `:apple` container left on the default `readonly_rootfs: true`, for
|
|
141
|
+
instance, cannot start a process at all (`The volume is read only`) — reported as an
|
|
142
|
+
`:error`, not as an empty toolchain.
|
|
143
|
+
|
|
144
|
+
**Scope is deliberately coarse: commands on `PATH` and the locale, never packages.** Gems,
|
|
145
|
+
wheels and npm modules belong to whoever builds the image; modelling them here would be a
|
|
146
|
+
cross-language dependency resolver competing with the manifest every ecosystem already has.
|
|
147
|
+
|
|
148
|
+
### Declaring what an agent needs
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
class Publisher < Nexo::Agent
|
|
152
|
+
skills :dashboard_designer
|
|
153
|
+
requires commands: {"ruby" => ">= 3.1"}, locale: :utf8
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Checked once, before the first turn, and it fails with every unmet requirement at once:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
Publisher cannot run here: no ruby on PATH; no locale set (needs a UTF-8 locale).
|
|
161
|
+
Provision the sandbox, or drop the `requires` declaration.
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- `commands:` maps a command to a `Gem::Requirement` string, or `"*"` for any version. A
|
|
165
|
+
command whose version cannot be read (busybox `sh` prints none) satisfies any constraint
|
|
166
|
+
by being present — an unreadable version is not evidence of a wrong one.
|
|
167
|
+
- `locale:` takes `:utf8` (any UTF-8 locale, the case that actually comes up) or an exact
|
|
168
|
+
`String`.
|
|
169
|
+
- Declaring nothing is the default and costs **no probe at all**.
|
|
170
|
+
|
|
171
|
+
The declaration lives here, in Nexo's vocabulary, rather than in the skill file: whoever
|
|
172
|
+
wires an agent to a sandbox is the only person who can *fix* a gap, so the declaration and
|
|
173
|
+
the fix sit together. A skill states its needs in prose through `compatibility:`, which is
|
|
174
|
+
the Agent Skills spec's field for it and is aimed at a human or a model.
|
|
175
|
+
|
|
97
176
|
## Remote sandbox — bring your own container
|
|
98
177
|
|
|
99
178
|
`Sandboxes::Remote` contains **zero vendor code**. It wraps any object that satisfies a
|
|
@@ -138,6 +217,30 @@ Nexo ships **only** `Remote` plus this documented pattern — purpose-built
|
|
|
138
217
|
`Sandboxes::E2B` / `Sandboxes::Daytona` classes are a possible future addition, deliberately
|
|
139
218
|
left out of v1 because their vendor client APIs aren't pinned yet.
|
|
140
219
|
|
|
220
|
+
### Path confinement is the client's job
|
|
221
|
+
|
|
222
|
+
`Local` and `Container` confine every `read`/`write`/`glob` to their working directory and
|
|
223
|
+
raise `SecurityError` on escape. **`Remote` does not.** It passes each path to the injected
|
|
224
|
+
client untouched, because only the provider knows what its own boundary is.
|
|
225
|
+
|
|
226
|
+
That is deliberate — Nexo re-implementing a confinement it cannot enforce would be
|
|
227
|
+
theatre — but it moves a guarantee you may be relying on. A shim is responsible for:
|
|
228
|
+
|
|
229
|
+
- rejecting paths that escape the session's working directory;
|
|
230
|
+
- deciding what `write` does with a missing parent directory (`Local` creates it,
|
|
231
|
+
`Container` creates it — a shim should too, or say that it does not);
|
|
232
|
+
- raising on failure rather than returning a non-zero status the caller may ignore.
|
|
233
|
+
|
|
234
|
+
Give the shim an `instructions:` describing the environment, too — `Remote` cannot derive
|
|
235
|
+
one, and an agent told nothing about where it runs will assume it is on the host:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
Nexo::Sandboxes::Remote.new(
|
|
239
|
+
client: my_client,
|
|
240
|
+
instructions: "You run in an E2B sandbox, cwd /home/user. Ruby 3.4 is available."
|
|
241
|
+
)
|
|
242
|
+
```
|
|
243
|
+
|
|
141
244
|
## Container sandbox — Docker / Apple Container
|
|
142
245
|
|
|
143
246
|
`Sandboxes::Container` runs an agent's tools inside a **throwaway OCI container** via the
|
|
@@ -161,12 +264,50 @@ the host dir enters only through a `binds:` entry.
|
|
|
161
264
|
|
|
162
265
|
`sandbox :docker` (or `runtime: :docker`) shells out to `docker`; `sandbox :apple`
|
|
163
266
|
(`runtime: :apple`) shells out to Apple's `container` binary. The `run`/`exec` surface is
|
|
164
|
-
largely shared; where the CLIs diverge
|
|
165
|
-
runtime. **Apple `container` parity is NOT yet verified** — the flags are encoded from the
|
|
166
|
-
reference mapping, not confirmed against a live daemon, so every Apple flag must be verified
|
|
167
|
-
before trust (the networking flag in particular; see the parity table below). An unknown
|
|
267
|
+
largely shared; where the CLIs diverge the class branches on the runtime. An unknown
|
|
168
268
|
runtime raises `Nexo::ConfigurationError`.
|
|
169
269
|
|
|
270
|
+
**Apple `container` parity is now verified** against `container` 1.2.2 on macOS. Three
|
|
271
|
+
flags docker accepts are rejected outright, so they are omitted for `:apple`:
|
|
272
|
+
|
|
273
|
+
| Knob | `:docker` | `:apple` |
|
|
274
|
+
|---|---|---|
|
|
275
|
+
| `--security-opt no-new-privileges` | applied | **rejected** (`Unknown option`) — omitted |
|
|
276
|
+
| `--pids-limit` | applied (`512`) | **rejected** (`Unknown option`) — omitted |
|
|
277
|
+
| `--read-only` + `--tmpfs <cwd>:rw` | scratch is writable | flags accepted, but **the scratch is NOT writable** |
|
|
278
|
+
|
|
279
|
+
Everything else — `--network` (including `none`), `--cap-drop ALL`, `--memory`,
|
|
280
|
+
`--cpus`, `--user`, `-v` binds, `-e` env, `-w`, `exec -i` — is honored identically.
|
|
281
|
+
|
|
282
|
+
> **`readonly_rootfs: true` is the default, and on `:apple` it leaves you with no
|
|
283
|
+
> writable workspace.** Apple accepts `--tmpfs /workspace:rw` but under `--read-only`
|
|
284
|
+
> the mount is still read-only, so every `write` fails. Pass `readonly_rootfs: false`,
|
|
285
|
+
> or mount a `:rw` bind, when using the `:apple` runtime.
|
|
286
|
+
|
|
287
|
+
Because omitting a hardening flag means *weaker isolation than you asked for*, the
|
|
288
|
+
difference is reported rather than hidden:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
sandbox = Nexo::Sandboxes::Container.new(image: "…", runtime: :apple, network: :none)
|
|
292
|
+
sandbox.hardening_gaps
|
|
293
|
+
# => ["--security-opt no-new-privileges is not supported by apple",
|
|
294
|
+
# "--pids-limit is not supported by apple",
|
|
295
|
+
# "apple has no 'none' network; using 'default' instead"]
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`hardening_gaps` is empty on `:docker`. If your threat model requires one of those knobs,
|
|
299
|
+
check it and refuse rather than assuming every runtime honors every flag.
|
|
300
|
+
|
|
301
|
+
Two practical notes for Apple `container`:
|
|
302
|
+
|
|
303
|
+
- It resolves its **init image through the keychain** and fails in a non-interactive
|
|
304
|
+
shell (`errSecAuthFailed`, status `-25293`). Pinning an already-pulled tag avoids the
|
|
305
|
+
lookup: `container run --init-image ghcr.io/apple/containerization/vminit:<tag> …`.
|
|
306
|
+
- Containers are **VM-backed**, so infrastructure failures surface differently — e.g.
|
|
307
|
+
`no available interface strategy for network default, plugin=container-network-vmnet`
|
|
308
|
+
when the network service needs restarting (`container system stop && container system
|
|
309
|
+
start`).
|
|
310
|
+
|
|
170
311
|
### Hardened by default — every knob an explicit opt-out
|
|
171
312
|
|
|
172
313
|
All of the following are applied to the `run` argv by default and individually invertible:
|
|
@@ -239,9 +380,9 @@ The container starts **lazily** on first tool use and its id is memoized.
|
|
|
239
380
|
`:rw` bind (this is where staged files and artifacts land).
|
|
240
381
|
- **Non-root is recommended, not forced.** The default hardening holds regardless of uid; set
|
|
241
382
|
`user:` for defense-in-depth.
|
|
242
|
-
- **Apple `container`
|
|
243
|
-
|
|
244
|
-
|
|
383
|
+
- **Apple `container` needs `readonly_rootfs: false`.** The default read-only rootfs makes the
|
|
384
|
+
`--tmpfs` scratch unwritable on Apple, so an agent cannot write to its own workspace. The
|
|
385
|
+
parity table below records what was verified.
|
|
245
386
|
- **Reconnect is Docker-only today.** `reconnect: true` combined with `runtime: :apple` raises
|
|
246
387
|
`Nexo::ConfigurationError` at the point reconnect would run. Apple's `container` CLI has no
|
|
247
388
|
**live-verified** exact `label=` filter, and a name-substring match is unsafe (it can attach
|
|
@@ -251,40 +392,35 @@ The container starts **lazily** on first tool use and its id is memoized.
|
|
|
251
392
|
|
|
252
393
|
#### Apple `container` parity table (Group 0)
|
|
253
394
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
environment this spec shipped from), so the divergences below are **UNVERIFIED** and marked as
|
|
257
|
-
such. Until a maintainer runs Group 0 on Apple hardware and records the results here, treat the
|
|
258
|
-
`:apple` runtime as **Docker-flag-shaped but unconfirmed** and reconnect as unsupported (it
|
|
259
|
-
raises). Do not silently trust any Apple hardening flag until its row reads `same`.
|
|
395
|
+
Filled from live Group 0 runs against **Apple `container` 1.2.2 on macOS (arm64)** and
|
|
396
|
+
**Docker 29.4.0**, image `ubuntu:24.04`. Rows marked _unverified_ have not been run.
|
|
260
397
|
|
|
261
398
|
| Subcommand / flag | Docker | Apple `container` | Divergence → action |
|
|
262
399
|
|---|---|---|---|
|
|
263
|
-
| `run -d` | ✓ |
|
|
264
|
-
| `exec -i` | ✓ |
|
|
265
|
-
| `
|
|
266
|
-
| `
|
|
267
|
-
| `
|
|
268
|
-
| `--
|
|
269
|
-
| `--
|
|
270
|
-
| `--
|
|
271
|
-
| `--
|
|
272
|
-
| `--
|
|
273
|
-
| `--
|
|
274
|
-
|
|
|
275
|
-
| `-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
daemon. See `examples/container_review.rb` for a runnable end-to-end example.
|
|
400
|
+
| `run -d` | ✓ | ✓ | same |
|
|
401
|
+
| `exec -i` (stdin reaches the process) | ✓ | ✓ | same — this is what `#write` uses |
|
|
402
|
+
| `stop` / `start <id>` | ✓ | ✓ | same |
|
|
403
|
+
| `rm -f <id>` | ✓ | ✓ | same |
|
|
404
|
+
| `ps -aqf label=` | ✓ (exact) | ✗ `Plugin 'container-ps' not found` | Apple has `list`, not `ps` → **reconnect raises `ConfigurationError`** |
|
|
405
|
+
| `--network` (incl. `none`) | ✓ | ✓ | same — `--network none` runs |
|
|
406
|
+
| `--cap-drop ALL` | ✓ | ✓ | same |
|
|
407
|
+
| `--read-only` | ✓ | ✓ | rootfs is read-only on both |
|
|
408
|
+
| `--tmpfs <cwd>:rw` | ✓ writable | ✗ **not writable under `--read-only`** | → omit `--read-only` (`readonly_rootfs: false`) or use a `:rw` bind |
|
|
409
|
+
| `--security-opt no-new-privileges` | ✓ | ✗ `Unknown option` | → omitted for `:apple`, reported in `#hardening_gaps` |
|
|
410
|
+
| `--pids-limit` | ✓ | ✗ `Unknown option` | → omitted for `:apple`, reported in `#hardening_gaps` |
|
|
411
|
+
| `-v host:ctr:ro` | ✓ | ✓ | same |
|
|
412
|
+
| `-e KEY=val` | ✓ | ✓ | same |
|
|
413
|
+
| `-w <dir>` | ✓ | ✓ | same |
|
|
414
|
+
| `--user` | ✓ | ✓ | same |
|
|
415
|
+
| `--memory` / `--cpus` | ✓ | ✓ | same |
|
|
416
|
+
|
|
417
|
+
Two operational notes for Apple, both hit during verification:
|
|
418
|
+
|
|
419
|
+
- It resolves its **init image via the keychain** and fails in a non-interactive shell
|
|
420
|
+
(`errSecAuthFailed`, `-25293`). Pin an already-pulled tag to skip the lookup:
|
|
421
|
+
`--init-image ghcr.io/apple/containerization/vminit:<tag>`.
|
|
422
|
+
- Containers are **VM-backed**, so infrastructure failures look unfamiliar — e.g.
|
|
423
|
+
`no available interface strategy for network default, plugin=container-network-vmnet`,
|
|
424
|
+
cleared by `container system stop && container system start`.
|
|
289
425
|
|
|
290
426
|
← Back to the [README](../README.md)
|
data/docs/skills.md
CHANGED
|
@@ -57,12 +57,91 @@ Without it installed, `require "nexo"` still loads; touching a skill raises a cl
|
|
|
57
57
|
`Nexo::MissingDependencyError` telling you to add `gem "ruby_llm-skills"`. Referencing a
|
|
58
58
|
skill that does not exist raises `Nexo::Error` naming the missing `SKILL.md` path.
|
|
59
59
|
|
|
60
|
+
## Say what a skill needs with `compatibility:`
|
|
61
|
+
|
|
62
|
+
A skill's script runs in whatever the agent's sandbox happens to be — which is not the
|
|
63
|
+
machine the script was written on. A container typically has **no locale**, so Ruby's
|
|
64
|
+
default external encoding is `US-ASCII` and a bare `File.read` on a UTF-8 file raises;
|
|
65
|
+
it may also have no interpreter at all.
|
|
66
|
+
|
|
67
|
+
`compatibility:` is the Agent Skills spec's field for saying so, and Nexo passes it to
|
|
68
|
+
the model alongside the skill body:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
---
|
|
72
|
+
name: dashboard-designer
|
|
73
|
+
description: Render the briefing dashboard.
|
|
74
|
+
compatibility: Requires a Ruby interpreter (>= 3.1) and a UTF-8 locale.
|
|
75
|
+
---
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The model sees the body, then `Compatibility: Requires a Ruby interpreter …` as a labelled
|
|
79
|
+
line, so it can tell a requirement from a step. Skills that do not set the field contribute
|
|
80
|
+
exactly their body, unchanged.
|
|
81
|
+
|
|
82
|
+
It is **free text, by design** — the spec does not make it machine-checkable, and Nexo does
|
|
83
|
+
not try to. It is documentation aimed at a human or a model, not a dependency manifest:
|
|
84
|
+
provisioning the sandbox (a gem, a library, a config) is the job of whoever wires the agent
|
|
85
|
+
to it, not of the skill file. `license:` and `allowed-tools:` are parsed by
|
|
86
|
+
`ruby_llm-skills` but deliberately **not** surfaced — the first is prompt noise, and the
|
|
87
|
+
second would compete with `Nexo::Permissions`, which is the real gate.
|
|
88
|
+
|
|
60
89
|
## Skill tools stay gated
|
|
61
90
|
|
|
62
91
|
A skill contributes **instructions only**. A loaded skill ships no independent tools, and
|
|
63
92
|
Nexo deliberately does not attach `ruby_llm-skills`' progressive-disclosure tool (which
|
|
64
|
-
reads files outside the sandbox)
|
|
65
|
-
|
|
66
|
-
|
|
93
|
+
reads files outside the sandbox) — so **attaching a skill never widens what an agent can
|
|
94
|
+
do** beyond its configured sandbox/permission mode.
|
|
95
|
+
|
|
96
|
+
## Bundled files are not reachable until you stage them
|
|
97
|
+
|
|
98
|
+
A skill's `scripts/`, `assets/` and `references/` live under `skills_path`, which is
|
|
99
|
+
**outside every sandbox**. Sandboxes confine file access to their own working directory
|
|
100
|
+
(`Local` and `Container` raise `SecurityError` on escape), so an agent cannot read or run
|
|
101
|
+
a skill's bundled files just because the skill is attached.
|
|
102
|
+
|
|
103
|
+
`Skills.materialize` copies them in, through the sandbox's own `#write` — the only route
|
|
104
|
+
that works on every tier:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
staged = Nexo::Skills.materialize(:dashboard_designer, into: agent.sandbox)
|
|
108
|
+
# => { scripts: ["scripts/render_dashboard.rb"],
|
|
109
|
+
# assets: ["assets/dashboard-template.html"] }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
They are then ordinary workspace files, reached through the permission-gated `read`,
|
|
113
|
+
`glob` and `shell` tools like anything else in the workspace — and the returned paths are
|
|
114
|
+
**sandbox-relative**, so you can build a command without knowing which tier you are on:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
agent.prompt("Run: ruby #{staged[:scripts].first} digest.json #{staged[:assets].first} out.html")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`Local` writes to the filesystem, `Container` streams over `docker exec` / `container
|
|
121
|
+
exec`, and `Remote` hands off to the injected client. The call is identical.
|
|
122
|
+
|
|
123
|
+
| Option | Meaning |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `kinds:` | which of `scripts` / `assets` / `references` to copy (default: all three) |
|
|
126
|
+
| `overwrite:` | `false` skips files already present — what you want against an image that bakes the skill in |
|
|
127
|
+
|
|
128
|
+
Kinds the skill ships nothing for are omitted from the result.
|
|
129
|
+
|
|
130
|
+
Two things to get right:
|
|
131
|
+
|
|
132
|
+
- **Use relative paths** (`"scripts/render.rb"`), in the write and in any command you
|
|
133
|
+
hand the agent. A host-absolute path is meaningless inside a container and on a remote
|
|
134
|
+
sandbox.
|
|
135
|
+
- **A bundled script must not depend on ambient environment.** It runs wherever the
|
|
136
|
+
sandbox is, which may have no locale (Ruby then defaults to `US-ASCII`, and reading a
|
|
137
|
+
UTF-8 file raises `Encoding::InvalidByteSequenceError`), a different `PATH`, or no
|
|
138
|
+
interpreter at all. Be explicit — `File.read(path, encoding: "UTF-8")` — and document
|
|
139
|
+
which interpreter your skill needs.
|
|
140
|
+
|
|
141
|
+
Staged files land in **writable** space. If the agent also holds `:shell` it can rewrite a
|
|
142
|
+
script before running it — where the same file, left outside the sandbox, could not be
|
|
143
|
+
touched at all. `materialize` overwrites by default, so re-materializing at the start of
|
|
144
|
+
every run bounds tampering to a single turn. If that is not enough, keep the resource out
|
|
145
|
+
of the sandbox and feed the agent its contents another way.
|
|
67
146
|
|
|
68
147
|
← Back to the [README](../README.md)
|