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:remoteis always an explicit choice in your code. The default never widens host access.
Virtual(default) — in-memory, zero host access.#shellraisesNotImplementedErroron 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 againstcwdand must stay inside it (elseSecurityError), and the shell sees onlyPATH,HOME,LANG(plus explicitenv:additions) — never the full process environment.Container— run the tools inside a throwaway local container via thedocker(default) or ApplecontainerCLI. 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:remoteis 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…”.Virtualsays nothing. Ordering: agent instructions → sandbox instructions → skill instructions. - Capability-gated tool attach (
Sandbox#supports?). A:virtualagent no longer advertises aShelltool it can never run —Agent#chatattachesShellonly 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 lastmax_lineslines, appends a…[truncated N lines]marker, then caps atmax_chars. The integerstatuspasses 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 onVirtual. Best-effort: mtime-based. Clobber-safety within a session only — no versioning, locking, or VCS semantics. -
Scoped
:askpredicate (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):closeforce-removes the container and clears the memo. Idempotent. A standalone container-backed agent tears its container down onAgent#close; a workflow driving one throughrun_agentshares the run’s sandbox, so teardown happens once at the end of the run inWorkflow.execute’sensure. - Reconnect (
name:+reconnect: true): every container is tagged atrunwith 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;closeleaves it running/stopped so a later sandbox with the same name reattaches. The default container name isnexo-<run-id>.- Exact match, never a substring. A container merely named
<name>xis never reattached — the label filter is exact. - Ambiguity raises, never guesses. If more than one container carries the same identity label, reconnect raises
Nexo::Errorrather than pick one. - Reconnect never crosses runtimes. A
:dockercontainer is never reattached by an:applesandbox or vice versa.
- Exact match, never a substring. A container merely named
Honest caveats
- Network-none breaks installs.
npm install/bundle installneed egress; with the defaultnetwork: :nonethey fail. Passnetwork: :bridgeor bake dependencies into the image. - Read-only rootfs needs the scratch. With
--read-only, only the tmpfs atcwd(and any:rwbind) is writable, and the tmpfs is ephemeral — lost onclose. Persist via a:rwbind. - Non-root is recommended, not forced. The default hardening holds regardless of uid; set
user:for defense-in-depth. - Apple
containerparity is NOT yet verified — especially networking. Every Apple flag in the parity table is UNVERIFIED; confirm against Apple’s CLI before trusting the:appleruntime in production. - Reconnect is Docker-only today.
reconnect: truecombined withruntime: :appleraisesNexo::ConfigurationErrorat the point reconnect would run. Useruntime: :dockerfor reconnect, or run an ephemeral:applesandbox.
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