nexo_ai 0.1.0 → 0.7.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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.document +3 -0
  3. data/CHANGELOG.md +232 -0
  4. data/README.md +75 -4
  5. data/Rakefile +29 -0
  6. data/app/views/nexo/_event.html.erb +1 -0
  7. data/docs/concurrency.md +94 -0
  8. data/docs/durable-workflows.md +184 -0
  9. data/docs/getting-started.md +103 -0
  10. data/docs/loops.md +93 -0
  11. data/docs/mcp.md +144 -0
  12. data/docs/permissions.md +47 -0
  13. data/docs/rails.md +129 -0
  14. data/docs/sandboxes.md +290 -0
  15. data/docs/sessions.md +99 -0
  16. data/docs/skills.md +68 -0
  17. data/docs/tools.md +38 -0
  18. data/docs/web.md +130 -0
  19. data/docs/workflows.md +255 -0
  20. data/examples/README.md +51 -0
  21. data/examples/approval_agent.rb +64 -0
  22. data/examples/approval_workflow.rb +62 -0
  23. data/examples/artifact_from_template.rb +54 -0
  24. data/examples/chat_session.rb +49 -0
  25. data/examples/code_reviewer.rb +64 -0
  26. data/examples/container_review.rb +52 -0
  27. data/examples/inbox_digest.rb +62 -0
  28. data/examples/inbox_digest_http.rb +69 -0
  29. data/examples/inbox_digest_task.rb +40 -0
  30. data/examples/mcp_filesystem.rb +50 -0
  31. data/examples/news_search.rb +49 -0
  32. data/examples/news_summary.rb +37 -0
  33. data/examples/rails_usage.md +141 -0
  34. data/examples/skills/email_triage/SKILL.md +47 -0
  35. data/examples/skills/news_summary/SKILL.md +37 -0
  36. data/examples/skills/ruby-code-review/SKILL.md +61 -0
  37. data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
  38. data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
  39. data/lib/generators/nexo/install/install_generator.rb +35 -0
  40. data/lib/generators/nexo/install/templates/nexo.rb +19 -0
  41. data/lib/generators/nexo/skill/skill_generator.rb +45 -0
  42. data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
  43. data/lib/generators/nexo/state/state_generator.rb +37 -0
  44. data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
  45. data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
  46. data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
  47. data/lib/nexo/agent.rb +423 -0
  48. data/lib/nexo/concurrent.rb +78 -0
  49. data/lib/nexo/configuration.rb +82 -0
  50. data/lib/nexo/engine.rb +51 -0
  51. data/lib/nexo/loop.rb +30 -0
  52. data/lib/nexo/loops/agent_sdk.rb +68 -0
  53. data/lib/nexo/loops/ruby_llm.rb +66 -0
  54. data/lib/nexo/mcp/gated_tool.rb +70 -0
  55. data/lib/nexo/mcp.rb +96 -0
  56. data/lib/nexo/output_truncator.rb +41 -0
  57. data/lib/nexo/permissions.rb +162 -0
  58. data/lib/nexo/read_tracker.rb +32 -0
  59. data/lib/nexo/run_store.rb +162 -0
  60. data/lib/nexo/sandbox.rb +64 -0
  61. data/lib/nexo/sandboxes/container.rb +293 -0
  62. data/lib/nexo/sandboxes/local.rb +157 -0
  63. data/lib/nexo/sandboxes/remote.rb +77 -0
  64. data/lib/nexo/sandboxes/virtual.rb +42 -0
  65. data/lib/nexo/sandboxes.rb +43 -0
  66. data/lib/nexo/session.rb +152 -0
  67. data/lib/nexo/skills.rb +54 -0
  68. data/lib/nexo/tools/fetch.rb +133 -0
  69. data/lib/nexo/tools/glob.rb +28 -0
  70. data/lib/nexo/tools/read_file.rb +54 -0
  71. data/lib/nexo/tools/shell.rb +35 -0
  72. data/lib/nexo/tools/web_search.rb +81 -0
  73. data/lib/nexo/tools/write_file.rb +61 -0
  74. data/lib/nexo/turbo_broadcaster.rb +41 -0
  75. data/lib/nexo/version.rb +2 -1
  76. data/lib/nexo/workflow.rb +705 -0
  77. data/lib/nexo/workflow_job.rb +42 -0
  78. data/lib/nexo/workflow_run.rb +128 -0
  79. data/lib/nexo.rb +138 -1
  80. data/lib/tasks/nexo.rake +17 -0
  81. data/sig/nexo/agent.rbs +23 -0
  82. data/sig/nexo/output_truncator.rbs +7 -0
  83. data/sig/nexo/permissions.rbs +15 -0
  84. data/sig/nexo/read_tracker.rbs +8 -0
  85. data/sig/nexo/sandbox.rbs +12 -0
  86. data/sig/nexo/sandboxes/container.rbs +26 -0
  87. data/sig/nexo/sandboxes/local.rbs +12 -0
  88. data/sig/nexo/sandboxes/virtual.rbs +7 -0
  89. data/sig/nexo/sandboxes.rbs +5 -0
  90. data/sig/nexo/tools.rbs +28 -0
  91. data/sig/nexo_ai.rbs +22 -1
  92. metadata +185 -2
data/docs/sandboxes.md ADDED
@@ -0,0 +1,290 @@
1
+ # Sandboxes
2
+ The sandbox is *where* an agent's tools act — pick in-memory Virtual, host-backed Local, a throwaway Container, or a Remote you inject.
3
+
4
+ Two seams compose the execution environment. The **sandbox** is *where* tools act; the
5
+ **permission mode** is *what* they may do. A denied capability returns `{ error: ... }`
6
+ and the agent loop continues — it does not raise. A path that escapes the workspace raises
7
+ `SecurityError`; an agent built with no resolvable model raises `Nexo::ConfigurationError`.
8
+
9
+ | | `:read` | `:glob` | `:write` | `:shell` | `:fetch` | `:search` |
10
+ | ------------------------ | ------- | ------- | ------------------- | --------------------------------- | ---------- | ---------- |
11
+ | `:read_only` (default) | ✅ | ✅ | ❌ `{error}` | ❌ `{error}` | ❌ `{error}` | ❌ `{error}` |
12
+ | `:auto` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
13
+ | `:ask` | ✅ | ✅ | per `on_ask` | per `on_ask` | per `on_ask` | per `on_ask` |
14
+ | `:approve` | ✅ | ✅ | per `decision` | per `decision` | per `decision` | per `decision` |
15
+ | `Virtual` sandbox | ✅ | ✅ | ✅ (in-memory) | ❌ `NotImplementedError`→`{error}` | ✅ † | ✅ † |
16
+ | `Local` sandbox | ✅ (guarded) | ✅ | ✅ (guarded) | ✅ (narrowed ENV) | ✅ † | ✅ † |
17
+ | `Container` sandbox | ✅ (guarded) | ✅ | ✅ (guarded, scratch) | ✅ (in container) | ✅ † | ✅ † |
18
+
19
+ `:read`/`:glob` are auto-allowed under **every** mode (they sit in the default
20
+ `allow` list), so `:ask`/`:approve` never prompt for them — only
21
+ `:write`/`:shell`/`:fetch`/`:search` reach the gate. **†** `:fetch` and `:search`
22
+ run in the **host process** (stdlib `net/http` / a host-injected backend), so **no
23
+ sandbox constrains them** — not even a `--network none` container. They are bounded
24
+ only by the capability gate above plus `fetch_allow` / the injected backend.
25
+
26
+ - **`Virtual`** (default) — in-memory, zero host access. `#shell` raises
27
+ `NotImplementedError` on purpose: in-memory means no command execution. That is the
28
+ safety property, not a gap.
29
+ - **`Local`** — host filesystem + shell, for trusted dev/CI. Two guards: every path is
30
+ expanded against `cwd` and must stay inside it (else `SecurityError`), and the shell sees
31
+ only `PATH`, `HOME`, `LANG` (plus explicit `env:` additions) — never the full process
32
+ environment.
33
+ - **`Container`** — run the tools inside a throwaway local container via the `docker`
34
+ (default) or Apple `container` CLI. Shell-out only, no client gem. **Hardened by default**
35
+ (no network, dropped caps, read-only rootfs + ephemeral scratch, read-only host binds);
36
+ every hardening is an explicit opt-out. See [Container sandbox](#container-sandbox--docker--apple-container) below.
37
+ - **`Remote`** — run the tools inside a remote container (E2B / Daytona / Modal / Docker /
38
+ your own) by injecting a client. Escalating to `:remote` is always an explicit choice in
39
+ your code — the default stays `:virtual`.
40
+
41
+ ## Safety refinements — safer, more legible real-FS sandboxes
42
+
43
+ Five small refinements tighten the real-filesystem sandboxes (`Local`, `Container`) and make
44
+ the execution environment more legible to the model. Each wires into an existing seam — no new
45
+ sandbox tier, no new capability, no new dependency. Every one *tightens* a default or *narrows*
46
+ scope; none widens authority silently.
47
+
48
+ - **Self-describing sandbox (`Sandbox#instructions`).** A real-FS sandbox appends one plain-text
49
+ system message describing where the agent runs, so a weak local tool-caller (e.g. Ollama/Gemma)
50
+ knows its environment. `Local` → *"You run on the host machine, cwd /path/to/repo. The real host
51
+ filesystem and shell are reachable; file access is guarded to /path/to/repo."*; `Container` →
52
+ *"You run inside a docker container (image node:22-slim), cwd /workspace, network none…"*.
53
+ `Virtual` says nothing (`#instructions` is `nil`). Ordering in the system messages: **agent
54
+ instructions → sandbox instructions → skill instructions**. Provider-neutral, injected through
55
+ the existing `with_instructions` path.
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.
61
+
62
+ - **Shell output truncation (`Nexo::OutputTruncator`).** Unbounded command output (`npm install`,
63
+ `git log`) is truncated before it reaches the model, so a single command can't blow a small
64
+ context window. `Tools::Shell` wraps `stdout`/`stderr` through
65
+ `OutputTruncator.call(text, max_lines: 200, max_chars: 16_000)` — strips ANSI escapes, keeps the
66
+ **last** `max_lines` lines, appends a `…[truncated N lines]` marker, then caps at `max_chars`.
67
+ The integer `status` passes through untouched. Pure line/char truncation — **no tokenizer**;
68
+ configurable via the kwargs only (no global config, no per-agent macro).
69
+
70
+ - **Read-before-write + stale guard (real-FS only).** Within a session, the agent is blocked from
71
+ overwriting a file it never read, or one that changed underneath it. `Agent#chat` builds one
72
+ `Nexo::ReadTracker` per chat and threads it into `ReadFile` (records `(path, mtime)` on a
73
+ successful read) and `WriteFile` (enforces): overwriting an existing, un-read file returns
74
+ `{error: "read <path> before overwriting it"}`; a file whose mtime changed since the read returns
75
+ `{error: "stale: <path> changed since you read it"}`; a new file writes freely. The guard is
76
+ **real-FS only** — skipped entirely on `Virtual` (nil `mtime`) and when no tracker is passed
77
+ (direct tool construction). Best-effort: mtime-based, so a sub-second external edit may slip past
78
+ the stale check (read-before-write is the primary guard). Clobber-safety within a session only —
79
+ no versioning, locking, or VCS semantics.
80
+
81
+ - **Scoped `:ask` predicate (`ask_when`).** Under `:ask`, `Permissions.new(ask_when: ->(cap, detail)
82
+ { … })` scopes *which* actions actually prompt a human. When the predicate returns falsey the
83
+ action is auto-allowed **without** calling `on_ask`; truthy (or when `ask_when` is unset) falls
84
+ through to `on_ask` exactly as before. Unset = ask for everything (backward-compatible). It only
85
+ ever *narrows* what is auto-allowed from the "ask for everything" baseline — it never widens
86
+ authority, and it applies to `authorize!` only (the separate MCP ask axis is unchanged).
87
+
88
+ ```ruby
89
+ # Only prompt for writes under /protected; auto-allow everything else.
90
+ perms = Nexo::Permissions.new(
91
+ mode: :ask,
92
+ on_ask: ->(cap, detail) { ask_the_human(cap, detail) },
93
+ ask_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") }
94
+ )
95
+ ```
96
+
97
+ ## Remote sandbox — bring your own container
98
+
99
+ `Sandboxes::Remote` contains **zero vendor code**. It wraps any object that satisfies a
100
+ four-method contract — `read`, `write`, `exec`, `close` — and delegates the `Sandbox`
101
+ interface to it. Switching providers is swapping the injected object, nothing else:
102
+
103
+ ```ruby
104
+ sandbox = Nexo::Sandboxes::Remote.new(client: my_container_client)
105
+ # read(path) -> client.read(path)
106
+ # write(path, content) -> client.write(path, content)
107
+ # shell(cmd, timeout:) -> client.exec(cmd, timeout:)
108
+ # glob(pattern) -> client.exec(<pattern as a positional $1, never interpolated>)
109
+ # close -> client.close
110
+ ```
111
+
112
+ Vendor SDKs rarely expose exactly `read/write/exec/close`, so adapt them with a tiny shim
113
+ object. Keep the vendor gem a **soft** dependency behind a lazy `require` that raises
114
+ `Nexo::MissingDependencyError` when it's absent:
115
+
116
+ ```ruby
117
+ # A ~10-line adapter wrapping a hypothetical vendor client to the four-method contract.
118
+ class E2BAdapter
119
+ def initialize(api_key:)
120
+ require "e2b" # soft dep — lazy, only when you actually use it
121
+ @sbx = E2B::Sandbox.create(api_key: api_key)
122
+ rescue LoadError
123
+ raise Nexo::MissingDependencyError, "E2BAdapter needs `gem \"e2b\"` in your Gemfile."
124
+ end
125
+
126
+ def read(path) = @sbx.files.read(path)
127
+ def write(path, content) = @sbx.files.write(path, content)
128
+ def exec(cmd, timeout: 30) = (r = @sbx.commands.run(cmd, timeout: timeout)
129
+ {stdout: r.stdout, stderr: r.stderr, status: r.exit_code})
130
+ def close = @sbx.kill
131
+ end
132
+
133
+ agent = Nexo::Agent.new(model: ENV.fetch("NEXO_MODEL"),
134
+ sandbox: Nexo::Sandboxes::Remote.new(client: E2BAdapter.new(api_key: ENV["E2B_API_KEY"])))
135
+ ```
136
+
137
+ Nexo ships **only** `Remote` plus this documented pattern — purpose-built
138
+ `Sandboxes::E2B` / `Sandboxes::Daytona` classes are a possible future addition, deliberately
139
+ left out of v1 because their vendor client APIs aren't pinned yet.
140
+
141
+ ## Container sandbox — Docker / Apple Container
142
+
143
+ `Sandboxes::Container` runs an agent's tools inside a **throwaway OCI container** via the
144
+ `docker` (default) or Apple `container` CLI — shell-out only through `Open3`, **no client
145
+ gem**, no Compose, no image builder. A model-driven agent never touches your host filesystem
146
+ or shell directly. Declare it with the `sandbox` macro (`image:` is required — there is no
147
+ default image):
148
+
149
+ ```ruby
150
+ class ContainerReviewer < Nexo::Agent
151
+ model ENV.fetch("NEXO_MODEL")
152
+ sandbox :docker, image: "node:22-slim",
153
+ binds: { Dir.pwd => { to: "/workspace/repo", mode: :ro } }
154
+ end
155
+ ```
156
+
157
+ The container `cwd` defaults to `/workspace` (a container path, **not** your host directory);
158
+ the host dir enters only through a `binds:` entry.
159
+
160
+ ### `runtime:` — one class, two CLIs
161
+
162
+ `sandbox :docker` (or `runtime: :docker`) shells out to `docker`; `sandbox :apple`
163
+ (`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
168
+ runtime raises `Nexo::ConfigurationError`.
169
+
170
+ ### Hardened by default — every knob an explicit opt-out
171
+
172
+ All of the following are applied to the `run` argv by default and individually invertible:
173
+
174
+ | Concern | Default | Loosen with |
175
+ |---|---|---|
176
+ | Network | `--network none` (no egress) | `network: :bridge` / `:host` / a network name |
177
+ | Capabilities | `--cap-drop ALL` | `cap_add: %w[NET_BIND_SERVICE ...]` |
178
+ | Rootfs | `--read-only` | `readonly_rootfs: false` |
179
+ | Writable scratch | `--tmpfs <cwd>:rw` (ephemeral), only when `readonly_rootfs` | a `:rw` host bind for persistence |
180
+ | Privilege escalation | `--security-opt no-new-privileges` | (not exposed) |
181
+ | PIDs | `--pids-limit 512` (fork-bomb guard) | `pids_limit:` (`nil` omits the flag) |
182
+ | Memory / CPU | unset (host decides) | `memory:` / `cpus:` |
183
+ | User / uid | left to the image | `user:` (opt-in defense-in-depth) |
184
+ | Host binds | **read-only** (`:ro`) | per-bind `{ to:, mode: :rw }` |
185
+ | Env vars | none | `env: { "KEY" => "val" }` → one `-e KEY=val` per entry |
186
+
187
+ Bind spec forms:
188
+
189
+ ```ruby
190
+ binds: { "/host/proj" => "/workspace/proj" } # -> :ro
191
+ binds: { "/host/proj" => { to: "/workspace/proj", mode: :rw } } # -> :rw
192
+ ```
193
+
194
+ **Non-root is not forced.** The image's own uid is respected; `user:` is an opt-in. The other
195
+ hardening (`--cap-drop ALL`, `--security-opt no-new-privileges`, `--read-only`,
196
+ `--network none`) applies **regardless of uid**.
197
+
198
+ Every argument is passed to `Open3` as an **array**, never string-interpolated, so file
199
+ contents and commands can't break out of the argv. Paths are expanded against the container
200
+ `cwd` and a path that escapes raises `SecurityError`. A denied/failed **tool** op surfaces as
201
+ `{ error: ... }` through the gated tool layer; the **sandbox** itself raises only on misuse —
202
+ a missing binary (`Nexo::ConfigurationError` naming the binary), a path escape (`SecurityError`),
203
+ or a container start failure (`Nexo::Error`).
204
+
205
+ ### Lifecycle — ephemeral by default, opt-in reconnect
206
+
207
+ The container starts **lazily** on first tool use and its id is memoized.
208
+
209
+ - **Ephemeral (default, `reconnect: false`):** `close` force-removes the container
210
+ (`<bin> rm -f <id>`) and clears the memo. Idempotent — safe with nothing started or called
211
+ twice. A standalone container-backed agent tears its container down on `Agent#close` (the
212
+ agent owns the sandbox it resolved). A workflow driving one through `run_agent` shares the
213
+ run's sandbox — `run_agent` borrows it, so teardown happens once at the end of the run in
214
+ `Workflow.execute`'s `ensure` (on done, suspended, or failed), never per `run_agent` call.
215
+ - **Reconnect (`name:` + `reconnect: true`):** every container is tagged at `run` with an
216
+ **exact identity label** — `--label nexo.sandbox.id=<name>`. On start the sandbox looks up
217
+ that container by the **exact label filter** (`docker ps -aqf label=nexo.sandbox.id=<name>`),
218
+ **not** a name substring, and reuses/restarts it instead of creating a new one; `close`
219
+ leaves it running/stopped so a later sandbox with the same name reattaches. The default
220
+ container name is `nexo-<run-id>`.
221
+ - **Exact match, never a substring.** A container merely *named* `<name>x` is never
222
+ reattached — the label filter is exact. This closes the substring-reattach safety hole:
223
+ a long-lived session can never silently bind to the wrong environment.
224
+ - **Ambiguity raises, never guesses.** If more than one container carries the same identity
225
+ label, reconnect raises `Nexo::Error` (`ambiguous reconnect: <n> containers labeled
226
+ <name>`) rather than pick one. Zero matches falls through to a fresh `run`; a
227
+ daemon/CLI failure fails open to the create path.
228
+ - **Reconnect never crosses runtimes.** The lookup shells the runtime-specific binary and
229
+ each runtime keeps its own id namespace, so a `:docker` container is never reattached by an
230
+ `:apple` sandbox or vice versa.
231
+
232
+ ### Honest caveats
233
+
234
+ - **Network-none breaks installs.** `npm install` / `bundle install` need egress; with the
235
+ default `network: :none` they fail. Pass `network: :bridge` or bake dependencies into the
236
+ image.
237
+ - **Read-only rootfs needs the scratch.** With `--read-only`, only the tmpfs at `cwd` (and any
238
+ `:rw` bind) is writable, and the tmpfs is **ephemeral** — lost on `close`. Persist via a
239
+ `:rw` bind (this is where staged files and artifacts land).
240
+ - **Non-root is recommended, not forced.** The default hardening holds regardless of uid; set
241
+ `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.
245
+ - **Reconnect is Docker-only today.** `reconnect: true` combined with `runtime: :apple` raises
246
+ `Nexo::ConfigurationError` at the point reconnect would run. Apple's `container` CLI has no
247
+ **live-verified** exact `label=` filter, and a name-substring match is unsafe (it can attach
248
+ the wrong container), so reconnect refuses rather than risk it. Use `runtime: :docker` for
249
+ reconnect, or run an ephemeral `:apple` sandbox (`reconnect: false`). If a future Group 0 run
250
+ confirms an exact-identity mechanism on Apple, wire that verified mechanism instead of raising.
251
+
252
+ #### Apple `container` parity table (Group 0)
253
+
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`.
260
+
261
+ | Subcommand / flag | Docker | Apple `container` | Divergence → action |
262
+ |---|---|---|---|
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.
289
+
290
+ ← Back to the [README](../README.md)
data/docs/sessions.md ADDED
@@ -0,0 +1,99 @@
1
+ # Sessions — continuing, addressable memory
2
+ A session is a remembering instance of an agent, addressed by `(agent_name, instance_id)`, that accumulates context across invocations.
3
+
4
+ A `Workflow` is fire-and-finish. A **`Nexo::Session`** is the other half: a
5
+ *remembering* instance of an agent, addressed by `(agent_name, instance_id)`, that
6
+ accumulates context across separate invocations.
7
+
8
+ ```ruby
9
+ Nexo::Session.resume(Assistant, "user-42").prompt("My name is Mac.")
10
+ # ... a later request, job, or process ...
11
+ Nexo::Session.resume(Assistant, "user-42").prompt("What is my name?")
12
+ # => "...Mac..." — the persisted thread carried the earlier turn
13
+ ```
14
+
15
+ `resume` finds-or-creates the one thread for that pair (the pair is unique — one
16
+ thread per pair) and returns a session whose `#prompt` appends to it. `#prompt`
17
+ takes the same `max_turns:` and `&on_event` block as `Agent#prompt`, yielding the
18
+ same `(:tool_call | :tool_result | :done, payload)` events. Extra keywords are
19
+ forwarded to the agent constructor (e.g. `Nexo::Session.resume(Assistant, "u1", cwd: repo)`).
20
+
21
+ **A session adds only memory + addressability — never authority.** Its sandbox,
22
+ permissions (default `:read_only`), skills, MCP servers, and `fetch_allow` are
23
+ exactly the agent's; opening or resuming a session never widens what the agent can
24
+ do. The persisted record supplies the *thread*; the agent supplies the
25
+ *tools/skills/instructions* onto it.
26
+
27
+ ## Composition — `acts_as_chat`, owned by the host
28
+
29
+ Message persistence is **RubyLLM's `acts_as_chat`** — Nexo defines no message table
30
+ and serializes nothing. The host Rails app owns all four persistence models (`Chat`,
31
+ `Message`, `ToolCall`, `Model`), generated by ruby_llm's own installer:
32
+
33
+ ```bash
34
+ rails g ruby_llm:install # generates the Chat/Message/ToolCall/Model models + migrations
35
+ ```
36
+
37
+ **One setup step beyond the installer:** the session chat model must be addressable,
38
+ so add two columns and a **unique composite index** to the generated `chats` table:
39
+
40
+ ```ruby
41
+ class AddNexoAddressingToChats < ActiveRecord::Migration[8.0]
42
+ def change
43
+ add_column :chats, :agent_name, :string
44
+ add_column :chats, :instance_id, :string
45
+ add_index :chats, [:agent_name, :instance_id], unique: true
46
+ end
47
+ end
48
+ ```
49
+
50
+ Tell Nexo which model hosts sessions (only if it isn't ruby_llm's default `Chat`):
51
+
52
+ ```ruby
53
+ Nexo.configure { |c| c.session_chat_model = "Chat" } # default; a String class name,
54
+ # constantized lazily at resume time
55
+ ```
56
+
57
+ ## Rails-only durability — plain Ruby is in-memory
58
+
59
+ Durable sessions require ActiveRecord. Backend selection guards on
60
+ `defined?(::ActiveRecord::Base)` **and** the host chat model being defined (mirroring
61
+ how `RunStore` only uses the AR store when `Nexo::WorkflowRun` is present):
62
+
63
+ - **Rails (durable):** the thread is a `chats` row; `acts_as_chat`'s callbacks persist
64
+ every message. It survives across requests, jobs, and process restarts.
65
+ - **Plain Ruby (in-memory):** a process-wide store holds a live `RubyLLM::Chat` per
66
+ pair. The thread lives only for the process — a fresh process starts empty. This is
67
+ documented, non-durable behavior, not a bug.
68
+
69
+ Re-applying the agent's instructions on every resume is **idempotent**: `acts_as_chat`
70
+ stores instructions as `role: :system` messages, and Nexo re-applies them with
71
+ `with_instructions` (replace semantics) so the stored thread keeps exactly one copy
72
+ across resumes rather than accumulating duplicate system messages. The runtime tools
73
+ (the four sandbox tools + MCP + fetch) are re-attached each resume — they are not
74
+ persisted, and that is correct.
75
+
76
+ ## Retention, PII, and `#close` — the honest trade-off
77
+
78
+ A continuing session is a persistence surface, and that has real costs:
79
+
80
+ - **Stored messages persist until you delete them, and may contain sensitive data.**
81
+ A long-lived thread accumulates whatever the user and tools put into it. Nexo does
82
+ **not** redact, expire, or GC anything — retention is your responsibility. Treat the
83
+ `chats`/`messages` tables as PII stores and apply your own retention policy.
84
+ - **Close sessions that hold resources.** If the agent declares MCP servers (stdio/SSE)
85
+ or fetch, a session holds live subprocesses/sockets. Call `#close` when done — it
86
+ delegates to `Agent#close`, tearing those down (idempotent, safe with nothing held):
87
+
88
+ ```ruby
89
+ session = Nexo::Session.resume(InboxAssistant, "user-42")
90
+ begin
91
+ session.prompt("Summarize my unread threads.")
92
+ ensure
93
+ session.close # releases the agent's MCP/stdio/SSE connections
94
+ end
95
+ ```
96
+
97
+ See `examples/chat_session.rb` for a runnable, env-gated two-prompt resume.
98
+
99
+ ← Back to the [README](../README.md)
data/docs/skills.md ADDED
@@ -0,0 +1,68 @@
1
+ # Skills
2
+ A skill is a `SKILL.md` package that teaches the model *how you want a task done* — reasoning, layered on with one macro.
3
+
4
+ A **skill** is a `SKILL.md` package — frontmatter plus instructions — that teaches the
5
+ model *how you want a task done*. Skills guide **reasoning**; the sandbox-backed tools
6
+ above perform **execution**. Nexo does not implement skill loading; it composes the
7
+ [`ruby_llm-skills`](https://github.com/kieranklaassen/ruby_llm-skills) gem so you attach a
8
+ skill with one macro and no loader setup.
9
+
10
+ Drop a package under `app/skills/` (or scaffold one — see below):
11
+
12
+ ```
13
+ app/skills/
14
+ └── triage/
15
+ ├── SKILL.md # frontmatter (name, description) + process steps
16
+ └── references/ # supporting docs the skill can cite
17
+ ```
18
+
19
+ ```markdown
20
+ ---
21
+ name: triage
22
+ description: Triage incoming issues by severity and route them to the right owner.
23
+ ---
24
+
25
+ # Triage
26
+
27
+ ## Process
28
+ 1. Classify the issue severity.
29
+ 2. Route to the right owner.
30
+ ```
31
+
32
+ Reference it with the `skills` macro — its instructions are layered on top of the agent's
33
+ own, in declaration order:
34
+
35
+ ```ruby
36
+ require "nexo"
37
+
38
+ class TriageAgent < Nexo::Agent
39
+ model ENV.fetch("NEXO_MODEL") # any ruby_llm model — never a hardcoded vendor default
40
+ skills :triage # one macro, no loader wiring
41
+ end
42
+
43
+ TriageAgent.new.chat # chat built with the base sandbox tools + the skill's instructions
44
+ ```
45
+
46
+ Scaffold a new skill package with the generator (creates a valid `SKILL.md` plus a
47
+ `references/` directory):
48
+
49
+ ```sh
50
+ rails g nexo:skill triage
51
+ # create app/skills/triage/references/.keep
52
+ # create app/skills/triage/SKILL.md
53
+ ```
54
+
55
+ `ruby_llm-skills` is an **optional** dependency — required lazily only when you use a skill.
56
+ Without it installed, `require "nexo"` still loads; touching a skill raises a clear
57
+ `Nexo::MissingDependencyError` telling you to add `gem "ruby_llm-skills"`. Referencing a
58
+ skill that does not exist raises `Nexo::Error` naming the missing `SKILL.md` path.
59
+
60
+ ## Skill tools stay gated
61
+
62
+ A skill contributes **instructions only**. A loaded skill ships no independent tools, and
63
+ 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.
67
+
68
+ ← Back to the [README](../README.md)
data/docs/tools.md ADDED
@@ -0,0 +1,38 @@
1
+ # Tools
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
+
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.
10
+
11
+ `Shell` truncates unbounded command output before it reaches the model:
12
+
13
+ - **Shell output truncation (`Nexo::OutputTruncator`).** Unbounded command output (`npm install`,
14
+ `git log`) is truncated before it reaches the model, so a single command can't blow a small
15
+ context window. `Tools::Shell` wraps `stdout`/`stderr` through
16
+ `OutputTruncator.call(text, max_lines: 200, max_chars: 16_000)` — strips ANSI escapes, keeps the
17
+ **last** `max_lines` lines, appends a `…[truncated N lines]` marker, then caps at `max_chars`.
18
+ The integer `status` passes through untouched. Pure line/char truncation — **no tokenizer**;
19
+ configurable via the kwargs only (no global config, no per-agent macro).
20
+
21
+ `WriteFile` enforces a read-before-write guard so the agent can't clobber files blindly:
22
+
23
+ - **Read-before-write + stale guard (real-FS only).** Within a session, the agent is blocked from
24
+ overwriting a file it never read, or one that changed underneath it. `Agent#chat` builds one
25
+ `Nexo::ReadTracker` per chat and threads it into `ReadFile` (records `(path, mtime)` on a
26
+ successful read) and `WriteFile` (enforces): overwriting an existing, un-read file returns
27
+ `{error: "read <path> before overwriting it"}`; a file whose mtime changed since the read returns
28
+ `{error: "stale: <path> changed since you read it"}`; a new file writes freely. The guard is
29
+ **real-FS only** — skipped entirely on `Virtual` (nil `mtime`) and when no tracker is passed
30
+ (direct tool construction). Best-effort: mtime-based, so a sub-second external edit may slip past
31
+ the stale check (read-before-write is the primary guard). Clobber-safety within a session only —
32
+ no versioning, locking, or VCS semantics.
33
+
34
+ These are sandbox refinements as much as tool behavior — see [Sandboxes](sandboxes.md) for the
35
+ guard details behind each capability. The `fetch` tool for reading the web lives in
36
+ [Web](web.md), gated by its own `:fetch` capability plus a host allow-list.
37
+
38
+ ← Back to the [README](../README.md)
data/docs/web.md ADDED
@@ -0,0 +1,130 @@
1
+ # Web content — the `fetch` tool
2
+ A safe, default-denied way to read the web: a stdlib HTTP(S) GET gated by a `:fetch` capability and a host allow-list.
3
+
4
+ `Nexo::Tools::Fetch` gives an agent a safe, default-denied way to **read the web**: a
5
+ stdlib `net/http` HTTP(S) `GET`, gated by a new `:fetch` capability *and* a host
6
+ allow-list. It's the no-server alternative to an MCP fetch endpoint for simple, mostly
7
+ static pages — every model, no `npx`, no daemon.
8
+
9
+ ```ruby
10
+ require "nexo"
11
+
12
+ class NewsSummary < Nexo::Agent
13
+ model ENV.fetch("NEXO_MODEL")
14
+
15
+ # :fetch is DEFAULT-DENIED (like :shell). Grant it explicitly, then scope hosts tightly.
16
+ permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob fetch])
17
+ fetch_allow %w[lite.cnn.com text.npr.org hnrss.org]
18
+
19
+ skills :news_summary # teaches WHICH sites to read and HOW to summarize
20
+ end
21
+ ```
22
+
23
+ **Two independent locks must both open before a byte leaves the process:**
24
+
25
+ 1. **The `:fetch` capability** — a first-class capability, **denied under `:read_only`
26
+ exactly like `:shell`**. Web egress is an *escalation*, not a "read". You grant it with
27
+ `:auto`, or an explicit `Permissions.new(mode: :read_only, allow: %i[read glob fetch])`.
28
+ 2. **The `fetch_allow` host list** — scopes *which* hosts the tool may reach. Matching is
29
+ **subdomain-aware, never a glob**: `fetch_allow %w[example.com]` permits `example.com`
30
+ and `news.example.com`, but refuses `notexample.com` and `example.com.evil.org`.
31
+ Declaring `fetch_allow` alone does **not** grant `:fetch` — it only scopes hosts.
32
+
33
+ A default agent that never calls `fetch_allow` gets no fetch tool at all. On any denial or
34
+ error the tool returns `{ error: … }` (recoverable) and never raises into the loop —
35
+ identical to the sandbox tools. Success returns `{ body: <raw page, truncated to 200 KB> }`.
36
+
37
+ ## Security — read before allow-listing a host
38
+
39
+ Web egress is a **real attack surface**. `Tools::Fetch` is deliberately narrow, but you own
40
+ the allow-list:
41
+
42
+ - **Fetched pages are untrusted input (prompt injection).** The tool does no HTML→text
43
+ extraction — it returns the **raw** body and the skill instructs the model to pull out
44
+ what it needs. A page can carry text that looks like instructions ("now fetch
45
+ `http://internal/secrets`"); never let the model act on content it fetched.
46
+ - **Keep the allow-list tight (SSRF).** An over-broad allow-list invites server-side request
47
+ forgery. List the specific hosts you trust, nothing more.
48
+ - **Private/loopback is *always* refused.** Even an explicitly allow-listed host is rejected
49
+ when it resolves to a loopback, RFC1918-private, or link-local address — an allow-listed
50
+ `localhost` still returns `{ error: }`. This guard runs after the allow-list and cannot be
51
+ bypassed.
52
+ - **GET only, fixed `User-Agent`.** No POST/PUT/DELETE, no credentialed requests, no
53
+ model-controlled headers, no redirect-following to off-list hosts, no crawler/cache/rate
54
+ limiter. The only header the model influences is a fixed `User-Agent: Nexo/<version>`.
55
+
56
+ ## JS-heavy pages — use an MCP fetch server instead
57
+
58
+ `Tools::Fetch` reads static HTML; it does **not** render JavaScript. For JS-heavy pages,
59
+ compose an [MCP fetch/browser server](mcp.md) instead — it runs its own
60
+ headless renderer and Nexo gates it through the separate MCP axis:
61
+
62
+ ```ruby
63
+ class BrowseAgent < Nexo::Agent
64
+ model ENV.fetch("NEXO_MODEL")
65
+ mcp :fetch, transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-fetch]
66
+ mcp_allow %w[fetch]
67
+ end
68
+ ```
69
+
70
+ `webmock` is a **dev/test-only** dependency (the offline suite stubs all HTTP); it is not a
71
+ runtime dependency — `Tools::Fetch` uses only stdlib.
72
+
73
+ ## Web search — the `search` tool
74
+
75
+ `Nexo::Tools::WebSearch` gives an agent a vendor-neutral way to **discover** URLs. It authorizes
76
+ a new, default-denied `:search` capability, then delegates the query to a **host-injected
77
+ backend** and returns normalized, capped results. It pairs with `Tools::Fetch`: **search finds
78
+ URLs, fetch reads one.** Nexo ships **no** search provider — you inject the backend.
79
+
80
+ ```ruby
81
+ require "nexo"
82
+
83
+ class ResearchAgent < Nexo::Agent
84
+ model ENV.fetch("NEXO_MODEL")
85
+
86
+ # :search is DEFAULT-DENIED (like :fetch/:shell). Grant it explicitly.
87
+ permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob fetch search])
88
+ fetch_allow %w[lite.cnn.com text.npr.org]
89
+ search_backend MyBraveAdapter.new(ENV.fetch("BRAVE_API_KEY")) # host-owned; Nexo ships none
90
+ end
91
+ ```
92
+
93
+ **Two things must both be true before the tool runs:**
94
+
95
+ 1. **The `:search` capability** — a first-class capability, **denied under `:read_only` exactly
96
+ like `:fetch`/`:shell`**. Web discovery is an *escalation*. Grant it with `:auto`, or an
97
+ explicit `Permissions.new(mode: :read_only, allow: %i[read glob search])`.
98
+ 2. **A declared `search_backend`** — the injected provider. A default agent that never calls
99
+ `search_backend` gets **no search tool at all**; existing agents are byte-for-byte unchanged.
100
+
101
+ ## The backend contract
102
+
103
+ The backend is any object responding to:
104
+
105
+ ```ruby
106
+ search(query, **opts) -> Enumerable of {title:, url:, snippet:}
107
+ ```
108
+
109
+ Rows may be Hashes or any object responding to `#to_h`. Nexo normalizes each row to
110
+ `{title:, url:, snippet:}` (all stringified), truncates the snippet to **300 chars**, and returns
111
+ **at most 8 rows**:
112
+
113
+ ```ruby
114
+ { results: [{ title: "…", url: "https://…", snippet: "… (≤300 chars)" }, …] } # ≤8 rows
115
+ ```
116
+
117
+ On any denial or error the tool returns `{ error: … }` (recoverable) and never raises into the
118
+ loop — identical to the sandbox tools. The v1 tool exposes only `query`; result count, region,
119
+ safesearch and other `**opts` stay a host-side backend concern and are never populated by the tool.
120
+
121
+ ## Security — read before injecting a backend
122
+
123
+ - **The search tool runs in the host process, not the sandbox.** Like `Fetch`, a `--network none`
124
+ container does **not** constrain it; only the `:search` capability and the backend's own scope
125
+ do (the same boundary class as "MCP effects aren't sandboxed").
126
+ - **The backend is trust-bearing.** Nexo hands it the raw query and returns its results to the
127
+ model as **untrusted input** — snippets can carry prompt-injection text. Choose a reputable
128
+ backend, and never let the model act on a snippet's instructions.
129
+
130
+ ← Back to the [README](../README.md)