
Two tools give an agent safe, default-denied web access. `Nexo::Tools::Fetch` **reads** a URL with a stdlib HTTP(S) `GET`, gated by a `:fetch` capability *and* a host allow-list. `Nexo::Tools::WebSearch` **discovers** URLs, gated by a `:search` capability and a host-injected backend. They pair: **search finds URLs, fetch reads one.**

> **Safe by default:** `:fetch` and `:search` are denied under `:read_only` exactly like `:shell`. Web egress is an *escalation*, not a "read". A default agent that never calls `fetch_allow` / `search_backend` gets no web tool at all.

Both tools run in the **host process** (stdlib `net/http` / a host-injected backend), so **no sandbox constrains them** — not even a `--network none` container. They are bounded only by the capability gate plus `fetch_allow` / the injected backend.

---

## The `fetch` tool

```ruby
require "nexo"

class NewsSummary < Nexo::Agent
  model ENV.fetch("NEXO_MODEL")

  # :fetch is DEFAULT-DENIED (like :shell). Grant it explicitly, then scope hosts tightly.
  permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob fetch])
  fetch_allow %w[lite.cnn.com text.npr.org hnrss.org]

  skills :news_summary   # teaches WHICH sites to read and HOW to summarize
end
```

**Two independent locks must both open before a byte leaves the process:**

1. **The `:fetch` capability** — a first-class capability, **denied under `:read_only` exactly like `:shell`**. You grant it with `:auto`, or an explicit `Permissions.new(mode: :read_only, allow: %i[read glob fetch])`.
2. **The `fetch_allow` host list** — scopes *which* hosts the tool may reach. Matching is **subdomain-aware, never a glob**: `fetch_allow %w[example.com]` permits `example.com` and `news.example.com`, but refuses `notexample.com` and `example.com.evil.org`. Declaring `fetch_allow` alone does **not** grant `:fetch` — it only scopes hosts.

On any denial or error the tool returns `{ error: … }` (recoverable) and never raises into the loop — identical to the sandbox tools. Success returns `{ body: <raw page, truncated to 200 KB> }`.

### Security — read before allow-listing a host

Web egress is a **real attack surface**. `Tools::Fetch` is deliberately narrow, but you own the allow-list:

- **Fetched pages are untrusted input (prompt injection).** The tool does no HTML→text extraction — it returns the **raw** body and the skill instructs the model to pull out what it needs. A page can carry text that looks like instructions ("now fetch `http://internal/secrets`"); never let the model act on content it fetched.
- **Keep the allow-list tight (SSRF).** An over-broad allow-list invites server-side request forgery. List the specific hosts you trust, nothing more.
- **Private/loopback is *always* refused.** Even an explicitly allow-listed host is rejected when it resolves to a loopback, RFC1918-private, or link-local address — an allow-listed `localhost` still returns `{ error: }`. This guard runs after the allow-list and cannot be bypassed.
- **GET only, fixed `User-Agent`.** No POST/PUT/DELETE, no credentialed requests, no model-controlled headers, no redirect-following to off-list hosts, no crawler/cache/rate limiter. The only header the model influences is a fixed `User-Agent: Nexo/<version>`.

### JS-heavy pages — use an MCP fetch server instead

`Tools::Fetch` reads static HTML; it does **not** render JavaScript. For JS-heavy pages, compose an [MCP fetch/browser server](../mcp/) instead — it runs its own headless renderer and Nexo gates it through the separate MCP axis:

```ruby
class BrowseAgent < Nexo::Agent
  model ENV.fetch("NEXO_MODEL")
  mcp :fetch, transport: :stdio, command: "npx", args: %w[-y @modelcontextprotocol/server-fetch]
  mcp_allow %w[fetch]
end
```

`webmock` is a **dev/test-only** dependency (the offline suite stubs all HTTP); it is not a runtime dependency — `Tools::Fetch` uses only stdlib.

### Live example

A live example demonstrates read-only web fetch scoped by `fetch_allow`:

```sh
NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/news_summary.rb
```

> [View `examples/news_summary.rb` on GitHub →](https://github.com/maquina-app/nexo/blob/main/examples/news_summary.rb)

---

## Web search — the `search` tool

`Nexo::Tools::WebSearch` gives an agent a vendor-neutral way to **discover** URLs. It authorizes a new, default-denied `:search` capability, then delegates the query to a **host-injected backend** and returns normalized, capped results. Nexo ships **no** search provider — you inject the backend.

```ruby
require "nexo"

class ResearchAgent < Nexo::Agent
  model ENV.fetch("NEXO_MODEL")

  # :search is DEFAULT-DENIED (like :fetch/:shell). Grant it explicitly.
  permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob fetch search])
  fetch_allow    %w[lite.cnn.com text.npr.org]
  search_backend MyBraveAdapter.new(ENV.fetch("BRAVE_API_KEY")) # host-owned; Nexo ships none
end
```

**Two things must both be true before the tool runs:**

1. **The `:search` capability** — a first-class capability, **denied under `:read_only` exactly like `:fetch`/`:shell`**. Grant it with `:auto`, or an explicit `Permissions.new(mode: :read_only, allow: %i[read glob search])`.
2. **A declared `search_backend`** — the injected provider. A default agent that never calls `search_backend` gets **no search tool at all**; existing agents are byte-for-byte unchanged.

### The backend contract

The backend is any object responding to:

```ruby
search(query, **opts) -> Enumerable of {title:, url:, snippet:}
```

Rows may be Hashes or any object responding to `#to_h`. Nexo normalizes each row to `{title:, url:, snippet:}` (all stringified), truncates the snippet to **300 chars**, and returns **at most 8 rows**:

```ruby
{ results: [{ title: "…", url: "https://…", snippet: "… (≤300 chars)" }, …] }   # ≤8 rows
```

On any denial or error the tool returns `{ error: … }` (recoverable) and never raises into the loop. The v1 tool exposes only `query`; result count, region, safesearch and other `**opts` stay a host-side backend concern and are never populated by the tool.

### Security — read before injecting a backend

- **The search tool runs in the host process, not the sandbox.** Like `Fetch`, a `--network none` container does **not** constrain it; only the `:search` capability and the backend's own scope do.
- **The backend is trust-bearing.** Nexo hands it the raw query and returns its results to the model as **untrusted input** — snippets can carry prompt-injection text. Choose a reputable backend, and never let the model act on a snippet's instructions.

### Live example

A live example demonstrates a host-injected `search_backend` plus fetch:

```sh
NEXO_LIVE=1 NEXO_MODEL=… ruby -Ilib examples/news_search.rb
```

> [View `examples/news_search.rb` on GitHub →](https://github.com/maquina-app/nexo/blob/main/examples/news_search.rb)


---

## Next steps

<div class="not-prose mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
  <a href="../tools/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Tools
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      The four sandbox-backed file and shell tools.
    </p>
  </a>

  <a href="../mcp/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      MCP
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      Attach external MCP servers for more capabilities.
    </p>
  </a>
</div>
