Claude Code - Experimenting With Dev Containers and Permission Allowlists
Updated 2026-07-11: reworked around workspace trust, which was the real reason my allowlist wasn’t taking effect.
I run Claude Code, and its VS Code extension, inside dev containers. The reason is isolation. Editor extensions and the toolchains a project pulls in have been a real supply-chain vector lately, and a dev container keeps a compromised one off my host: the extension, the agent, and everything npm or uv installs all live inside the container, not on my machine.
That isolation changes how I think about permissions. Because the blast radius is the container and not my laptop, I’m comfortable letting the agent do far more inside it than I would on bare metal.
The container is a small file. The Claude Code dev container feature installs the CLI and the extension into the container, and I run as a non-root user. I also mount a named volume at ~/.claude so plugins and login state persist across rebuilds:
{
"name": "project",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"mounts": [
"source=claude-code-shared,target=/home/vscode/.claude,type=volume"
],
"remoteUser": "vscode",
"postCreateCommand": "bash .devcontainer/post-create.sh"
}
That volume isn’t the whole story. Claude Code keeps your login in ~/.claude.json, a $HOME file that sits outside the mounted .claude directory, so a rebuild wipes it and forces a re-login. I fixed that a while back by symlinking the file into the volume:
if [ ! -L "$HOME/.claude.json" ]; then
[ -f "$HOME/.claude.json" ] && mv -f "$HOME/.claude.json" "$HOME/.claude/.claude.json"
ln -sf "$HOME/.claude/.claude.json" "$HOME/.claude.json"
fi
The same file holds your per-repo workspace trust, and that turned out to matter more than the login. Claude Code won’t apply a repo’s committed allow rules until you accept its workspace-trust dialog (docs); until then it reads the rules and ignores them, so an allowlisted make test still prompts. Since trust lives in the same ~/.claude.json, the symlink already persists it, so I pre-accept it for the repo and skip the dialog. Do it right after the symlink so the write lands in the volume, and key on the exact git repo root, since trusting a parent like /workspaces doesn’t count (anthropics/claude-code#72896):
REPO_ROOT="$(git -C "$PWD" rev-parse --show-toplevel)"
node -e '
const fs = require("fs"), f = process.env.HOME + "/.claude.json", p = process.argv[1];
const j = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8") || "{}") : {};
(j.projects ||= {})[p] ||= {};
j.projects[p].hasTrustDialogAccepted = true;
fs.writeFileSync(f, JSON.stringify(j, null, 2));
' "$REPO_ROOT"
With trust settled, the committed allowlist takes effect. I commit a wide allowlist to .claude/settings.json and turn on acceptEdits so routine file writes don’t prompt either:
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Bash(make test:*)",
"Bash(make lint:*)",
"Bash(uv run pytest:*)",
"Bash(npm run build:*)",
"Bash(tail:*)",
"Bash(head:*)",
"Bash(grep:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
],
"deny": ["Read(.env)", "Read(**/.env)"]
}
}
It goes in .claude/settings.json, not .claude/settings.local.json, on purpose. When you click “yes, don’t ask again”, Claude Code writes the rule to the local file, and that file gets wiped on rebuild too. Commit the allowlist and it survives rebuilds and travels with the repo. tail, head, and grep are in there because Claude appends | tail or | head to a lot of commands to keep output short, and Claude Code matches each side of a pipe on its own, so the pipe target needs its own rule.
The container isn’t a free-for-all, so I keep two guardrails inside it. It still holds my code and a token, so I don’t blanket-allow uv run or npm install: uv run runs whatever follows it, and npm install <pkg> runs package install scripts, which is how a lot of npm supply-chain attacks land. I pin the inner command (uv run pytest) and leave the runners prompting. I also keep the container’s GitHub token read-only and push from the host, so a bad suggestion can’t push or open a PR on its own.
A committed allowlist still won’t cover every command, and a new one puts you back at a prompt. The option that fits the VS Code extension is auto mode, which approves routine commands with a classifier instead of a static list. The built-in Bash sandbox is the other documented path, isolating commands at the OS level, but it’s CLI-only today (anthropics/claude-code#64061), so it doesn’t reach the extension.
None of this is a hard sandbox on its own. Permission rules are enforced by Claude Code, not the OS. I’ve always been more worried about approving the one thing I shouldn’t than about the prompts themselves, and I’m still working out how to shrink the blast radius further. This is early, and I expect parts of it to look risky in hindsight.