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.
- checksums.yaml +4 -4
- data/.document +3 -0
- data/CHANGELOG.md +232 -0
- data/README.md +75 -4
- data/Rakefile +29 -0
- data/app/views/nexo/_event.html.erb +1 -0
- data/docs/concurrency.md +94 -0
- data/docs/durable-workflows.md +184 -0
- data/docs/getting-started.md +103 -0
- data/docs/loops.md +93 -0
- data/docs/mcp.md +144 -0
- data/docs/permissions.md +47 -0
- data/docs/rails.md +129 -0
- data/docs/sandboxes.md +290 -0
- data/docs/sessions.md +99 -0
- data/docs/skills.md +68 -0
- data/docs/tools.md +38 -0
- data/docs/web.md +130 -0
- data/docs/workflows.md +255 -0
- data/examples/README.md +51 -0
- data/examples/approval_agent.rb +64 -0
- data/examples/approval_workflow.rb +62 -0
- data/examples/artifact_from_template.rb +54 -0
- data/examples/chat_session.rb +49 -0
- data/examples/code_reviewer.rb +64 -0
- data/examples/container_review.rb +52 -0
- data/examples/inbox_digest.rb +62 -0
- data/examples/inbox_digest_http.rb +69 -0
- data/examples/inbox_digest_task.rb +40 -0
- data/examples/mcp_filesystem.rb +50 -0
- data/examples/news_search.rb +49 -0
- data/examples/news_summary.rb +37 -0
- data/examples/rails_usage.md +141 -0
- data/examples/skills/email_triage/SKILL.md +47 -0
- data/examples/skills/news_summary/SKILL.md +37 -0
- data/examples/skills/ruby-code-review/SKILL.md +61 -0
- data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
- data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
- data/lib/generators/nexo/install/install_generator.rb +35 -0
- data/lib/generators/nexo/install/templates/nexo.rb +19 -0
- data/lib/generators/nexo/skill/skill_generator.rb +45 -0
- data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
- data/lib/generators/nexo/state/state_generator.rb +37 -0
- data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
- data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
- data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
- data/lib/nexo/agent.rb +423 -0
- data/lib/nexo/concurrent.rb +78 -0
- data/lib/nexo/configuration.rb +82 -0
- data/lib/nexo/engine.rb +51 -0
- data/lib/nexo/loop.rb +30 -0
- data/lib/nexo/loops/agent_sdk.rb +68 -0
- data/lib/nexo/loops/ruby_llm.rb +66 -0
- data/lib/nexo/mcp/gated_tool.rb +70 -0
- data/lib/nexo/mcp.rb +96 -0
- data/lib/nexo/output_truncator.rb +41 -0
- data/lib/nexo/permissions.rb +162 -0
- data/lib/nexo/read_tracker.rb +32 -0
- data/lib/nexo/run_store.rb +162 -0
- data/lib/nexo/sandbox.rb +64 -0
- data/lib/nexo/sandboxes/container.rb +293 -0
- data/lib/nexo/sandboxes/local.rb +157 -0
- data/lib/nexo/sandboxes/remote.rb +77 -0
- data/lib/nexo/sandboxes/virtual.rb +42 -0
- data/lib/nexo/sandboxes.rb +43 -0
- data/lib/nexo/session.rb +152 -0
- data/lib/nexo/skills.rb +54 -0
- data/lib/nexo/tools/fetch.rb +133 -0
- data/lib/nexo/tools/glob.rb +28 -0
- data/lib/nexo/tools/read_file.rb +54 -0
- data/lib/nexo/tools/shell.rb +35 -0
- data/lib/nexo/tools/web_search.rb +81 -0
- data/lib/nexo/tools/write_file.rb +61 -0
- data/lib/nexo/turbo_broadcaster.rb +41 -0
- data/lib/nexo/version.rb +2 -1
- data/lib/nexo/workflow.rb +705 -0
- data/lib/nexo/workflow_job.rb +42 -0
- data/lib/nexo/workflow_run.rb +128 -0
- data/lib/nexo.rb +138 -1
- data/lib/tasks/nexo.rake +17 -0
- data/sig/nexo/agent.rbs +23 -0
- data/sig/nexo/output_truncator.rbs +7 -0
- data/sig/nexo/permissions.rbs +15 -0
- data/sig/nexo/read_tracker.rbs +8 -0
- data/sig/nexo/sandbox.rbs +12 -0
- data/sig/nexo/sandboxes/container.rbs +26 -0
- data/sig/nexo/sandboxes/local.rbs +12 -0
- data/sig/nexo/sandboxes/virtual.rbs +7 -0
- data/sig/nexo/sandboxes.rbs +5 -0
- data/sig/nexo/tools.rbs +28 -0
- data/sig/nexo_ai.rbs +22 -1
- 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)
|