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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e3ef801100c09633b3bc0eba7f1c5444abc3a28d39b093b3f556a6e6a193304
4
- data.tar.gz: 770aa194ebef30adc05049890df4611914d9af49ce5bc37460d142dce0a5277d
3
+ metadata.gz: 6c12fe51de0643c44b5ede5f140d45a2c2c50b15b8f357ad0e40132bcd40034c
4
+ data.tar.gz: 6c98b8a0adab754ba7ea290ca2227df8f464f9a71407ad4956b6a985b5a40346
5
5
  SHA512:
6
- metadata.gz: 201531d77a85255355cf77d34d0dd676843f20dee2149627226a1f9068c5b6dfcc7e9e4d92e33b357536326f35e861d9fc1d1d6230bac1e5eac5db9314e3ec75
7
- data.tar.gz: aff13284d24b6465a10a34b60847d6ceece755b8d8791b688777a6ec7538d7506db82922a9e83d4382a66d3045ee45ee3a4c9eede4d2b8a37e4021e9e134a934
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 (networking especially) the class branches on the
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` parity is NOT yet verified** — especially networking. Every Apple flag
243
- in the parity table below is UNVERIFIED; confirm the flag/subcommand against Apple's CLI
244
- before trusting the `:apple` runtime in production.
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
- This table is filled **only** from live Group 0 runs against a real Apple `container` runtime —
255
- never from assumption. Apple's runtime is macOS-only and is **not** present in CI (or in the
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` | ✓ | _unverified_ | verify before trust |
264
- | `exec -i` | ✓ | _unverified_ | verify before trust |
265
- | `ps -aqf` | ✓ | _unverified_ | verify before trust |
266
- | `start <id>` | ✓ | _unverified_ | verify before trust |
267
- | `rm -f <id>` | ✓ | _unverified_ | verify before trust |
268
- | `--label` / `label=` filter | ✓ (exact) | _unverified_ | **reconnect raises `ConfigurationError` until confirmed** |
269
- | `--network` | ✓ | _unverified_ | expected to diverge — verify before trust |
270
- | `--tmpfs` | ✓ | _unverified_ | verify before trust |
271
- | `--read-only` | ✓ | _unverified_ | verify before trust |
272
- | `--cap-drop` / `--cap-add` | ✓ | _unverified_ | verify before trust |
273
- | `--security-opt no-new-privileges` | ✓ | _unverified_ | verify before trust |
274
- | `--pids-limit` | ✓ | _unverified_ | verify before trust |
275
- | `-w` / `-v` / `-e` / `--user` / `--memory` / `--cpus` | ✓ | _unverified_ | verify before trust |
276
-
277
- Keep-alive (Docker, Group 0): `tail -f /dev/null` holds Alpine / Debian-slim / ruby-slim open —
278
- **to be confirmed on the maintainer's live daemon** (busybox-portable by construction; `sleep
279
- infinity` is not and was replaced for exactly this reason).
280
-
281
- > **Reduced-guarantee posture.** Where a hardening flag turns out to have no Apple equivalent,
282
- > the container stays functional but the guarantee is **reduced** — and that reduction is
283
- > documented here, never silently dropped. Until the table above is filled from a live run, a
284
- > maintainer must not assume any given `:apple` hardening flag is honored.
285
-
286
- Live container runs are exercised by `NEXO_LIVE`-gated smoke
287
- (`test/sandboxes/container_live_test.rb`); the core suite asserts argv construction with no
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). The model reaches a skill's `references/`/`scripts/`
65
- files through Nexo's own permission-gated, sandbox-backed tools — so **attaching a skill
66
- never widens what an agent can do** beyond its configured sandbox/permission mode.
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)