Sandboxing an AI coding worker
How Mullet 2 keeps a coding worker in a private clone behind bubblewrap, and what probing the real Claude Code binary turned up that the docs did not.
The old Mullet gave its delegates the run of my home directory. They could write anywhere under it and run any shell command, and opencode was started with --auto and handed whatever keys sat in the .env of the current directory. That is fine for a toy. It is not fine for something I leave running while I am out.
Mullet 2 treats the worker as untrusted. Here is how, and what I learned from testing the real binary.
A private clone, with the git directory out of reach
Each node of a job runs in a private clone of the pinned base commit. The clone is made with git clone --separate-git-dir, so the git directory sits outside the worker’s writable area. The worker can edit files. It cannot move a ref, rewrite history or delete objects.
Mullet runs all its own git commands with hooks disabled (core.hooksPath=/dev/null) and an isolated git config. A tracked .husky/ directory is a file the worker can edit, so running hooks from the clone would mean running code the worker wrote, outside any checks.
Bubblewrap
On Linux the worker runs under bubblewrap. macOS Seatbelt support is planned, not done. The setup is:
- A read-only root.
$HOME,/tmp,/runand container sockets hidden.- No network, except during the setup step.
- An allowlisted environment, so none of the keys Mullet itself loaded get through.
- Symlinked bind sources refused.
If the sandbox cannot start, the job refuses to run. There is no unsandboxed fallback. I would rather get an error at 9pm than find out in the morning that a job ran with no walls.
Gate commands run in the same sandbox. They execute the code the worker just wrote, so they need the same walls.
Claude Code’s own settings, fail-closed
Bubblewrap is the outer layer. Claude Code has its own sandbox and permission settings, and Mullet sets them so that anything unclear fails closed:
- The sandbox is required (
failIfUnavailable). - Unsandboxed commands are not allowed.
- Bash is not auto-allowed.
- The home directory is unreadable.
- No network domains are allowed.
- Writes to dependency directories are denied.
The worker itself is started like this:
claude -p --restricted --strict-mcp-config --disable-slash-commands \
--tools Read,Edit,Write,Glob,Grep,Bash \
--permission-mode dontAsk --output-format stream-json \
--json-schema ...
The working directory is the clone. dontAsk means anything not explicitly allowed is refused, and nobody is there to answer a prompt.
What probing the real binary found
I did not trust the settings until I had tried to get out. A few things surprised me.
Claude Code keeps a per-user temp directory at /tmp/claude-<uid>, and it was writable from the worker’s Bash. Whatever one worker leaves there, another could read or change. So Mullet gives each worker its own private TMPDIR.
My first private TMPDIR was a long path, and it broke Claude Code’s sandbox with “Failed to create bridge sockets”. Unix socket paths have a length limit, and a long temp directory pushes the socket path over it. The fix is a short path of the form /tmp/mullet-XXXX.
Compound Bash commands, such as two commands joined with &&, are refused in dontAsk mode. A worker that tries one just gets a refusal and wastes an attempt. So the prompt now states the rules up front.
I confirmed that docker.sock is unreachable from inside. A process that can talk to the Docker socket can generally start containers with host paths mounted, so this was one to check rather than assume.
Hooks in the clone do not fire under --restricted. I wanted to know that for certain, and not assume it.
Why the clone is private
The worker never touches your checkout. Your repo stays as it was until you run mullet job merge, which fetches the job’s branch in as a new branch and stops. If a job goes wrong, mullet job discard is all the cleanup there is. That is a product of the layout, not an extra feature: the worker has no path to the real repo in the first place.
The environment
The same idea applies to secrets. The worker’s environment is built from an allowlist, not filtered from Mullet’s own. The old setup passed along keys from the current directory’s .env. Now nothing Mullet loaded reaches the worker, and a key can only get in if I put it on the list.
What the sandbox does not cover
It limits what the worker can touch on my machine. It does not make the worker’s code correct, which is the job of the gates in Gates, not a model’s word. And it doesn’t cover macOS yet. I don’t have a Mac to test a Seatbelt version on, so on macOS a job refuses to start rather than run without a sandbox.
I am also working on a second executor that runs opencode inside the same sandbox. A security review of it found that the worker’s shell could read the provider key from /proc/<pid>/environ. That is being fixed, and I will write about it when it is. I mention it because it shows the reviews are doing their job, and because an environment allowlist alone does not hide a key the process holds.
The code is at https://github.com/tomgould/mullet-public.