
The sandbox is *where* an agent's tools act. Pick in-memory `Virtual`, host-backed `Local`, a throwaway `Container`, or a `Remote` you inject. Two seams compose the execution environment: the **sandbox** is *where* tools act; the **permission mode** is *what* they may do ([Permissions](../permissions/)).

A denied capability returns `{ error: ... }` and the agent loop continues — it does not raise. A path that escapes the workspace raises `SecurityError`; an agent built with no resolvable model raises `Nexo::ConfigurationError`.

---

## The four sandboxes

| Sandbox | What it is | `:shell` | Best for |
|---|---|---|---|
| `Virtual` (default) | In-memory, zero host access | raises `NotImplementedError` (intentional) | Reading staged data, pure-Ruby work |
| `Local` | Host filesystem + shell, guarded to `cwd` | Yes (narrowed ENV) | Trusted dev/CI |
| `Container` | Throwaway OCI container via `docker` or Apple `container` CLI | Yes (in container) | Model-driven work, untrusted models |
| `Remote` | A remote container you inject (E2B / Daytona / Modal / Docker / your own) | Yes (via injected client) | Cloud sandboxes, scale-out |

> **Safe by default:** agents start `:virtual` — escalating to `:local`, `:container`, or `:remote` is always an explicit choice in your code. The default never widens host access.

- **`Virtual`** (default) — in-memory, zero host access. `#shell` raises `NotImplementedError` on purpose: in-memory means no command execution. That is the safety property, not a gap.
- **`Local`** — host filesystem + shell, for trusted dev/CI. Two guards: every path is expanded against `cwd` and must stay inside it (else `SecurityError`), and the shell sees only `PATH`, `HOME`, `LANG` (plus explicit `env:` additions) — never the full process environment.
- **`Container`** — run the tools inside a throwaway local container via the `docker` (default) or Apple `container` CLI. Shell-out only, no client gem. **Hardened by default** (no network, dropped caps, read-only rootfs + ephemeral scratch, read-only host binds); every hardening is an explicit opt-out. See [Container sandbox](#container-sandbox--docker--apple-container) below.
- **`Remote`** — run the tools inside a remote container by injecting a client. Escalating to `:remote` is always an explicit choice — the default stays `:virtual`.

---

## Capability matrix

`Tools::Fetch` and `Tools::WebSearch` (`:fetch` / `:search`) 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. See [Web](../web/).

|  | `:read` | `:glob` | `:write` | `:shell` | `:fetch` | `:search` |
| --- | --- | --- | --- | --- | --- | --- |
| `Virtual` sandbox | Yes | Yes | Yes (in-memory) | No `NotImplementedError`→`{error}` | Yes † | Yes † |
| `Local` sandbox | Yes (guarded) | Yes | Yes (guarded) | Yes (narrowed ENV) | Yes † | Yes † |
| `Container` sandbox | Yes (guarded) | Yes | Yes (guarded, scratch) | Yes (in container) | Yes † | Yes † |

**†** `:fetch` / `:search` run in the host process — no sandbox constrains them.

A `:virtual` agent never advertises a `Shell` tool it can never run — `Agent#chat` attaches `Shell` only when `@sandbox.supports?(:shell)`. `ReadFile`/`WriteFile`/`Glob` are always attached. See [Tools](../tools/).

---

## Safety refinements — safer, more legible real-FS sandboxes

Five small refinements tighten the real-filesystem sandboxes (`Local`, `Container`) and make the execution environment more legible to the model. Each wires into an existing seam — no new sandbox tier, no new capability, no new dependency. Every one *tightens* a default or *narrows* scope; none widens authority silently.

- **Self-describing sandbox (`Sandbox#instructions`).** A real-FS sandbox appends one plain-text system message describing where the agent runs, so a weak local tool-caller knows its environment. `Local` → *"You run on the host machine, cwd /path/to/repo. The real host filesystem and shell are reachable; file access is guarded to /path/to/repo."*; `Container` → *"You run inside a docker container (image node:22-slim), cwd /workspace, network none…"*. `Virtual` says nothing. Ordering: **agent instructions → sandbox instructions → skill instructions**.
- **Capability-gated tool attach (`Sandbox#supports?`).** A `:virtual` agent no longer advertises a `Shell` tool it can never run — `Agent#chat` attaches `Shell` only when `@sandbox.supports?(:shell)`.
- **Shell output truncation (`Nexo::OutputTruncator`).** Unbounded command output (`npm install`, `git log`) is truncated before it reaches the model — strips ANSI escapes, keeps the **last** `max_lines` lines, appends a `…[truncated N lines]` marker, then caps at `max_chars`. The integer `status` passes through untouched. Pure line/char truncation — **no tokenizer**; configurable via the kwargs only.
- **Read-before-write + stale guard (real-FS only).** Within a session, the agent is blocked from overwriting a file it never read, or one that changed underneath it. Overwriting an existing, un-read file returns `{error: "read <path> before overwriting it"}`; a file whose mtime changed since the read returns `{error: "stale: <path> changed since you read it"}`; a new file writes freely. Real-FS only — skipped entirely on `Virtual`. Best-effort: mtime-based. Clobber-safety within a session only — no versioning, locking, or VCS semantics.
- **Scoped `:ask` predicate (`ask_when`).** Under `:ask`, `Permissions.new(ask_when: ->(cap, detail) { … })` scopes *which* actions actually prompt a human. It only ever *narrows* what is auto-allowed from the "ask for everything" baseline — it never widens authority.

  ```ruby
  # Only prompt for writes under /protected; auto-allow everything else.
  perms = Nexo::Permissions.new(
    mode: :ask,
    on_ask:   ->(cap, detail) { ask_the_human(cap, detail) },
    ask_when: ->(cap, detail) { cap == :write && detail.to_s.start_with?("/protected") }
  )
  ```

---

## Remote sandbox — bring your own container

`Sandboxes::Remote` contains **zero vendor code**. It wraps any object that satisfies a four-method contract — `read`, `write`, `exec`, `close` — and delegates the `Sandbox` interface to it. Switching providers is swapping the injected object, nothing else:

```ruby
sandbox = Nexo::Sandboxes::Remote.new(client: my_container_client)
# read(path)            -> client.read(path)
# write(path, content)  -> client.write(path, content)
# shell(cmd, timeout:)  -> client.exec(cmd, timeout:)
# glob(pattern)         -> client.exec(<pattern as a positional $1, never interpolated>)
# close                 -> client.close
```

Vendor SDKs rarely expose exactly `read/write/exec/close`, so adapt them with a tiny shim object. Keep the vendor gem a **soft** dependency behind a lazy `require` that raises `Nexo::MissingDependencyError` when it's absent:

```ruby
# A ~10-line adapter wrapping a hypothetical vendor client to the four-method contract.
class E2BAdapter
  def initialize(api_key:)
    require "e2b"            # soft dep — lazy, only when you actually use it
    @sbx = E2B::Sandbox.create(api_key: api_key)
  rescue LoadError
    raise Nexo::MissingDependencyError, "E2BAdapter needs `gem \"e2b\"` in your Gemfile."
  end

  def read(path)              = @sbx.files.read(path)
  def write(path, content)    = @sbx.files.write(path, content)
  def exec(cmd, timeout: 30)  = (r = @sbx.commands.run(cmd, timeout: timeout)
                                 {stdout: r.stdout, stderr: r.stderr, status: r.exit_code})
  def close                   = @sbx.kill
end

agent = Nexo::Agent.new(model: ENV.fetch("NEXO_MODEL"),
                        sandbox: Nexo::Sandboxes::Remote.new(client: E2BAdapter.new(api_key: ENV["E2B_API_KEY"])))
```

Nexo ships **only** `Remote` plus this documented pattern — purpose-built `Sandboxes::E2B` / `Sandboxes::Daytona` classes are a possible future addition, deliberately left out of v1 because their vendor client APIs aren't pinned yet.

---

## Container sandbox — Docker / Apple Container

`Sandboxes::Container` runs an agent's tools inside a **throwaway OCI container** via the `docker` (default) or Apple `container` CLI — shell-out only through `Open3`, **no client gem**, no Compose, no image builder. A model-driven agent never touches your host filesystem or shell directly. Declare it with the `sandbox` macro (`image:` is required — there is no default image):

```ruby
class ContainerReviewer < Nexo::Agent
  model   ENV.fetch("NEXO_MODEL")
  sandbox :docker, image: "node:22-slim",
          binds: { Dir.pwd => { to: "/workspace/repo", mode: :ro } }
end
```

The container `cwd` defaults to `/workspace` (a container path, **not** your host directory); the host dir enters only through a `binds:` entry.

### `runtime:` — one class, two CLIs

`sandbox :docker` (or `runtime: :docker`) shells out to `docker`; `sandbox :apple` (`runtime: :apple`) shells out to Apple's `container` binary. The `run`/`exec` surface is largely shared; where the CLIs diverge (networking especially) the class branches on the runtime. **Apple `container` parity is NOT yet verified** — the flags are encoded from the reference mapping, not confirmed against a live daemon, so every Apple flag must be verified before trust. An unknown runtime raises `Nexo::ConfigurationError`.

### Hardened by default — every knob an explicit opt-out

All of the following are applied to the `run` argv by default and individually invertible:

| Concern | Default | Loosen with |
|---|---|---|
| Network | `--network none` (no egress) | `network: :bridge` / `:host` / a network name |
| Capabilities | `--cap-drop ALL` | `cap_add: %w[NET_BIND_SERVICE ...]` |
| Rootfs | `--read-only` | `readonly_rootfs: false` |
| Writable scratch | `--tmpfs <cwd>:rw` (ephemeral), only when `readonly_rootfs` | a `:rw` host bind for persistence |
| Privilege escalation | `--security-opt no-new-privileges` | (not exposed) |
| PIDs | `--pids-limit 512` (fork-bomb guard) | `pids_limit:` (`nil` omits the flag) |
| Memory / CPU | unset (host decides) | `memory:` / `cpus:` |
| User / uid | left to the image | `user:` (opt-in defense-in-depth) |
| Host binds | **read-only** (`:ro`) | per-bind `{ to:, mode: :rw }` |
| Env vars | none | `env: { "KEY" => "val" }` → one `-e KEY=val` per entry |

Bind spec forms:

```ruby
binds: { "/host/proj" => "/workspace/proj" }                       # -> :ro
binds: { "/host/proj" => { to: "/workspace/proj", mode: :rw } }    # -> :rw
```

**Non-root is not forced.** The image's own uid is respected; `user:` is an opt-in. The other hardening applies **regardless of uid**.

Every argument is passed to `Open3` as an **array**, never string-interpolated, so file contents and commands can't break out of the argv. Paths are expanded against the container `cwd`; a path that escapes raises `SecurityError`. A denied/failed **tool** op surfaces as `{ error: ... }` through the gated tool layer; the **sandbox** itself raises only on misuse — a missing binary (`Nexo::ConfigurationError` naming the binary), a path escape (`SecurityError`), or a container start failure (`Nexo::Error`).

### Lifecycle — ephemeral by default, opt-in reconnect

The container starts **lazily** on first tool use and its id is memoized.

- **Ephemeral (default, `reconnect: false`):** `close` force-removes the container and clears the memo. Idempotent. A standalone container-backed agent tears its container down on `Agent#close`; a workflow driving one through `run_agent` shares the run's sandbox, so teardown happens once at the end of the run in `Workflow.execute`'s `ensure`.
- **Reconnect (`name:` + `reconnect: true`):** every container is tagged at `run` with an **exact identity label** — `--label nexo.sandbox.id=<name>`. On start the sandbox looks up that container by the **exact label filter**, **not** a name substring, and reuses/restarts it instead of creating a new one; `close` leaves it running/stopped so a later sandbox with the same name reattaches. The default container name is `nexo-<run-id>`.
  - **Exact match, never a substring.** A container merely *named* `<name>x` is never reattached — the label filter is exact.
  - **Ambiguity raises, never guesses.** If more than one container carries the same identity label, reconnect raises `Nexo::Error` rather than pick one.
  - **Reconnect never crosses runtimes.** A `:docker` container is never reattached by an `:apple` sandbox or vice versa.

### Honest caveats

- **Network-none breaks installs.** `npm install` / `bundle install` need egress; with the default `network: :none` they fail. Pass `network: :bridge` or bake dependencies into the image.
- **Read-only rootfs needs the scratch.** With `--read-only`, only the tmpfs at `cwd` (and any `:rw` bind) is writable, and the tmpfs is **ephemeral** — lost on `close`. Persist via a `:rw` bind.
- **Non-root is recommended, not forced.** The default hardening holds regardless of uid; set `user:` for defense-in-depth.
- **Apple `container` parity is NOT yet verified** — especially networking. Every Apple flag in the parity table is UNVERIFIED; confirm against Apple's CLI before trusting the `:apple` runtime in production.
- **Reconnect is Docker-only today.** `reconnect: true` combined with `runtime: :apple` raises `Nexo::ConfigurationError` at the point reconnect would run. Use `runtime: :docker` for reconnect, or run an ephemeral `:apple` sandbox.

### Live example

A runnable end-to-end container example is in the repo — the agent reads the mounted repo but never touches your host directly, with no network, dropped capabilities, a read-only rootfs, and the host repo bind-mounted read-only. When the run ends, `agent.close` tears the container down.

```sh
NEXO_LIVE=1 NEXO_MODEL=gemma3:12b ruby -Ilib examples/container_review.rb /path/to/repo
```

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


---

## Next steps

<div class="not-prose mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
  <a href="../permissions/" 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">
      Permissions
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      The second safety axis — what tools may do, on top of where they run.
    </p>
  </a>

  <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 tools gated by these seams.
    </p>
  </a>
</div>
