Everything that makes Docker Sandboxes comfortable to run Claude Code in day to day: per-stack templates, one-word attach helpers, your host Claude config carried in and network kits. The microVM sandbox itself still does the isolating with a policy-controlled network and no access to your real GitHub or Anthropic credentials.
The isolation covers the network, credentials and the system, not the workspace files. By default the working tree is bind-mounted live at its host path, so the agent's edits land in your real checkout as it makes them - git is the only undo.
This is a personal setup, published so others can build their own from it. It is not a supported project and takes no contributions.
The templates it builds on:
| Template | Directory | Suggested tag |
|---|---|---|
| Swift | swift/ |
swift-sbx:1 |
| TypeScript | typescript/ |
typescript-sbx:1 |
| Python | python/ |
python-sbx:1 |
Each image adds its stack's toolchain to docker/sandbox-templates:claude-code and stays thin: linters, formatters and test runners come from the project's own dependencies. The one base default replaced is Claude Code itself, reinstalled on the latest channel to match a host running the claude-code@latest cask.
Every template directory holds:
Dockerfile- the stack's toolchain, baked once and cached.docker/*.sh- optional, the stack's own build steps and baked-in scripts (TypeScript has the nvm ones).agent-instructions.md- the stack's own agent instructions, appended to the shared ones. See Agent instructions.kit/spec.yaml- aschemaVersion: "2"mixin applied at sandbox creation: the stack's network allowlist and environment variables.
shared/ holds what none of that should repeat:
shared/agent-instructions.md- the instructions every template gets.shared/kit/spec.yaml- a second mixin, stacked under the stack's own: GitHub and distro package hosts,GIT_TERMINAL_PROMPT, the git-over-HTTPS rewrite.shared/docker/*.sh- the build steps every template runs: base apt packages,gh, Claude Code, the shared sanity checks.
local/kit/spec.yaml is a third, optional mixin for whatever only this machine needs. It is gitignored; see Local kit.
Because the templates COPY from shared/, the build context is the repo root. .dockerignore lets only */agent-instructions.md and */docker into it, so anything a Dockerfile COPYs lives in one of those.
At the root, build.sh regenerates every template, aliases.zsh provides one-word helpers, and claude-config.sh unpacks your Claude config inside a sandbox.
Docker Desktop, Apple Silicon, macOS 14+. The kits use the v2 kit grammar, so sbx v0.38.0 or newer.
brew install docker/tap/sbx
brew install jq # the shell helpers use `jq` to check what a sandbox is bound to
sbx login
sbx policy init balanced # one-time global baseline; `sbx policy reset` to start overbalanced is what sbx recommends: typical development traffic, with the kits adding their stack's domains on top. The kits are additive and assume that baseline, so keep balanced as the global policy. To go wider, open up a single sandbox - see Run: strict vs. full network.
Run build.sh after editing any Dockerfile, agent-instructions.md, kit/spec.yaml or anything under shared/:
./build.sh # all three
./build.sh ts swift # subset; ts|typescript, py|python, swift
./build.sh --prune # plus `docker builder prune -af` at the endPer stack it runs the local, no-registry flow end to end:
docker build --load -t swift-sbx:1 -f swift/Dockerfile .
docker image save swift-sbx:1 -o swift-sbx.tar
sbx template load swift-sbx.tar
docker image rm swift-sbx:1
rm -f swift-sbx.tarsbx keeps its own copy, so the tar and the Docker image go as soon as the template is loaded. Stacks run one at a time, keeping peak disk at a single stack rather than all three (Swift alone is ~7.5 GB). Build cache survives unless you pass --prune.
Re-loading a tag leaves the previous image untagged in sbx's store; clear it with sbx template rm, safe once no sandbox uses that tag.
To share across machines, push to a registry (keep the full docker.io/ prefix; the repo may be private):
docker build -t docker.io/YOURUSER/swift-sbx:1 -f swift/Dockerfile --push .aliases.zsh wraps create-then-attach into one word per stack. Source it from ~/.zshrc:
source /path/to/this/repo/aliases.zsh| Helper | Network |
|---|---|
ccts / ccswift / ccpy |
kit allowlist |
the same with --full (short: -f) |
everything |
Each creates the sandbox for the current directory if needed, applies ** with --full, then attaches; an existing sandbox is attached to as it stands, so the rule is applied once. Remaining arguments pass through to sbx run, so ccts -- --continue works.
Two more flags, both creation-only - on an existing sandbox the helper warns and carries on:
| Flag | Effect |
|---|---|
-p, --publish |
publish a port, repeatable. ccts -p 3000:3000; bare -p 3000 picks a host port |
--mcp a,b |
a fixed MCP server set instead of the default |
Afterwards it is sbx ports <name> --publish 3000 and sbx mcp load <server> --sandbox <name>.
The sandbox is named <helper>[-full]-<current directory>, so ccts --full in myproj/ gives ccts-full-myproj. Two projects sharing a directory name collide; the helper refuses, keeping you out of the wrong tree, and prints the sbx rm -f that would take the name over.
No teardown step: sbx stops a sandbox itself ~30s after the last session disconnects. After editing aliases.zsh, re-source it or open a new shell.
sbx gives a sandbox none of your Claude setup: no global CLAUDE.md, commands, skills or plugins. On every attach, before sbx run starts the agent, the helpers tar parts of ~/.claude, sbx cp it in, and claude-config.sh unpacks it inside. One-way, no mount, so nothing in the sandbox can reach your host config.
- Every attach:
CLAUDE.md,commands,skills,agents,output-styles,rules,workflows,themes,hooks,agent-memory,keybindings.json,loop.md,remote-settings.json(the cached policy file, if your host has one). Managed entries are wiped first, so host deletions propagate. - On change:
plugins, ~70 MB, skipped unless the host fingerprint moved; removing the directory on the host clears it here too. Absolute host paths ininstalled_plugins.jsonandknown_marketplaces.jsonare repointed here; entries that still resolve nowhere are dropped, along with their plugins. - Merged:
settings.jsonover sbx's, somodel,effortLevel,permissionsandthemeapply while sbx'sapiKeyHelpersurvives. Every merge restarts fromsettings.sbx.json, so a key dropped on the host disappears here too. The image's secret-file deny list is unioned in afterwards, so a hostpermissions.denyadds to it rather than replacing it. - Untouched:
.credentials.jsonand~/.claude.jsonstay sbx's.routinesandscheduled_tasks.jsonare left out on purpose, so the sandbox does not re-fire your host's scheduled runs.
Transcripts and memory stay in the sandbox (still there for claude -c on re-attach), and host edits land at the next attach. claude-config.sh is copied in at attach time, so editing it needs no rebuild.
sbx's own sbx skills import mounts one store read-write into ~/.claude/skills in every sandbox. The helpers pass --no-share-skills and keep copying: a shared mount lets an agent rewrite what every other sandbox reads, and it outlives sbx rm.
A fine-grained GitHub PAT, scoped to the one repo and bound to this sandbox rather than globally, with Contents: Read and write, Pull requests: Read and write, Actions: Read, Metadata: Read:
echo "<your-fine-grained-token>" | sbx secret set myproj githubA host-side proxy injects the token into requests to github.com. It is never written inside the VM, so the agent can push but cannot read or exfiltrate it.
Anthropic credentials: sign in with OAuth on first run, or sbx secret set -g anthropic.
Every sandbox runs an MCP gateway, registered as the mcp-gateway server by sbx's own Claude kit. The agent reaches servers through it and never talks to them directly, so no kit allowlist entry is needed. Register once, on the host:
sbx mcp add notion --url https://mcp.notion.com/mcp # OAuth flow opens in the browser
sbx mcp lsA sandbox starts dynamic: nothing preloaded, but the agent gets mcp-find and mcp-add and can attach anything registered above mid-session. --mcp notion,linear fixes the set at creation and turns those tools off. sbx mcp load notion --sandbox ccts-myproj attaches to a running sandbox without a restart.
The OAuth token stays in the host keychain, same as GitHub. A server registered with --command runs on the host, outside the isolation.
Two sandboxes off the same image and kits: the strict one gets the kits' allowlist, the full one can reach anything, which is what you want when researching a new library and an allowlist is just friction. --kit is repeatable and the kits are additive, so the shared one goes first and the stack's on top. The shell helpers do this in one word; below is the equivalent by hand, minus the Claude config they also copy in. Add --kit ./local/kit to every one of these if you keep a local kit.
# strict - kit allowlist only
sbx run --name myproj -t swift-sbx:1 --kit ./shared/kit --kit ./swift/kit claude
# full - unrestricted outbound
sbx run --name myproj-full -t swift-sbx:1 --kit ./shared/kit --kit ./swift/kit -d claude
sbx policy allow network --sandbox myproj-full "**"
sbx run --name myproj-full # attach
# re-attach later; agent and template come from the sandbox spec
sbx run --name myprojIt takes two commands because ** is only valid for sbx policy allow network, so "allow everything" cannot live in a kit's permissions.network.allow. Consequences:
- The rule attaches to a sandbox, not an image, so the sandbox has to exist first - hence
-d(detached create), then the policy call, then attach. --sandboxscopes it. Omitting it would open the network for all sandboxes.- The rule dies with the sandbox. After
sbx rm, re-add it.
Use --clone to run against a private in-container clone instead of the live tree; mount extra read-only context with sbx run claude . /path/to/docs:ro.
sbx ls
sbx exec myproj -- bash -lc 'swift --version' # run a command without attaching
sbx kit add myproj ./shared/kit # re-apply a kit, one per call
sbx kit add myproj ./swift/kit
sbx policy ls # effective policies, per sandbox
sbx ports myproj # what is published
sbx rm myprojsbx kit add applies the kit's network rules too, so editing an allowlist no longer means recreating the sandbox. It swaps the container to do it, keeping volumes and the workspace, so the running session drops - re-attach with the helper.
Two files decide what a sandbox can reach: shared/kit/spec.yaml for what every stack needs (GitHub, distro packages) and the stack's own kit/spec.yaml for its ecosystem. Read and edit them there. Two rules:
- A root domain does not match its wildcard. List
swift.organd*.swift.orgseparately. - Ports are optional but written explicitly (
:443) to keep the intent obvious.
One-off additions without touching the kit:
sbx policy allow network --sandbox myproj docs.example.com # this sandbox
sbx policy allow network "docs.example.com,cdn.example.com" # all sandboxes
sbx policy deny network --sandbox myproj tracker.example.com # deny wins over allow
sbx policy check ... # test a request against policyIf general web access is the actual requirement, use a --full sandbox.
A gitignored file for anything only this machine needs to reach, such as your employer's telemetry endpoint, a private package registry, or a service behind a company network:
cp local/kit/spec.yaml.example local/kit/spec.yamlEach image carries /etc/claude-code/CLAUDE.md, Claude Code's managed-policy memory path on Linux. It loads in every session in every repo inside the sandbox, cannot be excluded by any settings layer, and loads before ~/.claude/CLAUDE.md and the project's own ./CLAUDE.md, both of which still load afterwards. The sandbox instructions are a floor, not a replacement.
It is shared/agent-instructions.md with the template's own agent-instructions.md appended, concatenated during the build. Stack-specific instructions go in the template's file, everything else in the shared one.
The sources are deliberately not named CLAUDE.md or AGENTS.md: those load as project memory, so an agent working on this repo on the host would read the sandbox instructions and act as if it were inside one. Only the root CLAUDE.md applies here.
Because it lives in the image it exists only inside the sandbox, never on the host. Editing it means rebuilding that image.
shared/docker/secret-deny.json tries to block credential reads with Claude Code deny rules, which it bakes into every image, and claude-config.sh unions them into the agent's settings on each attach, blocking reads of .env and other credentials while leaving .env.example readable.
It covers the file tools and the read-a-file commands in Bash, cat, head, tail, grep and friends. Two limits: a deliberate subprocess such as python3 -c "print(open('.env').read())" still reads the file - and so does your app, which is the point, since scripts, dev servers and tests have to keep working - and the agent can edit the list out of ~/.claude/settings.json. Treat it as a floor against accidental reads, not a boundary; for a real one use --clone, where a gitignored .env never enters the VM.
MIT, see LICENSE.