Run Claude Code in an isolated, rootless, daemonless container so it can
work autonomously (--dangerously-skip-permissions) without reaching your real
machine. The only host directory it sees is the one you launch it from; the
only network it can reach is an egress allowlist.
Generic and project-agnostic: cs mounts whatever directory you run it from.
It also understands the OODA per-product worktree layout. Same files work on
macOS (podman applehv) and Windows/WSL2 -- one Containerfile, one
workflow, one mental model.
curl -fsSL https://raw.githubusercontent.com/dynamicalsystem/sandbox/main/install.sh | bashThis clones the repo to ~/.local/share/sandbox and symlinks cs into
~/.local/bin. Needs podman and (the first time) a running machine:
podman machine init && podman machine start # once, if you have no machineOn macOS, install.sh also installs a LaunchAgent that starts the Podman
machine automatically at login. To skip this, run:
SANDBOX_SKIP_PODMAN_LAUNCHAGENT=1 ./install.shFrom a local checkout instead: ./install.sh.
cd ~/some/project # the dir Claude will be able to see
cs # claude --dangerously-skip-permissions, sandboxed
cs --version # args pass straight through to claude
cs kimi # kimi --yolo, sandboxed (Kimi Code CLI)
cs kimi --version # args pass straight through to kimi
cs shell # interactive bash inside the sandbox
cs rebuild # rebuild the image (after a git pull or script edit)First run builds the image (a few minutes). The current directory is mounted at
/work; nothing else of your host is visible. Container is --rm, so it is
gone on exit -- only the project files it changed and your auth persist.
install.sh also drops a cs() shell function into your ~/.zshrc (sourced
from cs.zsh). Give it a project name and it cds into that project's
per-product directory before launching the sandbox:
cs myproj # cd ~/work/myproj/ooda, then sandbox it
# (falls back to ~/work/myproj/main if there is no ooda/)
cs myproj foo # cd ~/work/myproj/foo, then sandbox it
# (if foo doesn't exist, you'll be asked to create it)
cs myproj main # cd ~/work/myproj/main, then sandbox it
cs myproj --worktree foo # explicit form of `cs myproj foo`
cs kimi myproj foo # cd ~/work/myproj/foo, then launch Kimi
cs myproj --resume # cd into the default worktree, pass --resume to claude
cs # no name -> sandbox in $PWD, exactly as before
cs shell / cs rebuild # subcommands still work, run in $PWDFor an OODA product the no-loop form lands in the control plane, so bare
cs myproj and cs myproj ooda are equivalent. Loop-scoped work names its
worktree (cs myproj foo); orientation sessions get the ooda/ worktree by
default.
Override the projects root with CS_PROJECT_ROOT (default ~/work). The
function calls the launcher via command cs, so the bare-cs behaviour above
is unchanged.
If a product is laid out as a directory of worktrees, cs detects it and mounts
the right pieces into the container:
~/work/<product>/
├── main/ # normal clone, main branch
├── ooda/ # control-plane worktree on the orphan ooda branch
├── foo/ # loop foo product worktree
└── bar-fix/ # loop bar-fix product worktree
The container mounts:
- the sandbox worktree (the one you launched from, or the one named by
--worktree) at/work; <product>/main/at its host absolute path, so Git metadata resolves;<product>/ooda/at its host absolute path, so/orientcan read loop docs.
This lets git worktree list inside the container see main, ooda, and the
sandbox worktree, while keeping each container session scoped to a single loop.
If you name a loop worktree that does not exist yet, cs.zsh asks whether to
create it. Confirming adds the Git worktree from main/ and creates a skeleton
ooda/<loop>/README.md, then launches the agent in the new worktree. In
non-interactive contexts (stdin not a tty) the wrapper keeps the old error
behavior so scripts do not hang.
- Rootless + daemonless -- Podman fork-execs the container as your
unprivileged user; no root daemon owns it. "root" inside the container maps to
you on the host, which is what lets the firewall program iptables and keeps
files written to
/workowned by you (not a weird subuid). - Egress allowlist -- the firewall is what makes skipping permissions defensible, not the container boundary alone. Default-deny outbound; only the allowlisted domains are reachable.
- Isolation is from the host, not between agents. Two
cssessions share the podman-machine kernel -- fine, since the threat model is "keep Claude off my laptop", which the VM boundary already covers. Separate kernels would mean separate podman machines; you do not need that here.
Pick one:
-
Subscription OAuth token (recommended): mint a long-lived (~1 year) token once on the host and forward it, exactly like
GH_TOKEN. No volume state, no re-auth, survives--rm:claude setup-token # prints a sk-ant-oat... token (needs a subscription) printf 'CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat...\n' >> ~/.config/dynamicalsystem/sandbox
csforwardsCLAUDE_CODE_OAUTH_TOKENinto the container, so every launch is authenticated with zero web-OAuth roundtrip. (Don't haveclaudeon the host? Runcs shellonce, runclaude setup-tokeninside, copy the token out.) -
API key (simplest, headless):
export ANTHROPIC_API_KEY=...before running;cspasses it through.
Both are forwarded the same stateless way as the GitHub token -- the secret
stays in the host-side file (~/.config/dynamicalsystem/sandbox) and is handed
in as an env var, so nothing auth-related needs to persist in the volume.
Why not just log in interactively? An interactive
claudelogin writes the OAuth account record to/root/.claude.json(at$HOME, outside theclaude-configvolume). Claude Code rewrites that file atomically (rename()), which defeats any symlink-into-the-volume trick, so on a--rmcontainer the account record is dropped and you get the web-OAuth prompt every launch -- even though the credentials file persisted. The forwarded token sidesteps all of this. If you want interactive login to stick instead, mount the volume at/rootrather than/root/.claudeso.claude.jsonlands in it for real.
Kimi Code CLI does not read API keys from shell environment variables directly.
To avoid running /login or /provider add every launch, add a Kimi API key to
the same host-side env file:
printf 'KIMI_API_KEY=sk-kimi-xxx\n' >> ~/.config/dynamicalsystem/sandboxThe entrypoint auto-seeds ~/.kimi-code/config.toml from KIMI_API_KEY on the
first run (or if the config has no provider), so cs kimi starts already
authenticated. Set KIMI_BASE_URL if you use a custom endpoint.
Host-managed Kimi skills and a global AGENTS.md are shared into the container
read-only while auth/config/session state stays in the kimi-config volume:
mkdir -p ~/.kimi-code/skills
ln -s ~/Documents/dynamicalsystem/augment/<github-username>-AGENTS.md ~/.kimi-code/AGENTS.mdcs kimi bind mounts ~/.kimi-code/skills/ to /root/.kimi-code/skills/ and
~/.kimi-code/AGENTS.md to /root/.kimi-code/AGENTS.md when they exist. Set
KIMI_HOST_DIR to override the host path.
The sandbox pushes over HTTPS with a token -- no ssh key is ever exposed to
the agent. Give it a fine-grained PAT scoped to just the repos you want
(Contents: RW, Pull requests: RW), so even a misbehaving agent can't reach
the rest of your account. Put it in an untracked host-side file:
mkdir -p ~/.config/dynamicalsystem
printf 'GH_TOKEN=github_pat_xxx\n' > ~/.config/dynamicalsystem/sandbox
chmod 600 ~/.config/dynamicalsystem/sandboxcs sources that file and forwards GH_TOKEN (and GITHUB_TOKEN); the
entrypoint runs gh auth setup-git, so git push and gh pr create just work.
Git author identity is read from your host git config automatically (override
with GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL in the same file). It is a shell file,
so quote any value with spaces -- GIT_AUTHOR_NAME="Ada Lovelace".
Because the file is sourced, the token can also be minted per run rather
than stored -- point GH_TOKEN at a command:
# ~/.config/dynamicalsystem/sandbox -- mint a short-lived GitHub App
# installation token on the host; the App private key never enters the sandbox.
GH_TOKEN=$(my-app-token-minter)GitHub App installation tokens (POST /app/installations/{id}/access_tokens)
expire in an hour and are repo-scoped, so a leak self-heals -- the only API path
to programmatically renew a credential (personal fine-grained PATs can only be
created/regenerated in the web UI). The sandbox is indifferent: it just consumes
GH_TOKEN. Either way, cs rebuild is needed once to pick up gh in the image.
Two layers, merged at container start:
- Global (
allowed-domains.txtin this repo): project-neutral essentials -- Anthropic, npm, GitHub. - Per-project (
.claude-sandbox/allowed-domains.txtinside a project): extra domains just that project needs.csmounts it automatically when present, so project-specific access never pollutes the global list.
Both are plain domain-per-line files (# comments allowed). Edits apply on the
next cs run -- no rebuild. If something Claude needs hangs on a network call,
that is the allowlist; add the domain and restart.
cd ~/projectA && cs # window 1
cd ~/projectB && cs # window 2Two containers, two fresh firewalls, each pinned to its own mounted dir. They share the machine kernel but cannot see each other's files.
| Var | Default | Effect |
|---|---|---|
CLAUDE_SANDBOX_FIREWALL |
1 |
set 0 to disable the egress allowlist |
CLAUDE_SANDBOX_IMAGE |
claude-sandbox:latest |
image tag |
CLAUDE_SANDBOX_CONFIG_VOLUME |
claude-config |
Claude auth-persistence volume |
CLAUDE_SANDBOX_KIMI_CONFIG_VOLUME |
kimi-config |
Kimi auth-persistence volume (mounted at /root/.kimi-code) |
KIMI_HOST_DIR |
~/.kimi-code |
host path for Kimi skills/AGENTS.md bind mounts |
CS_PROJECT_ROOT |
~/work |
directory that holds per-product directories |
CLAUDE_SANDBOX_WORKDIR |
$PWD |
host dir to mount at /work (fallback for non-OODA projects) |
CLAUDE_SANDBOX_ENGINE |
podman |
container engine to drive |
CLAUDE_SANDBOX_ENV |
~/.config/dynamicalsystem/sandbox |
host file sourced for GH_TOKEN / CLAUDE_CODE_OAUTH_TOKEN / git identity (a directory with an env file inside also works) |
Installer knobs: SANDBOX_HOME, SANDBOX_REPO, PREFIX, SANDBOX_SKIP_PODMAN_LAUNCHAGENT (see install.sh).
Run the same cs from a WSL2 shell (where podman lives). The isolation boundary
there is WSL2's own VM rather than bare Win32, and the host path is the WSL2
filesystem -- the Podman CLI papers over the difference, rootless holds on both.
Keep the project on the WSL2 side (~/..., not /mnt/c/...) for sane file
performance.
- No ipset. The podman-machine kernel ships no
ipset/xt_setmodule, so the firewall uses plain per-IP iptables rules. If you ever lift Anthropic's devcontainer firewall verbatim, it will not work here as-is. - Rebuild after editing baked files.
Containerfile,init-firewall.sh, andentrypoint.share baked into the image;csonly auto-builds when the image is absent, so after editing them (orgit pull) runcs rebuildor you will silently run a stale image. Allowlist edits are mounted -- no rebuild. - Firewall fail-closed. If iptables setup fails partway, egress ends up blocked, not open -- but the entrypoint's warning text may read "NOT restricted". If you see that warning, treat the network state as untrusted and investigate rather than believing either reading.
- Allowlist resolved once at startup. Long sessions can outlive a CDN's DNS; if a previously-working host starts failing, restart the container.