How I sandbox Claude Code in a Docker dev container on WSL so it can edit my code freely but never touch my host machine or read the files it has no business reading.
I let Claude Code, Anthropic's CLI coding agent, run real commands on my research code: a mix of modern Python and the kind of battle-tested Fortran that quietly runs most of computational science. It edits files, runs the build, launches long compute jobs, and chases down failures across a large scientific codebase, autonomously, in self-designed, end-to-end task chunks. That reach is the point. An agent that can only suggest edits is a glorified autocomplete; one that can act is a collaborator.
The same reach is what makes it dangerous. The first time an agent helpfully rm -rf'd a stale build directory, I had the oh moment every developer who's tried this knows: I'd wired a fast, capable process straight into the machine that holds my SSH keys, my credentials, and work I'm not free to leak. "Be careful what you approve" is not a security model.
Here's the whole idea in one paragraph; the rest is the why and the how.
TL;DR. One asymmetric wall: I reach into the box freely from my host; the agent can't reach back out. Claude Code runs in a Docker dev container on WSL, sees only the folders I mount, can't escalate privileges, and can't reach the rest of my host filesystem. Three rule layers (hard: the kernel; semi-hard: the permission engine; soft: a behavioral contract) keep it productive inside while the sensitive files stay out of reach. Clone the starter repo for the baseline box in about ten minutes; edit a couple of paths to fit your project and you're running on it.
It's a familiar fork: approve every prompt until fatigue sets in and you rubber-stamp, or skip the checks and let it run. Anthropic watched their own users hit this fork and built a way around it, auto mode, which hands each call to a classifier instead of asking you. They're candid that it doesn't catch everything.
17% of overeager actions still slip past auto mode. That's Anthropic's own published false-negative rate for the category they call "overeager actions," and even this built-in safety net is, in their words, "not a drop-in replacement for careful human review on high-stakes infrastructure." (Source: "How we built Claude Code auto mode," Anthropic Engineering, published Mar 25, 2026.)
Smarter approvals help; they don't shrink the blast radius when one slips through.
So I built it a box. This is the box.
This isn't a Docker tutorial. It's the threat model, the actual config, and an honest account of where the box still might leak, and why I'm fine with that.
What's off-limits for the agent, and what's served on a platter
Two things I keep the agent away from. One I hand it, no questions asked.
Off-limits: my host (Asset A). Everything outside the project: proprietary and contract-sensitive data, client material, SSH keys and credentials, unrelated repos, the ordinary contents of a working machine. None of it reachable, on purpose or by accident.
Off-limits: the restricted files in the project (Asset B). A handful I compile against but won't let the agent read, copy, or echo into a transcript. The catch: the build needs them present, so I can't just delete them or wall them off. They sit in the tree, compilable, but opaque to the agent.
Served on a platter: the rest of the project (Asset C). The unrestricted source, the working directory, the inputs, the build routines, the outputs and post-processing: full reach to read, edit, build, and run, no questions asked. That freedom is the whole point; a caged agent that asks permission for every file is back to being autocomplete.
The adversary here isn't a hacker; it's an eager collaborator, not malware. It's a capable, probabilistic system taking action after action on my behalf, and now and then it reaches for one I didn't intend: a broad grep, a cat "to understand the build," some tidying. I'm guarding against that overshoot, not someone who already has my password, which is why a layered, fail-safe design is enough and why I won't call it a prison. (Is your threat model genuinely adversarial, with hostile inputs or untrusted code? This design isn't built for that: reach for a VM, and put Asset B as far out of reach as Asset A, not merely behind these layers.)
Why a dev container (and not a VM, the cloud, or just trusting it)
A VM gives the strongest boundary, but it's heavy, slow to boot, and awkward for GPU and large compute jobs. A cloud box moves the risk off my machine, but adds cost and latency, raises data-egress questions for sensitive code, and still leaves me isolating the agent on that box. And raw trust, approving each action by hand, fails the moment I stop watching, which for an unattended run is the whole point.
I'm on Windows, so the Linux side already lives in WSL 2, a lightweight VM with its own kernel and ext4 root, separate from Windows. That buys a little natural separation, but WSL is built for interop, not isolation: it auto-mounts my Windows drives at /mnt/c and exposes its filesystem back to Windows, so an agent loose in WSL can still wander. WSL isn't the competitor here; it's the substrate: it provides the Linux host, and the dev container provides the isolation WSL deliberately doesn't. (Not on Windows? Nothing here is lost on you: WSL is just my host. On macOS or native Linux you skip it and run Docker directly; VS Code with the Dev Containers extension, the box, the layers, and the Claude Code setup are all the same.)
A Docker dev container sits between VM-grade weight and WSL's openness: a real Linux environment with kernel-enforced isolation, up in seconds, disposable, and reproducible from a version-controlled config, with VS Code attaching natively so the agent and I share one IDE. Docker isn't a security tool by design; it's a packaging system. The sandboxing is something I layer on top, not a property I get for free.
That asymmetric wall, concretely: I reach into the box freely from the host, drop files in, pull results out; the agent only ever sees the folders I mount, and can't reach back into the rest of my host. (Two caveats I'll come back to: those folders are real host data, and the network is open. "Can't reach back out" is about the filesystem, not the wire.) That asymmetry is Asset A's whole defense. Asset B is harder, and that's where the layers come in.
The three layers, top to bottom
A read attempt on a restricted file has to get past three gates, hardest at the bottom:
| Layer | Lives in | Enforced by | Can the agent argue past it? |
|---|---|---|---|
| Soft | CLAUDE.md |
the model's own compliance | Yes, in principle; it's a contract, not a wall |
| Semi-hard | settings.json |
Claude Code's permission engine | Mostly no, but it matches on patterns, so edge cases exist |
| Hard |
devcontainer.json / Dockerfile
|
the OS and the kernel | No, these aren't suggestions |
Under all three sits an OS-level file-mode backstop on the single most sensitive file. The layers cover each other's blind spots: the soft layer, the lightest to set up and the first thing the agent actually reads, handles almost everything cleanly, but it's persuasion, not enforcement: a model can ignore it, and sometimes does. The semi-hard layer catches the agent when it reaches for a shortcut, and the hard layer is what's left standing if the other two get talked around.
By here you have the mental model. The rest is making each layer real.
Layer 1: the container walls (devcontainer.json)
This is the hard layer: rules the agent can't argue with, because the kernel enforces them. The whole project lives in ~/claude-docker-sandbox/ on WSL, built from a Dockerfile (the recipe for what's in the box) and a devcontainer.json (how the box is run, where the sandboxing flags live). Here's the security-relevant core:
{
"name": "Claude Sandbox",
"build": { "dockerfile": "../Dockerfile", "context": ".." },
"workspaceFolder": "/workspace",
"mounts": [
"source=${localWorkspaceFolder}/workspace,target=/workspace,type=bind",
"source=${localWorkspaceFolder}/config,target=/home/claudeuser,type=bind"
],
"runArgs": [
"--tmpfs", "/tmp:rw,size=4g,mode=1777,noexec,nosuid,nodev",
"--tmpfs", "/run:rw,size=64m,mode=755,noexec,nosuid,nodev",
"--cap-drop=ALL",
"--security-opt", "no-new-privileges"
],
"remoteUser": "claudeuser",
"containerEnv": { "TMPDIR": "/tmp" }
}
Every line earns its place:
Scoped bind mounts (Asset A's wall). Only two host directories are exposed: workspace/ becomes /workspace, and config/ becomes the container's home. A bind mount is a read-write window into the host, so what you don't mount matters as much as what you do. My home, my keys, my other projects simply aren't in the container's universe. Anything that should be look-but-don't-touch, mount :ro.
Non-root by default, but root is a docker exec away when I want it. remoteUser is claudeuser, an ordinary UID-1000 user, so the agent never runs as root and a slip is bounded by normal file permissions. That bound is on the agent, not on me: when I need to install a system package, I step in from the host with docker exec -u root -it <container> bash. The control stays with me; the agent just doesn't get it.
Drop all capabilities. Docker already runs containers with a restricted capability set, not a privileged one, so this tightens an already-limited default. And because the container starts as claudeuser, the process holds no effective capabilities to begin with, and --cap-drop=ALL makes that explicit and clears the bounding set too. For a non-root container, that one line is the whole story. (If you instead start as root and use an entrypoint to fix ownership and drop privileges, that's the one case where you'd add back a few caps: SETUID/SETGID/CHOWN/FOWNER/DAC_OVERRIDE, and nothing else.)
No privilege escalation after start. --security-opt no-new-privileges means a setuid binary can't be used to climb back to root mid-session. (OWASP Docker Security Cheat Sheet)
Writable scratch as tmpfs. /tmp and /run are in-memory tmpfs, size-bounded so they can't eat host memory, and marked noexec,nosuid,nodev so the agent can't execute code it drops into scratch. A tmpfs filesystem is executable by default, so that noexec is doing real work.
Where my personal in-use image differs. I run
/tmpwithexec, notnoexec. My scientific Python stack builds native wheels and runs conda post-link scripts from/tmp, andnoexecbreaks them, a deliberate loosening for a working toolchain. If your agent doesn't compile native extensions, keepnoexec. The starter repo ships this minimal (noexec) baseline, with notes on adapting it for anexectoolchain like mine.
And one thing deliberately absent: the Docker socket. Plenty of dev containers bind-mount /var/run/docker.sock for convenience. I never do. Mounting it is effectively handing the container root on the host: the socket is the control channel for the Docker daemon, the daemon runs as root, and anything inside can ask it to start a privileged container or mount the host's /. None of the hardening above stops this: non-root, dropped caps, no-new-privileges, even a read-only socket mount all constrain the container, while the request runs against the daemon. If you take one rule from this section, take that one. (OWASP Docker Security Cheat Sheet)
In VS Code you build and attach with Dev Containers: Reopen in Container (it rebuilds from the Dockerfile when it changes). On attach it auto-installs the Claude Code extension (via a customizations block in the repo's full devcontainer.json), which signs in with my Claude subscription, no API key baked into the image. Without VS Code, you build and run the same container by hand, then start Claude Code inside it yourself:
docker build -t claude-sandbox .
docker run --rm -it \
-v "$PWD/workspace:/workspace" -v "$PWD/config:/home/claudeuser" \
--tmpfs /tmp:rw,size=4g,mode=1777,noexec,nosuid,nodev \
--tmpfs /run:rw,size=64m,mode=755,noexec,nosuid,nodev \
--cap-drop=ALL --security-opt no-new-privileges \
--user claudeuser -w /workspace claude-sandbox bash
That's Layer 1: a normal-user container that can't escalate, can't see my host beyond the folders I mount, and can't phone the Docker daemon.
Layer 2: the permission engine (settings.json)
Layer 1 stops the agent leaving the box. It does nothing about Asset B inside the box, where the restricted files have to live so the compiler can reach them. That's Claude Code's own permission engine. It's the semi-hard layer: enforced on every tool call, but it matches command patterns, so its strength depends on how completely you spell out the ways a file can be read.
Below is the shape, abridged. The full list is longer, and generated rather than hand-written; it lives in the starter repo. These rules can sit in your user-level ~/.claude/settings.json (applying everywhere) or in a project's own .claude/settings.json, committed so they travel with that project, as the starter repo does:
{
"permissions": {
"defaultMode": "default",
"disableBypassPermissionsMode": "disable",
"deny": [
"Read(/workspace/project/restricted/**)",
"Edit(/workspace/project/restricted/**)",
"Write(/workspace/project/restricted/**)",
"Read(/workspace/project/params/sensitive_values.conf)",
"Bash(cat /workspace/project/restricted/**)",
"Bash(grep * /workspace/project/restricted/**)",
"Bash(tar * /workspace/project/restricted/**)",
"Bash(python3 * /workspace/project/restricted/**)"
],
"ask": ["Bash(rm *)", "Bash(curl *)", "Bash(wget *)", "Bash(git push *)"],
"allow": ["Bash(make *)", "Bash(cmake *)", "Bash(mpiexec *)"]
}
}
The ideas doing the work:
-
Deny beats allow.
makeis allowed, but a deny on the restricted folder still wins, so the build compiles those files (a childgfortranreads them as me), while the agent's owncatorReadis refused. That single precedence rule is what makes Asset B compilable but unreadable. -
One rule isn't enough; you need tiers. A lone
Read()deny doesn't stopcat,grep,tar, orpython3 read_it.py. The real config layers tool-level denies, then Bash readers, then writers and movers, then archive shapes, then the obvious scripting one-liners. The snippet is abridged. -
No bypass.
disableBypassPermissionsMode: "disable"removes the "skip all permissions" escape hatch. -
Deny the irreversible, ask for the rest.
rm,curl,wget,git pushprompt me; build commands run unattended.
A small hook logs every Bash command the agent runs, so after an unattended session I can replay exactly what executed. Plain Python, no dependencies:
# .claude/hooks/audit.py: wired as a PreToolUse(Bash) hook in settings.json
import datetime, json, os, sys
log = os.path.join(os.environ.get("CLAUDE_PROJECT_DIR", "."), ".audit.log")
try:
d = json.load(sys.stdin)
cmd = d.get("tool_input", {}).get("command", "")
with open(log, "a") as f:
f.write(f"{datetime.datetime.now().isoformat(timespec='seconds')} {cmd}\n")
except Exception:
pass # never let auditing block a tool call
sys.exit(0)
So with Layers 1 and 2: the agent can't leave the box, and inside it the restricted files compile but won't open. Here's that second half actually happening: the agent reaching for a restricted file two ways and getting stopped, while the build reads the same file fine (representative session):
> let me check the solver constants
● Read(/workspace/project/restricted/solver_core.f90)
⎿ Denied by your settings (a deny rule covers /workspace/project/restricted).
● Bash(cat /workspace/project/restricted/solver_core.f90)
⎿ Denied by your settings (a deny rule covers /workspace/project/restricted).
● Bash(make) # the build reads the same file as a child process (allowed)
⎿ gfortran -c project/restricted/solver_core.f90 … done
# /workspace/.audit.log: the Bash commands the hook recorded, after the session
2026-06-19T01:14:10 make
2026-06-19T01:18:33 mpiexec -n 4 ./solver case_01.in
Two records are in play: the live denials are the permission engine blocking the agent, and the audit log is the hook's after-the-fact list of the Bash commands that actually ran. The build reads the file; the agent can't. That's the whole trick, on camera.
Layer 3: the behavioral contract (CLAUDE.md)
The soft layer is a Markdown file the agent reads at the start of every session. It can't enforce anything; it sets intent. In practice it resolves the large majority of cases before the harder layers ever fire, because the agent simply follows it.
## Restricted paths
- /workspace/project/restricted/ (do not read)
- /workspace/project/params/sensitive_values.conf (strict no-read)
## Behavioral rules
1. Do not read, copy, edit, or inspect the paths above. Treat them as opaque.
2. If a task seems to need them, STOP and ask me: name the path and why.
3. Build commands (make, cmake, mpiexec) may compile these files. That is fine.
4. Do not use workarounds (quoted-path scripting, shell-redirection tricks,
symlinks, process substitution) to reach restricted paths. The intent is
opacity; any path around the rules violates that intent even if it passes
the pattern matcher.
Rule 4 is the one that matters, and it's why the soft layer is worth keeping even though it's only persuasion. The permission engine matches patterns, and a clever enough path around a pattern exists. The contract asks the agent to honor the intent of the rules, not just their letter, and to not go looking for the gap. A well-aligned model takes that seriously, which means most of Layer 2's edge cases never get tested.
That's all three layers wired: a wall, a gate, and a contract.
Where it leaks (and why that's the point)
Here's what this box does not do.
-
The permission patterns aren't airtight. Take one real example: a deny on
python3 * /restricted/**does nothing againstpython3 -c "open('/restricted/x').read()": the path lives inside a quoted script string the pattern matcher never looks into. It's not a bug; it's what matching commands by pattern can and can't see. The same gap shows up with shell built-ins (read x < file), file-descriptor tricks, symlinks, and process substitution, a whole class no amount of pattern-writing fully closes, which is why Layer 3 asks the agent not to reach for them and Layer 1 sits underneath. - The container shares my kernel. A dev container is isolation, not a virtual machine, so a kernel-level exploit can cross it. It's a blast-radius reducer, not a VM-grade boundary. (OWASP Docker Security Cheat Sheet)
-
The mounted folders are real host data.
/workspaceand the home are read-write windows onto my disk, not throwaway copies. That's a feature: it's how I get results back out. But it means the box isn't hermetic. -
The network is open. I don't run
--network none; the agent needs package registries and the model API. So this controls what the agent can touch on my machine, not what it can send out. The open wire is also why the Asset B read-deny still earns its keep: an agent "debugging" could otherwise read a restricted file and POST it somewhere. If outbound exfiltration is in your threat model, the next lever is network scoping (--network none, or an egress allowlist), a different control than this one. -
"No API key in the container" is convenience, not security. I authenticate through the IDE extension's browser sign-in, but its token persists in the mounted home, which lands on my host disk, so the box isn't credential-free. (One thing worth knowing for real: if an
ANTHROPIC_API_KEYis set in the environment, Claude Code uses it instead of your subscription, with usage charges, so I keep it unset, not for isolation but to avoid silently billing the wrong account.) - And this hardens what runs inside the box, not the WSL shell I launch it from: that shell has root-equivalent Docker access, so I just never run the agent there.
None of these are in my threat model. Defense in depth doesn't mean no gaps; it means no single gap is fatal. (The next hardening lever, if you want one, is a seccomp or AppArmor profile, out of scope here.)
For the one file I care about most, I tighten the file mode as a backstop:
chmod 600 /workspace/project/params/sensitive_values.conf
But be honest about what it buys: it strips group- and world-readable exposure, nothing more. It's not an OS wall against the agent: in a single-user box the agent runs as claudeuser, the same owner the compiler does, so the file mode can't tell a legitimate gfortran read from a stray cat. The real stop is still Layer 2; chmod 600 is hygiene, not a substitute.
What this buys me, day to day
Mostly, I stop thinking about it. I drop a case into ~/claude-docker-sandbox/workspace, open the folder in VS Code, and let the agent work (editing, fixing build breaks, running jobs unattended) while I'm not watching. My keys and unrelated work aren't reachable. The restricted files compile but never surface in a transcript. And if I want to know what happened while I was away, the audit log has every command. It also means I can run Claude Code in its low-friction modes, auto-approving the routine work, without flinching: if a loose approval ever lets through something I didn't intend, the box is what catches it.
The agent gets full reach over my work, and nothing over my machine. That's the trade I wanted: the keys to my code, not the house.
Takeaways
- State your threat model first. "Protect the host" and "protect a few must-not-read files in a tree you still have to compile" are different problems with different answers. Most of the design falls out of naming them.
-
Layer, don't wall. Hard (
devcontainer.json), semi-hard (settings.json), soft (CLAUDE.md), plus a file-mode backstop. Each covers the one above's blind spots. -
The bind mount is your host's defense; the permission engine is your data's defense. Scope the mount tightly, and remember
denybeatsallow, so a sensitive file can be compilable yet unreadable. - Never mount the Docker socket into an agent's container.
- Be honest about the leaks. Shared kernel, open network, real host data, pattern-based rules. Knowing the limits is what tells you whether this fits your threat model.
The whole setup is in a public, MIT-licensed starter repo, claude-docker-sandbox. Clone it for the baseline box in about ten minutes; point the mounts at your project and swap the example paths in settings.json for your own restricted files to run on it. The README carries a short version of this threat model; if you're setting up Docker Desktop + WSL for the first time, the repo's SETUP.md walks the whole host-side path. This article is the reasoning behind it.
The permission layer itself, the full tiered deny rules and the audit hook, lives in the repo, ready to point at your own paths. What I've left open is the harder question underneath it: how far a behavioral contract can really be trusted to hold. That's a deeper topic, and one I may come back to.
Written by Shobhan Roy, PhD, and drafted and refined with Claude, the same kind of agent this piece is about. Generalized from a working research setup; adapt the paths and dials to yours.



Top comments (0)