Sandboxing Claude Code in a devcontainer
An AI coding agent with shell access can harm the system or my files. Before letting Claude Code run more or less unattended, I wanted a real boundary between it and the rest.
First try: a second OS user
My first version was a dedicated standard user, claude-agent — no admin
group, no sudo, with all the code it could touch in one shared folder. Claude
ran logged in as that user, and the filesystem kept it out of my home directory.
It works, and the files are protected. But the claude-agent user still has access to system package manager, to apps. It might mess its own tooling and the way to fix it is not the easiest one.
Another reason why I started with OS user is that it can re-use tooling with my original user. Good at first look, but if I allow claude-agent update the tooling (and it becomes a requirement fairly fast), my own user’s environment will be polluted. So I started to look for a better solution.
Now: a devcontainer
A devcontainer is the boring, correct answer, and I stopped talking myself out of it. The container is disposable — a bad run is docker rm away.
My .devcontainer/devcontainer.json, trimmed:
{
"build": { "dockerfile": "Dockerfile", "args": {
"USER_UID": "${localEnv:UID}", "USER_GID": "${localEnv:GID}"
}},
"mounts": [
"source=/Users/oleg/Repos,target=/Users/oleg/Repos,type=bind",
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind"
],
"workspaceFolder": "/Users/oleg/Repos",
"features": {
"ghcr.io/devcontainers/features/go:1": {},
"ghcr.io/devcontainers/features/python:1": {},
"ghcr.io/devcontainers/features/node:1": {}
},
"postCreateCommand": "npm install -g @anthropic-ai/claude-code",
"remoteUser": "vscode"
}
The Dockerfile just remaps the container vscode user to my host UID/GID so
bind-mounted files don’t change owner between host and container. Only two things
are mounted in: my ~/Repos tree and ~/.claude (config, hooks, and the shared
secret below). Everything else on the Mac — SSH keys, browser profiles,
~/.aws, the rest of $HOME — simply isn’t in the container’s world.
I run it headless, no VS Code:
npm install -g @devcontainers/cli
devcontainer up --workspace-folder ~/Repos
devcontainer exec --workspace-folder ~/Repos zsh # then: claude
Letting the agent reach back to the host
The catch with a container: the agent loses the small host niceties I’d wired
into hooks — say for spoken status, open for a finished preview, a
notification banner when it needs me. I didn’t want to punch a hole in the
sandbox to get them back, so those live behind a narrow bridge:
.claude/scripts/server.
It’s a ~200-line stdlib HTTP server that runs on the host and exposes a fixed set of named actions:
| action | does |
|---|---|
say |
speak text through the Mac’s say |
notify |
a macOS notification banner |
open |
open a URL, or a path under $HOME / /tmp |
focus |
report the frontmost app + idle time (is my terminal even visible?) |
What keeps it safe:
- Named actions only. The client sends
{"action": "say", "text": "..."}. The server maps that to a hard-codedargvand runs it withshell=False. There’s no field for a binary, a flag, or a shell string — the container can’t ask the host to run anything that isn’t already on the list. - Shared secret. Every request carries a token from
~/.claude/scripts/server/secret(auto-generated,0600, git-ignored). The container reads the same file through the~/.claudemount. - Localhost only. The host binds
127.0.0.1:8787; the container reaches it athost.docker.internal:8787. Nothing is exposed to the network. - Inputs are validated — lengths capped, control characters rejected,
openpaths resolved and confined to an allow-list of roots.
On the Mac it installs as a launchd agent
python3 .claude/scripts/server/server.py
and a hook just calls:
python3 .claude/scripts/server/client.py say "tests are green"
python3 .claude/scripts/server/client.py open http://localhost:4000
The client is fail-soft: if the bridge is unreachable it warns and returns nothing, so a missing host never blocks Claude.