Edu Sanchez

A sandbox for the agent, on your laptop

The other posts on this blog are about Semantic Graph. This one is about a different problem I hit while building it.

Running a coding agent with the permission checks turned off is the fastest way to work with it. It is also reckless. The agent can read anything you can read, reach anything you can reach, and act with all your credentials. One bad tool call and it is in your SSH keys, your other repositories, or your Keychain. I wanted to keep the speed and put a real boundary around it. In July I wrote a small tool in Rust that does that, and this post is about how it works.

What a sandbox is here

A sandbox is a lightweight virtual machine on your own Mac. It runs Claude Code plus the development tools you pick, and it gets only the credentials and network hosts you grant it. You create one from a terminal UI, drop into a shell inside it, work with the agent, and throw it away when you are done.

The tool is not a hypervisor. Apple ships an open source container command that runs each container in its own microVM through the Virtualization framework. Apple owns the VM lifecycle. My tool owns the policy and the convenience: which image, which credentials, which network, and the UI to drive it. Every action in the UI ends up as one of a few commands against that runtime: build, create, start, stop, exec and rm. The code that builds those commands has no side effects and is unit tested. The part that runs them is a thin wrapper.

Why a VM and not a devcontainer

The usual answer to this problem is a devcontainer. It shares the host kernel, and most setups mount your real Claude config and your real repositories into it. That is convenient, and it is exactly what I did not want. A shared kernel is one attack surface between the agent and everything else on the machine.

With a VM per sandbox the boundary is hardware virtualisation. By default the sandbox sees nothing from the host filesystem. Code enters the sandbox the boring way: you clone it inside, with a git token you typed into the wizard for that sandbox. If you really need a host folder, you add a mount, and it is read-only unless you say otherwise. The detail panel always shows which host folders a sandbox can reach and which it can write to. Those mounts are a deliberate hole in the isolation, so the tool makes them visible rather than easy.

The catalogue

The configuration is one TOML file. It is a catalogue, not a list of secrets. It names the credential slots a sandbox can fill in and the egress allowlist that every sandbox gets.

[cage]
cpus    = 4
memory  = "4G"
workdir = "/work"

[egress]
allow = ["gateway.corp:4000", "gitlab.corp:443"]

[[credentials]]
name = "GITLAB_TOKEN"
description = "git token for HTTPS clones"

When you create a sandbox, the wizard asks for a label, the values for those slots, and the tools you want. The tool selection decides the image. Pick Node and Claude Code and the image tag is node-claude. Pick nothing and you get core. Every toolset builds once, from one Containerfile with a build argument per tool, and every sandbox that picks the same set shares the image. The build output streams into the UI, because a build that takes minutes should not be a black box.

The core of the image is fixed and cannot be deselected: git, the corporate root certificate, the registry mirror configuration, and the firewall. On top of that you choose from Node, Python, a JDK, the Docker CLI, Claude Code and ripgrep. The build context is embedded in the binary, so the tool ships as a single executable.

Create stopped, attach later

A new sandbox is created stopped. No CPU or memory is spent until you attach. Attaching is where the terminal UI does the trick that tools like lazygit use. It suspends itself and clears the screen and the scrollback. Then it hands your real terminal to a login shell inside the VM, and rebuilds itself when the shell exits. Stopping a sandbox frees its compute but keeps its disk. Destroying it removes the whole VM and its disk.

The runtime knows which VMs exist. It does not know what you selected for each one. That intent, the label, the credential values, the mounts and the sizing, lives in a small SQLite file in your home directory. On startup the tool compares the two: each stored sandbox is running, stopped, or missing because the runtime no longer has it. It also measures the real on-disk size of each one, so you can see which to delete when the disk fills up.

Egress, enforced

This is the part I care most about. Egress is default-deny, and it is enforced, not suggested.

An empty allowlist means no network at all. The sandbox is launched with --network none and cannot reach anything, the AI gateway included. It is genuinely offline, so the agent will not work in it. That is the only way to shut the gateway out, and it is a valid choice for some jobs.

A non-empty allowlist means only those hosts. The runtime has no per-host filter. So the sandbox is launched on the default network with the one capability needed to program a firewall, and an entrypoint inside the VM builds an nftables ruleset. The output chain drops everything by default. Each allowed host is resolved once at boot and pinned to its addresses, narrowed to the port when one was given. DNS is only opened to the resolvers listed in the resolver file inside the VM, never to any host on port 53. A blanket DNS rule is an open tunnel out.

It fails closed. If no host resolves, or the ruleset cannot be loaded, the entrypoint installs a drop-all ruleset instead of starting with an open network. If even that fails, the sandbox refuses to start. I verified the behaviour against the runtime: an allowed host and port is reachable, and the same IP on a different port is blocked.

One more decision I would make again. The gateway the agent talks to is hardcoded in the binary, both the environment variables the agent reads and the matching firewall rule. The config file cannot repoint it, drop it or narrow it. The credential and the hole in the perimeter come from the same constant, so they cannot drift apart.

Credentials

Credential values are injected as environment variables through the command’s argument list, never through a shell string, so there is nothing to quote wrong. They are also saved on the sandbox, so you can edit a sandbox later without retyping them.

Shared caches are the other convenience. A cold sandbox downloads its whole dependency tree on the first build. You can opt into a shared Maven, npm or pip cache, each a named volume the runtime owns. It mounts only the artifact directory, never the parent folder that holds the settings files with registry configuration, so a cache can never shadow a credential. Everything in it was produced by the sandboxes themselves, so nothing built for the Mac ends up in a Linux cache.

A remote IDE without a port

Sometimes a terminal is not enough and you want the code inside the sandbox open in a real IDE. The obvious way is SSH into the VM. On the current version of Apple’s runtime there is no working TCP path from the host to the VM. The VM’s IP is not reachable, and a published port accepts the connection and then drops it before the SSH banner. I checked both.

What does always work is container exec, the same channel attach rides. So the tool tunnels SSH over it. Each connection starts a one-shot sshd -i inside the sandbox, and a five-line block in your SSH config turns that into a ProxyCommand. No TCP port is opened anywhere. JetBrains Gateway and VS Code Remote both work through it, and the IDE backend runs inside the sandbox, so your Mac only runs the thin client. The SSH server is in every image but stays off until you give a sandbox an SSH password.

Linux

The tool picks its backend from the operating system. On a Mac it is Apple’s runtime. On Linux it is Docker, and a sandbox is a container rather than a VM. The image and the in-VM firewall are the same. That was enough to run the test suite in CI, but a container on a shared kernel is a weaker boundary than a VM.

So for Linux I also built a server version, where several people share one Linux VM from the same terminal UI. There the server imposes a hardened container spec that the caller cannot weaken. Every capability is dropped and only the two needed to change user are added back. New privileges are forbidden. The root filesystem is read-only with a tmpfs for scratch. The container has to start as root because programming the firewall needs it. So the entrypoint installs the rules, then drops to an unprivileged user before running anything else. There is a unit test asserting that the --user flag is absent from the create command, because adding it would break the firewall install. The test is there so nobody adds the flag back with good intentions.

Attaching there goes over a WebSocket to a pseudo-terminal the server owns, and the shell inside the container is reached through the Docker daemon’s own channel. No SSH, no published port, and the terminal works even when the sandbox has no network.

Numbers and what was left

The whole thing is under nine thousand lines of Rust with around a hundred and seventy tests, most of them on the command assembly and the egress parser. Unsafe code is forbidden, and the lints are strict enough that unwrap and println do not compile outside tests. That last rule sounds fussy until you remember the UI owns the terminal and any stray print corrupts it.

What was left is the laptop. A sandbox lives on your Mac. Close the lid and the agent stops. The moment I started giving the agent jobs that ran for hours, I wanted three things. A machine that stays on. A browser page where each person asks for their own sandbox. And state that survives a disconnect. That is the next post.

All posts