The Sandbox and Permission Seams in Nexo

Nexo's four sandbox tiers and four permission modes, and how an email agent gets write access to one folder and nothing else, enforced in code.

I have been saying the source agents write their output into “the :local sandbox” without explaining what that is. It is time to, because the sandbox and permissions are the two things Nexo genuinely adds on top of ruby_llm, and they are what make “read-only email tool” an enforced property instead of a promise in a prompt.

The two seams answer two different questions. The sandbox is where an agent’s tools act: in memory, on the real filesystem, or inside a container. Permissions are what those tools are allowed to do: read, write, run shell, fetch the web. Every capability passes through both. This post covers both, and shows how the inbox tool uses them to grant exactly one thing beyond reading, and nothing more.

As in the earlier posts, the whole project is on GitHub at mariochavez/nexo-mail.

The four sandbox tiers

The sandbox is chosen with the sandbox macro, and there are four tiers.

:virtual is the default. It is in memory, with zero host access. A virtual sandbox has no filesystem to escape and cannot run a shell command at all; attempting to run one comes back as an error, on purpose. This is the safest option, and it is what you get if you declare no sandbox at all. An untrusted model in a virtual sandbox is fully contained: there is nothing on your machine for it to reach.

:local is the real host filesystem and shell, for code you trust in development or CI. It is what the source agents use, because they need to write a real file you can open afterward. It is guarded, which I will come back to.

:docker and :apple run tools inside a throwaway container, hardened by default: no network, dropped Linux capabilities, a read-only root filesystem with an ephemeral scratch space. This is the tier for running something you do not trust, like a classifier over attachments from strangers, without giving it your machine.

There is also a remote tier, where you inject a client that speaks a small read/write/exec/close interface, so you can point the sandbox at a hosted execution provider with a short adapter and no vendor code in Nexo.

For nexo_mail the choice is :local. The agents read mail through their tools and need to write one JSON file each. :virtual cannot write a file you can open, and a container is more isolation than a tool reading your own mail on your own machine needs.

How the local sandbox is guarded

:local is the real filesystem, so the interesting question is what stops an agent from writing outside its workspace. Two guards.

The first is path containment. The sandbox is rooted at a working directory, and every path an agent’s tools touch is expanded against that root and must stay inside it. A path that resolves outside, including through ../, raises a SecurityError. The second guard resolves symlinks before the check, so a symlink inside the workspace cannot point at a target outside it.

The source agents are constructed with the workspace as their working directory — here the workflow from Part 4 builds one from its catalog descriptor:

agent = descriptor.build(cwd: Config.sandbox_dir)   # e.g. an EmailSource or AppleMailSource

Everything the agent writes lands under Config.sandbox_dir. If the model, for any reason, tried to write to /etc/something or ../secrets, the write would raise rather than succeed. The agent can write its digest and nothing else.

There is one more piece of hygiene worth knowing: the local sandbox narrows the environment its shell sees to PATH, HOME, and LANG, plus anything you add explicitly. A model-driven shell only ever sees that narrowed environment, so secrets you happened to export elsewhere in your process stay out of a stray command’s reach.

The permission modes

The sandbox decides where; permissions decide what. The mode is set with the permissions macro, and there are four.

:read_only is the default. Reading and globbing are always allowed. Writing, running shell, fetching the web, and searching the web are denied. A denied capability returns an error the model can see and adapt to; it never raises. So an agent can look but not touch, and asking it to write simply comes back as “not allowed.”

:auto allows everything. :ask defers each sensitive action to a callback you provide, so you can prompt a human. :approve is the durable sibling of :ask: an undecided action suspends the whole workflow run and waits, which is the subject of a later post.

The important structural fact is that read and glob are free, and write, shell, fetch, and search are all gated. Reading the web is not a read in this model; it is an escalation, gated exactly like writing a file. That surprises people, and it is the right default.

What the inbox tool grants

The source agents need to read mail and write one file. So their permissions are read-only, with write added:

class SourceAgent < Nexo::Agent
  sandbox     :local
  permissions Nexo::Permissions.new(mode: :read_only, allow: %i[read glob write])
end

The allow list adds :write on top of the read-only baseline. That is the single capability granted beyond reading. There is no :shell, no :fetch, no :search.

This interacts with the sandbox in a way worth making explicit. Nexo attaches its Shell tool only when the sandbox can run a shell and the permission mode allows it. A :local sandbox can run shell, but these agents do not permit it, so no Shell tool is ever attached. The model is not told a shell exists. You cannot misuse a capability that was never offered, which is a stronger guarantee than telling the model not to use it.

The read-only story, in one place

By now the tool reads three inboxes through three different integrations, and each one is read-only for a different, concrete reason. It is worth seeing them together, because none of them rely on the prompt:

Apple Mail is reached over MCP, as Part 1 set up, and the mcp_allow list contains only read tools. The gate denies send and delete by name, even though the server exposes them.

Gmail is read over IMAP with EXAMINE, a read-only select, and BODY.PEEK, which does not set the seen flag. The server refuses changes and reading marks nothing.

HEY is read through its CLI as an argv array with hardcoded read subcommands, run without a shell, so there is no way to reach a write subcommand or inject one. Both of those are the custom tools from Part 3.

And underneath all three, the sandbox is :local with only read, glob, and write, fenced to the workspace. The agents can write their own digest files and touch nothing else on the machine.

Four independent mechanisms, none of them a request to the model to behave. That is what I mean when I call the tool read-only by construction.

A note on the write guard

Since the agents do write files, one built-in behavior is worth knowing. On a real filesystem, WriteFile will refuse to overwrite a file the agent has not read first, and will refuse to overwrite a file whose modification time changed since it was read. New file writes go through freely. This prevents an agent from clobbering something it never looked at, or racing over an external change. On the virtual sandbox the guard is skipped, since there is no real mtime. It rarely comes up when each agent writes its own fresh file, but it does matter once the sources run at the same time, as they have since Part 5.

Where this leaves us

The tool now reads three inboxes concurrently and writes each source’s structured output into a fenced workspace, read-only by construction, with only write granted beyond reading. What it does not yet have is a single, coherent result. There are three separate JSON files, not one briefing. The next post adds a synthesis step that merges them into one digest, and introduces the design rule that keeps the library thin: the Ruby orchestrates and provides tools, and the agents and skills do the judgment.

What will trip you up

The bare :ask symbol does not prompt anyone. permissions :ask with no callback resolves to a gate with nothing to ask, so sensitive actions are denied, not prompted. To actually prompt, pass a built Nexo::Permissions with an on_ask callback. This bites people who expect :ask to be interactive out of the box.

Fetch and search are denied by default, like shell. If you want an agent to read a web page, granting the capability is an escalation you opt into with :fetch in the allow list plus a host allow-list, not something reading implies.

The virtual sandbox cannot run shell, and that is not a gap. If you need shell, use :local or a container. Do not read the virtual sandbox’s refusal as a bug.

Container defaults are strict for a reason, and they break naive installs. A :docker sandbox runs with no network, so npm install or bundle install inside it fail. Bake dependencies into the image or open the network deliberately, and remember the container’s scratch space is ephemeral, so persist anything you need through a writable bind.

Next in the series: Part 7, merging the sources into one digest.