Skip to main content

Sandboxes

Where an agent's tools act — in-memory Virtual, host-backed Local, a throwaway Container, or a Remote you inject. Hardened by default; escalating is always an explicit opt-in.

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).

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 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.

  :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.


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.

    # 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:

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:

# 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):

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:

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.

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

View examples/container_review.rb on GitHub →


Next steps