Guide for setting up secured VS Code dev containers for coding agents. Use when creating or hardening a DevContainer to sandbox Claude Code or other coding agents, configuring Docker socket proxies, handling VS Code IPC escape vectors, setting up git worktree support, or verifying security controls. Covers threat model, three-layer defence architecture, Node.js/pnpm setup, and verification testing.
95
95%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Advisory
Suggest reviewing before use
Test security hardening from the host without opening VS Code. This uses @devcontainers/cli to build and start the container, then runs verification checks against it. Think of it as a TDD loop for container security: run tests, fix config, re-run until green.
docker compose version)@devcontainers/cli)Run all checks with the automated test runner:
bash .claude/skills/devcontainer-security/scripts/run-integration-tests.sh /path/to/workspaceThis outputs TAP format — human-readable and machine-parseable. Exit 0 if all pass, exit 1 if any fail.
If you need to run checks manually or debug failures, here's the full workflow.
1. Build and start the container:
npx @devcontainers/cli up --workspace-folder .This outputs JSON. Extract the container ID:
CONTAINER_ID=$(npx @devcontainers/cli up --workspace-folder . 2>&1 \
| grep -oP '"containerId"\s*:\s*"\K[^"]+' | head -1)2. Wait for docker proxy readiness:
The docker-proxy service may take a few seconds to start. Retry until docker ps works:
for i in $(seq 1 15); do
npx @devcontainers/cli exec --workspace-folder . docker ps &>/dev/null && break
echo "Attempt $i — proxy not ready, retrying in 2s..."
sleep 2
done3. Run verification checks:
Use npx @devcontainers/cli exec for standard checks and docker exec -e for env var injection tests (see table below).
4. Clean up:
docker compose -f .devcontainer/docker-compose.yml down --volumes --remove-orphansAlways clean up, even on failure — the test runner script uses trap EXIT to handle this automatically.
| # | Test | Command | Pass |
|---|---|---|---|
| 1 | Hardening script exists | devcontainer exec ... test -f ~/.config/security-harden.sh | exit 0 |
| 2 | .bashrc sources hardening on line 1 | devcontainer exec ... head -1 ~/.bashrc | contains security-harden.sh |
| 3 | Env vars cleared (with injection) | docker exec -e VSCODE_IPC_HOOK_CLI=/tmp/fake <id> bash -lc 'echo "${VSCODE_IPC_HOOK_CLI:-}"' | empty output |
| 4 | Agent vars set to empty | docker exec -e SSH_AUTH_SOCK=/tmp/fake <id> bash -lc 'echo "${SSH_AUTH_SOCK}"' | empty output |
| 5 | No sudo | devcontainer exec ... command -v sudo | exit non-zero |
| 6 | Non-root user | devcontainer exec ... whoami | not root |
| 7 | Capabilities dropped | devcontainer exec ... grep CapEff /proc/self/status | 0000000000000000 |
| 8 | No new privileges | devcontainer exec ... grep NoNewPrivs /proc/self/status | 1 |
| 9 | Docker read access | devcontainer exec ... docker ps | exit 0 |
| 10 | Docker write blocked | devcontainer exec ... docker run alpine echo test | exit non-zero |
| 11 | SSH agent unavailable | devcontainer exec ... bash -c 'test -z "${SSH_AUTH_SOCK}" -o ! -S "${SSH_AUTH_SOCK:-/x}"' | exit 0 |
Where devcontainer exec ... is shorthand for npx @devcontainers/cli exec --workspace-folder . and <id> is the container ID from step 1.
Tests 3 and 4 use docker exec -e to inject fake values simulating what VS Code does at runtime, then verify the hardening script clears them via bash -lc (login shell). This is much stronger than checking variables are absent in a bare container — that trivially passes without VS Code.
The pattern:
# Inject VSCODE_IPC_HOOK_CLI as VS Code would, then check it's cleared
docker exec -e VSCODE_IPC_HOOK_CLI=/tmp/fake "$CONTAINER_ID" \
bash -lc 'echo "${VSCODE_IPC_HOOK_CLI:-}"'
# Expected: empty output (hardening script unsets it)bash -lc triggers the login shell path: ~/.profile → ~/.bashrc → hardening script on line 1. If the hardening script works correctly, the injected value is cleared before the echo runs.
Some security controls can only be verified inside a full VS Code session:
vscode-ipc-*.sock, vscode-git-*.sock); the CLI doesn't, so the postStartCommand and background cleanup loop can't be tested this wayBROWSER and VSCODE_IPC_HOOK_CLI when spawning processes; the CLI doesn't replicate this behaviour (tests 3–4 simulate it with docker exec -e)postStartCommand execution — the CLI runs onCreateCommand and updateContentCommand but postStartCommand and postAttachCommand timing differs from VS CodeFor these, use the in-container verification script after opening the devcontainer in VS Code (see next section).
Run the comprehensive check:
bash .claude/skills/devcontainer-security/scripts/verify-hardening.shThis checks:
sudo is unavailablecap_drop: ALL)security_opt: no-new-privileges:true)git push is blockeddocker run / docker exec are blockeddocker ps, git log) still work# All of these should return empty
echo "VSCODE_IPC_HOOK_CLI: '$VSCODE_IPC_HOOK_CLI'"
echo "BROWSER: '$BROWSER'"
echo "GIT_ASKPASS: '$GIT_ASKPASS'"
echo "VSCODE_GIT_IPC_HANDLE: '$VSCODE_GIT_IPC_HANDLE'"
echo "REMOTE_CONTAINERS_IPC: '$REMOTE_CONTAINERS_IPC'"
echo "SSH_AUTH_SOCK: '$SSH_AUTH_SOCK'"# Should return nothing (or only non-escape-vector sockets)
find /tmp -maxdepth 2 -name '*.sock' 2>/dev/nullAcceptable sockets (not escape vectors):
biome-socket-* — Biome linterEscape vector sockets that should NOT exist:
vscode-ssh-auth-*.sock — deleted by postStartCommandvscode-remote-containers-ipc-*.sock — deleted by postStartCommandvscode-ipc-*.sock — deleted by background cleanup loop in Docker Compose command (10 passes at 30s intervals, ~5 min); may exist briefly after startupvscode-git-*.sock — deleted by background cleanup loop in Docker Compose command (10 passes at 30s intervals, ~5 min); may exist briefly after startup# All capabilities should be dropped (CapEff all zeros)
grep CapEff /proc/self/status
# Expected: CapEff: 0000000000000000
# No-new-privileges should be enforced
grep NoNewPrivs /proc/self/status
# Expected: NoNewPrivs: 1# Should work (read-only)
docker ps
# Should fail with 403
docker run alpine echo "test"
# Should fail with 403
docker exec $(docker ps -q | head -1) echo "test"# Should fail with SSH error
git push 2>&1 | head -5# Should fail with "command not found"
sudo whoamiThe verification script uses colour-coded output:
"vscode-ipc-*.sock sockets exist": If the container started within the last 5 minutes, the background cleanup loop in Docker Compose command may still be running (10 passes at 30s intervals, ~5 min total). Wait a few minutes and re-check — the sockets should be permanently deleted. If they persist after 5 minutes, check the command in docker-compose.yml.
"REMOTE_CONTAINERS env var still set": VS Code may re-inject some variables. Check whether the hardening script is being sourced correctly:
# This should show the source line
head -1 ~/.bashrc
# Expected: source ~/.config/security-harden.sh 2>/dev/null || trueAfter modifying the Dockerfile or devcontainer.json, rebuild and verify: