Skip to content

Repository files navigation

Docker Sandboxes templates

Reusable Docker Sandboxes templates for running coding agents against a repo inside an isolated microVM: 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.

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 - a schemaVersion: "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.

Prerequisites

Docker Desktop, Apple Silicon, macOS 14+.

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 over

balanced 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.

Build a template

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 end

Per 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.tar

sbx 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 .

Shell helpers

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.

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.

Host Claude config

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, remote-settings.json (the cached policy file, if your host has one). Managed directories 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 in installed_plugins.json and known_marketplaces.json are repointed here; entries that still resolve nowhere are dropped, along with their plugins.
  • Merged: settings.json over sbx's, so model, effortLevel, permissions and theme apply while sbx's apiKeyHelper survives. Every merge restarts from settings.sbx.json, so a key dropped on the host disappears here too.
  • Untouched: .credentials.json and ~/.claude.json stay sbx's.

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.

Store credentials

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 github

A 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.

Run: strict vs. full network

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 myproj

It takes two commands because ** is only valid for sbx policy allow network, so "allow everything" cannot live in a kit's caps.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.
  • --sandbox scopes 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.

Manage sandboxes

sbx ls
sbx exec myproj -- bash -lc 'swift --version'   # run a command without attaching
sbx kit add myproj ./shared/kit                 # re-apply startup commands + files
sbx kit add myproj ./swift/kit                  # one kit per call
sbx policy ls                                   # effective policies, per sandbox
sbx rm myproj

sbx kit add reapplies startup commands and injected files only, not network rules. Allowlists are fixed at sandbox creation, so after editing a kit's caps.network.allow you have to recreate:

sbx rm myproj
sbx run --name myproj -t swift-sbx:1 --kit ./shared/kit --kit ./swift/kit claude

Network policy

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.org and *.swift.org separately.
  • 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 policy

If general web access is the actual requirement, use a --full sandbox.

Local kit

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.yaml

Agent instructions

Each 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.

License

MIT, see LICENSE.

About

Template configurations for Docker Sandboxes running Claude Code

Topics

Resources

Stars

Watchers

Forks

Contributors

Languages