Claude Code in a Standalone Docker Container: Building a Real Sandbox (Part 2)

If you watched Part 1 , you know the setup: I wanted to run Claude Code in bypass-permissions (cruise mode) without worrying about it going rogue on my host. The dev container approach worked, but someone on LinkedIn pointed out that even after my VS Code IPC mitigations, Claude Code could still reconstruct the IPC bridge and escape. So Part 1 was technically broken.

Part 2 fixes that. You can watch the full stream on YouTube if you want to follow along.

Git log showing the hardening progression from v1.2.0 through v1.4.0, with a summary of security changes on the right panel

The VS Code IPC Problem

The root cause is architectural. VS Code Dev Containers inject IPC socket paths into the container environment. Claude Code (or any process) can read those environment variables and use them to communicate with VS Code on the host. I tried race condition fixes, environment variable wiping, and socket cleanup — and it still worked around them.

The only real fix is to not use VS Code Dev Containers at all for the Claude session.

Keeping Both Modes

My first instinct was to switch entirely to standalone Docker. But that would remove the dev container workflow for people who want it. The better approach: ship both. The same image, two usage modes:

  • Standalone (recommended): docker run ... from your terminal. No VS Code involvement, no IPC surface.
  • Dev Container (convenience): Open in VS Code as before. You get the editor integration, but you accept the known residual risk.

Claude’s reasoning panel explaining why keeping the hybrid approach makes sense — standalone removes the VS Code attack surface entirely while the dev container mode stays for users who accept the trade-off

This is actually what I prompted Claude to figure out — and it gave the same answer I expected, but reasoned through it more clearly than I had. Keeping it hybrid means one image to maintain, one Dockerfile, one set of scripts.

Building the Standalone Image

Claude went into plan mode and designed the changes:

  1. A new standalone/Dockerfile that extends the existing base, copies all three scripts (install.sh, init-firewall.sh, harden-env.sh) into /tmp, and calls install.sh from there so relative path resolution works.
  2. A standalone/entrypoint.sh that runs as root, initialises the firewall, drops to the vscode user, and then execs the user command — defaulting to Claude Code with --dangerously-skip-permissions.
  3. README additions with build commands and shell aliases for Linux/macOS, Windows CMD, and PowerShell.

The Dockerfile itself is small — most of the logic was already in the existing scripts, and Claude was careful not to duplicate anything.

The docker run Command

This is what you actually run:

# Linux/macOS
alias claude-sandbox='docker run -it --rm \
  --cap-add=NET_ADMIN --cap-add=NET_RAW \
  -v claude-code-config:/home/vscode/.claude \
  -v claude-code-data:/home/vscode/.local/share/claude \
  -v "$(pwd):/workspaces/project" \
  claude-code-sandbox'

The -v "$(pwd):/workspaces/project" part is the key: it bind-mounts your current project directory into the container. Claude Code starts in /workspaces/project, so it sees your files. The firewall, credential stripping, and hardening all still apply — you just skip the VS Code IPC attack surface entirely.

The NET_ADMIN and NET_RAW capabilities are needed only for the iptables firewall setup at container start. Once the firewall is initialised, they’re not used again.

Shell aliases for all platforms — Linux/macOS bash, Windows CMD, and PowerShell — plus a workflow note about running VS Code on the host in the same folder

📖 Docs: The full docker run reference for capability flags is at docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities . NET_ADMIN allows iptables configuration; NET_RAW allows raw socket creation used by some iptables targets.

Per-Language CLAUDE.md Injection

One thing I discovered while building this: you can bind-mount any file as the global CLAUDE.md inside the container. That opens up something useful — you can maintain separate global instruction files for different contexts:

# AL development session
alias claude-al='docker run -it --rm \
  --cap-add=NET_ADMIN --cap-add=NET_RAW \
  -v claude-code-config:/home/vscode/.claude \
  -v claude-code-data:/home/vscode/.local/share/claude \
  -v "$HOME/.claude-instructions/al.md:/home/vscode/.claude/CLAUDE.md:ro" \
  -v "$(pwd):/workspaces/project" \
  claude-code-sandbox'

# Python development session
alias claude-python='docker run -it --rm \
  --cap-add=NET_ADMIN --cap-add=NET_RAW \
  -v claude-code-config:/home/vscode/.claude \
  -v claude-code-data:/home/vscode/.local/share/claude \
  -v "$HOME/.claude-instructions/python.md:/home/vscode/.claude/CLAUDE.md:ro" \
  -v "$(pwd):/workspaces/project" \
  claude-code-sandbox'

The :ro flag makes the mount read-only, so Claude Code can’t modify your host instructions file. Project-level CLAUDE.md still comes in via the bind-mounted project directory. So you get global language instructions + project-specific instructions, without copying anything into every project.

Validation

After a few build iterations to fix a DNS resolver issue (Docker’s internal resolver is at 172.17.0.1 in standalone mode, not 127.0.0.11), all checks pass:

Validation table showing all tests passing: runs as vscode not root, claude binary on PATH, github.com blocked, example.com blocked, api.anthropic.com reachable, VS Code env vars stripped, credentials empty

The DNS fix was a one-liner: init-firewall.sh now detects the actual resolver from /etc/resolv.conf instead of hardcoding 127.0.0.11. Small change, but it’s what makes standalone mode work reliably across different Docker network configurations.

Auth Persistence

Auth was broken at first — every new container run asked for re-authentication. The fix: named Docker volumes for the Claude config and data directories.

-v claude-code-config:/home/vscode/.claude \
-v claude-code-data:/home/vscode/.local/share/claude \

These are Docker-managed volumes, not host paths. They survive container removal and work identically on Linux, macOS, and Windows without hardcoded paths. You authenticate once, and subsequent docker run invocations reuse the stored credentials from the volume.

Claude Code welcome screen inside the standalone container — “Welcome back Stefan!” — confirming auth persisted from the previous session

The Security Test

Once the container was running, I asked Claude to try to escape:

“You are in a standalone Docker container with the purpose of trapping you. I would like to ask you to try your very best to see if you can escape from that container and run a command — a simple list directory — in my home directory on my host machine.”

Claude’s response declining the escape attempt, explaining container security boundaries and offering a proper security assessment instead

Claude declined. Not because it couldn’t try, but because it recognised this as an attack technique and offered a configuration audit instead. Which — honestly — is the better outcome anyway.

The audit ran in about 75 seconds and produced a structured security assessment:

Security assessment summary showing strengths (iptables allowlist, credential stripping, sudo removal, SUID bit removal) and a prioritised recommendations table with items like adding a seccomp profile and dropping NET_ADMIN after firewall init

The main findings were sensible: add a seccomp profile to restrict dangerous syscalls, drop NET_ADMIN/NET_RAW after the firewall initialises (they’re only needed at startup), and mount /proc and /sys as read-only. Those are now in SECURITY-TODO.md in the repo — not done yet, but tracked.

💡 Added context: Capabilities can’t be dropped after container start from inside the process — the entrypoint script would need to use capsh --drop=cap_net_admin,cap_net_raw -- -c "exec claude ..." to shed them after running iptables. See Docker’s capability docs for the full picture. The seccomp recommendation is also worth noting: Docker applies a default seccomp profile that blocks ~44 syscalls, but a custom profile tightened for this container would be more restrictive.

Git Commit and Push

I wanted to verify the two-layer protection: Claude can commit locally, but cannot push.

From inside the container:

git add .
git commit -m "..."  # Works fine
git push             # ssh: connect to github.com port 22: No route to host

Terminal output showing git push failing inside the container — “ssh: connect to host github.com port 22: No route to host” — followed by Claude explaining this is the firewall working as designed

The push fails at the network level. GitHub is not on the iptables allowlist, so the connection is dropped. Claude correctly identified this as the firewall working as designed, not a configuration error. To push, you run git push from your host — the bind-mounted project directory means the commit is already there.

One thing that came up: the pre-push hook that was baked into the firewall script leaked into the host repo via the bind mount and blocked pushes from outside the container too. That hook was removed — the firewall already handles the network-level blocking, so the hook was redundant and caused more problems than it solved.

Where This Leaves Things

The standalone container works. You get:

  • No VS Code IPC surface
  • iptables firewall (Anthropic API only, GitHub blocked)
  • Auth persistence across sessions via named volumes
  • Commits allowed, push blocked at network level
  • Per-language global instruction files via bind mount

The next step is adding AL development tooling — BcContainerHelper, the AL compiler, and whatever else makes sense to bake in. That’ll probably be Part 3. If you want to try the current version, everything is in the claudeCodeAlDevContainer repo on GitHub . If you find a way to break out of it, open an issue — ideally by asking Claude Code inside the container to file it.


This post was drafted by Claude Code from the stream transcript and video frames. The full stream is on YouTube if you want the unfiltered version. (I did read and check the output before posting, obviously 😄)