Sessions in cages

Auto-approve is what makes an agent useful from a phone: you send a message, put the phone away, and the work gets done without you approving every command. It also means the agent runs any command it decides to, as your user, with everything your user can reach. A cage lets you keep auto-approve without lending the session the whole machine.

A caged session runs in a rootless podman container of its own. Inside, the agent is root, so it can apt install whatever its work needs. Outside, it is your user and nothing more, and of your home directory it sees only what it needs: its project, and the settings of the harness that runs it. What it installs stays in the container until the session is deleted.

Not caged, caged, sealed

Three choices, one sentence each, and kept true:

What makes "caged" true:

Its conversation is still the real file in your ~/.claude, so claude --resume in a terminal, the apps' history and taking the session out of the cage all work as before. Sessions in the same working folder share it: they see each other's conversations, as they would outside a cage.

Why it is built this way is in ADR-037; the research, and what comes after (micro-VMs, Akash, agents starting agents), is in the cages research and plan.

What a cage protects, and what it does not

A cage contains the machine. It does not contain the session's reach. Be clear about the difference before you rely on it.

A caged session cannot:

A caged session can still:

Three options widen it further, and none is on by default:

So a cage is the right tool against an agent that does something careless with your machine: rm -rf in the wrong place, a global install that breaks your toolchain, a stray server left running. It is not a security boundary against a session that is deliberately turned against you, for example by a prompt injection in a web page it read. For that, keep secrets out of the agent's environment, leave the two options off, and review what it pushes.

Sealed cages: for code nobody vouches for

For reviewing someone else's code — a contributor's pull request, an application a stranger built — tick sealed beside "in a cage" (ADR-045). A sealed cage is an ordinary cage, and also:

After a sealed session: the directory is untrusted. The code under review could write in its project, and leave something there that runs later, outside the cage, as you: a .git/hooks script, a Makefile target, an .envrc, an npm postinstall, an editor's workspace settings. No mount prevents that without breaking the review. So run nothing from that directory outside a cage — no git command that runs hooks, no build, no direnv allow. Read it, then delete it, or clone again to go on.

Giving a machine its sealed token, once:

claude setup-token
install -m 600 /dev/stdin ~/.local/share/shrooms-agent/sealed-claude-token

Paste the token claude setup-token printed into the second command, then Ctrl-D. It belongs to your subscription and can be revoked from your account; --sealed-token FILE keeps it elsewhere. Until it exists, the apps show how instead of the option.

Requirements

The agent looks for podman on its PATH when it starts. If it finds it, its log says cages offered, and "in a cage" appears in "+ session" in both apps. If it does not, or the agent runs with --cages=false:

A machine with docker and no podman gets no cages.

Choosing an image

There are two images of ours, built from one Containerfile that ships inside the agent (internal/agent/workbench.Containerfile):

ImageNameWhat is in it
workbenchlocalhost/shrooms-workbench:latestNode 22 on Debian bookworm, git, curl, a C toolchain, Python with pip and venv, ripgrep, jq, pi, the GitHub CLI
desktoplocalhost/shrooms-workbench:desktopthe workbench, plus Xvfb, xdotool, ImageMagick, ffmpeg, fonts and the libraries Qt apps and browsers load (about 1.4 GB)

Claude Code is in neither. The machine's own claude program is mounted into the cage read-only, so a cage runs the same version as the machine and needs no rebuild when Claude Code updates. pi is different: a caged pi session runs the pi installed in the image.

The workbench is the machine's image unless you start the agent with --cage-image (see Setting up). Whatever the machine's image is, the apps offer it first and the two of ours after it. Over the API, a session can name any image with {"image": "…"}.

Our images build themselves. The first time a cage needs one that is not there, the agent builds it on the machine, in the background. That takes a few minutes for the workbench and longer for the desktop. The apps say so beside "in a cage", and a message sent meanwhile is refused with:

the image for this session's cage is being built (a few minutes, the first time); send again then

Each image is labelled with a hash of the Containerfile it was built from. When a newer agent brings a changed Containerfile, it builds the image again, at its start for the images caged sessions use, and when a cage is made. The old image serves until the new one is ready. Containers already made keep the image they were made from; moving the session out and back in gives it the new one.

Your own image is yours to provide: the agent does not build or pull it. Pull or build it first (podman pull, podman build -t), or the session's first message fails with "there is no image … on this machine". It needs sleep on its PATH (the container runs sleep infinity), and Node if you run Claude Code from npm rather than its native build. Root inside is assumed.

Starting a caged session

In "+ session", in Basecamp or on the phone, tick in a cage. The line beside it says what a cage is given, and whether the image is ready, being built, or failed to build last time. Under it:

Turn on auto-approve as you would for any session. The same choice is offered when you take over a conversation started in a terminal.

The container is not made yet. It is made with the session's first start, when you send the first message. A caged session carries the violet CAGED tag in the list and on the board, and "in a cage (…)" in its header. In Basecamp, hover over the tag for the image and options.

Over the API, add cage to the request: {} for the machine's image.

curl -X POST "http://[MESH-ADDRESS]:7387/v1/sessions" \
  -d '{"name": "webapp", "dir": "~/src/webapp", "auto_approve": true,
       "cage": {"image": "localhost/shrooms-workbench:desktop", "github": true}}'

GET /v1/harnesses answers with a cage object as well: whether cages are available, the machine's image, the images offered, nix, ready, building, the error of the last failed build, and shared, shared_in and shared_root for the shared folder (below).

Moving a session in and out

"cage" in an open session's header moves it into a cage; on a caged session it reads "caged" and lets you CHANGE the options or TAKE OUT. Over the API:

curl -X POST "http://[MESH-ADDRESS]:7387/v1/sessions/webapp/cage" \
  -d '{"cage": {"nix": true}}'
curl -X POST "http://[MESH-ADDRESS]:7387/v1/sessions/webapp/cage" \
  -d '{"cage": null}'

A cage's memory and CPUs

A cage gets the machine's limits: 4 GB of memory and 2 CPUs unless the agent is started with others. A session can have its own. In the cage dialog, under RESOURCES, set its memory (16g, 512m) and CPUs (8, up to the machine's), or leave them empty for the machine's. Over the API:

curl -X POST "http://[MESH-ADDRESS]:7387/v1/sessions/review/cage/limits" \
  -d '{"memory": "16g", "cpus": "8"}'

They are set on the cage as it is (podman update), running or not. It is not made again, so what was installed in it stays: a nix, a toolchain, a build. A caged agent cannot change its own limits.

What the agent inside sees

Files, at the machine's own paths:

The shared folder (ADR-057) is one per set of meshes: shrooms-shared for sessions on every mesh, shrooms-shared-LABELS-HASH for a session limited to some. So a session limited to the home mesh shares nothing with an office one. Changing a session's meshes stops it, and its next start makes its cage again with the other folder. On the machine it is podman's volume directory (shared_root in GET /v1/harnesses, usually ~/.local/share/containers/storage/volumes), then VOLUME/_data; the list gives each session's volume as cage.shared, and the cage dialog shows the path with OPEN. Treat what is in it as written by an agent: a link there can point anywhere on your machine when you follow it outside a cage. The agent makes the volume itself and refuses one of that name defined any other way (a bind to a directory of yours, say), and podman volume prune deletes it once no container uses it.

Nothing else of your home directory is there. Files the agent writes into the project belong to your user outside, even though it wrote them as root.

HOME is your home directory's path, so ~/.claude resolves as on the machine. Claude Code's settings file, normally ~/.claude.json beside the home directory, goes to ~/.claude/.claude.json instead (CLAUDE_CONFIG_DIR), since nothing beside it is mounted.

Environment. The cage gets its own PATH (the image's, with your nix profile in front when it has nix), SHROOMS_AGENT_SESSION, IS_SANDBOX=1, the gateway's base URL and a dummy key (ADR-059), and of the agent's own environment only what is named:

Nothing else passes, whatever its name: no key or token (its model is reached through the agent, which holds them), not your PATH, DISPLAY, SSH_AUTH_SOCK or USER, and nothing added to the agent's environment later. A GitHub login reaches a cage only as the gh login mounted read-only. The variables are written to a file only you can read (cages/<container>.env in the agent's state directory, ~/.local/share/shrooms-agent by default) rather than on podman's command line, so keys do not show up in the process list.

Network. The cage has an address of its own (fd5e:ca9e:1::2) and a default route through pasta, which carries its traffic out on the machine's own sockets. To the internet and to the mesh, it looks like this machine.

A GUI in the desktop cage

The desktop image has what it takes to run an app without a screen, drive it and record it: what a reviewer needs. Nothing is started for you; the agent starts the X server itself, for example:

Xvfb :99 -screen 0 1920x1080x24 &
export DISPLAY=:99
xdotool search --name "My App" windowactivate
import -window root shot.png
ffmpeg -f x11grab -video_size 1920x1080 -i :99 -t 30 run.mp4

AppImages run: there is no FUSE in a cage, so the image sets APPIMAGE_EXTRACT_AND_RUN=1 and they unpack themselves on each start. Xvfb and the app end when the session's process does, with everything else the session left running; the agent starts them again on its next turn.

Checking on a cage from a shell

On the machine, as the agent's user. Each container is named shrooms-<session>-<6 hex characters>, chosen when the session is made or moved into a cage, so a renamed session keeps its container and a new session of an old name gets its own. Each carries a label with the session's name:

podman ps -a --filter label=xyz.vpavlin.shrooms.session
podman ps -a --filter label=xyz.vpavlin.shrooms.session=webapp

A container that is "Exited" is normal: it is stopped whenever the session's process ends, and started with the next message.

To look inside, or at what is running:

podman exec -it shrooms-webapp-3f9a1c bash
podman top shrooms-webapp-3f9a1c args

The agent runs the same podman top to see whether a background command is still running after a turn ended: then a session with an open task is not reminded of it yet.

To try a cage for real from a checkout of the repository (it runs a Claude Code turn that installs a package with apt and writes a file into a project, then checks the file is owned by you):

SHROOMS_REAL_CAGE=1 go test ./internal/agent -run TestARealCage -v

The workbench image has to be built already: start a caged session once, or the test's message is refused as "being built".

Cleaning up

Deleting a session deletes its container (podman rm -f), everything installed in it, and its environment file. Taking a session out of its cage does the same. You should not need to do more.

If containers are left over, for example from a session deleted while podman was not answering, list them with the label filter above and remove those whose session no longer exists:

podman rm -f shrooms-oldsession-a1b2c3

The images stay until you remove them. A rebuild leaves the previous image untagged:

podman image prune
podman rmi localhost/shrooms-workbench:desktop

A removed image of ours is built again the next time a cage needs it.

Troubleshooting

The image does not build. The apps show the last line of the build's error beside "in a cage", and GET /v1/harnesses has it in cage.error; the agent's log has the whole run. Builds use the machine's network (--network host), so a failure to fetch from Debian, npm or GitHub is the machine's own network or a proxy. Check disk space as well: the build cache and the desktop image take a few GB. Sending a message to a caged session tries the build again.

The first message is refused as "being built". The image is being built; send it again in a few minutes. Nothing was lost; the message was not sent.

A cage cannot reach the mesh. Check from inside, against this machine's agent:

podman exec shrooms-webapp-3f9a1c curl -s "http://[MESH-ADDRESS]:7387/v1/harnesses"

A container keeps the network it was made with. One made by an agent older than the pasta address above copies one of the machine's interfaces and reaches only one mesh, if any. Move the session out of its cage and back in to get a new container. If a new container cannot reach it either, check that the machine itself reaches the address, and that pasta is installed.

Claude Code refuses to skip permissions as root. It does that everywhere except a sandbox, which it detects by IS_SANDBOX=1. The agent sets it for every caged session. If you run claude yourself in a cage with podman exec, pass it: podman exec -it -e IS_SANDBOX=1 …, or --env-file with the session's environment file.

The shrooms tools fail inside a cage. The shrooms-agent program is mounted when the container is made. If the agent's binary has since moved to another path, the container lacks it: move the session out and back in. The tools reach agents only through /run/shrooms-agent/proxy.sock, which the agent serves while it runs.

A message fails with a podman error about cgroups or controllers. The limits could not be applied: see Requirements.

See also