| name | dunnlab-devcontainer |
| description | Add a .devcontainer configuration to a project for secure, reproducible Claude Code development environments. Use when setting up devcontainers, Docker-based dev environments, or configuring container-based workflows.
|
Devcontainer Setup
Add a .devcontainer/ directory to the current project with a production-ready configuration for Claude Code. Based on the official Claude Code devcontainer and reference implementation.
Firewall modes
The skill supports two firewall modes for the init-firewall.sh script:
locked-down (default) — Default-deny outbound policy with an allowlist for GitHub, npm, Anthropic APIs, conda, PyPI, and a few VS Code endpoints. Use for untrusted code or when egress should be restricted.
open — Flushes all rules and sets default-accept policies. The container can reach any host the host machine can. Use only for trusted projects where the firewall is more friction than protection (e.g., projects that need to reach many third-party APIs, package mirrors, or internal services).
Ask the user which mode they want if they have not specified. If the skill is invoked with the argument open, scaffold the open variant; otherwise default to locked-down.
Both modes still require NET_ADMIN and NET_RAW capabilities and run the same postStartCommand — only the contents of init-firewall.sh differ.
What to create
Create three files in .devcontainer/ at the project root:
1. .devcontainer/devcontainer.json
{
"name": "Claude Code Sandbox",
"build": {
"dockerfile": "Dockerfile",
"args": {
"TZ": "${localEnv:TZ:America/Los_Angeles}",
"CLAUDE_CODE_VERSION": "latest",
"GIT_DELTA_VERSION": "0.18.2",
"ZSH_IN_DOCKER_VERSION": "1.2.0"
}
},
"runArgs": [
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW"
],
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"eamodio.gitlens"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "bash",
"icon": "terminal-bash"
},
"zsh": {
"path": "zsh"
}
}
}
}
},
"remoteUser": "node",
"mounts": [
"source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume",
"source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume"
],
"containerEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
"POWERLEVEL9K_DISABLE_GITSTATUS": "true"
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
"waitFor": "postStartCommand"
}
2. .devcontainer/Dockerfile
FROM node:20
ARG TZ
ENV TZ="$TZ"
# Install basic development tools and iptables/ipset
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
procps \
sudo \
fzf \
zsh \
man-db \
unzip \
gnupg2 \
gh \
iptables \
ipset \
iproute2 \
dnsutils \
aggregate \
jq \
nano \
vim \
curl \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
ARG USERNAME=node
# Persist bash history.
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
&& mkdir /commandhistory \
&& touch /commandhistory/.bash_history \
&& chown -R $USERNAME /commandhistory
# Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true
# Create workspace, config, and npm global directories and set permissions
RUN mkdir -p /workspace /home/node/.claude /usr/local/share/npm-global && \
chown -R node:node /workspace /home/node/.claude /usr/local/share/npm-global
WORKDIR /workspace
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
sudo dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
# Install Miniconda
RUN ARCH=$(uname -m) && \
wget -q "https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-${ARCH}.sh" -O /tmp/miniconda.sh && \
bash /tmp/miniconda.sh -b -p /opt/conda && \
rm /tmp/miniconda.sh && \
/opt/conda/bin/conda clean -afy && \
chown -R node:node /opt/conda
ENV PATH="/opt/conda/bin:$PATH"
# Set up non-root user
USER node
# Initialize conda for zsh and bash
RUN conda init zsh && conda init bash
# Set the default shell to zsh rather than sh
ENV SHELL=/bin/zsh
# Set the default editor and visual
ENV EDITOR=nano
ENV VISUAL=nano
# Default powerline10k theme
ARG ZSH_IN_DOCKER_VERSION=1.2.0
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
-p git \
-p fzf \
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
-x
# Install global packages
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Install Claude Code
ARG CLAUDE_CODE_VERSION=latest
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# Copy and set up firewall script
COPY init-firewall.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/init-firewall.sh && \
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
chmod 0440 /etc/sudoers.d/node-firewall
USER node
3. .devcontainer/init-firewall.sh
Choose one of the two variants below based on the firewall mode (see "Firewall modes" above). Write only the chosen variant to disk.
Locked-down variant (default)
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
iptables -F
iptables -X
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
ipset destroy allowed-domains 2>/dev/null || true
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "Restoring Docker DNS rules..."
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
else
echo "No Docker DNS rules to restore"
fi
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A INPUT -p udp --sport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
ipset create allowed-domains hash:net
echo "Fetching GitHub IP ranges..."
gh_ranges=$(curl -s https://api.github.com/meta)
if [ -z "$gh_ranges" ]; then
echo "ERROR: Failed to fetch GitHub IP ranges"
exit 1
fi
if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
echo "ERROR: GitHub API response missing required fields"
exit 1
fi
echo "Processing GitHub IPs..."
while read -r cidr; do
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
exit 1
fi
echo "Adding GitHub range $cidr"
ipset add allowed-domains "$cidr" -exist
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
for domain in \
"registry.npmjs.org" \
"api.anthropic.com" \
"claude.ai" \
"sentry.io" \
"statsig.anthropic.com" \
"statsig.com" \
"marketplace.visualstudio.com" \
"vscode.blob.core.windows.net" \
"update.code.visualstudio.com" \
"repo.anaconda.com" \
"conda.anaconda.org" \
"pypi.org" \
"files.pythonhosted.org"; do
echo "Resolving $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
echo "ERROR: Failed to resolve $domain"
exit 1
fi
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "ERROR: Invalid IP from DNS for $domain: $ip"
exit 1
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip" -exist
done < <(echo "$ips")
done
HOST_IP=$(ip route | grep default | cut -d" " -f3)
if [ -z "$HOST_IP" ]; then
echo "ERROR: Failed to detect host IP"
exit 1
fi
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
echo "Host network detected as: $HOST_NETWORK"
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall configuration complete"
echo "Verifying firewall rules..."
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
exit 1
else
echo "Firewall verification passed - unable to reach https://example.com as expected"
fi
if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
exit 1
else
echo "Firewall verification passed - able to reach https://api.github.com as expected"
fi
Open variant (allow all)
Use this when the skill is invoked with open. It clears any existing rules and sets default-accept policies so the container has unrestricted outbound access.
#!/bin/bash
set -euo pipefail
iptables -P INPUT ACCEPT
iptables -P FORWARD ACCEPT
iptables -P OUTPUT ACCEPT
iptables -F
iptables -X
echo "Firewall: allow-all mode"
Test mode
When this skill is invoked with the argument test, create a minimal test project to validate the devcontainer configuration and the user's Docker setup. Do NOT apply this to the current project — instead create an isolated test folder.
Steps
- Create a temporary test directory at
/tmp/dunnlab-devcontainer-test/.
- Scaffold the three
.devcontainer/ files (devcontainer.json, Dockerfile, init-firewall.sh) exactly as specified above into that directory.
IMPORTANT: When writing init-firewall.sh, ensure awk $ variables (e.g., $4, $5) are preserved literally. The awk command awk '$4 == "A" {print $5}' must not have its $4/$5 stripped or interpolated. Verify the written file contains these variables after writing.
- Initialize a git repo (
git init) — required for the devcontainer to work.
- Create a
README.md with the manual validation checklist (see below).
- Build the Docker image:
docker build -t dunnlab-devcontainer-test /tmp/dunnlab-devcontainer-test/.devcontainer
If docker is not available, stop and inform the user they need Docker installed.
- Run the automated validation checks (see table below). Report a pass/fail summary table.
- Inform the user about manual-only checks (firewall, network) that require the full devcontainer.
- Clean up: inform the user they can remove the test directory with
rm -rf /tmp/dunnlab-devcontainer-test and the image with docker rmi dunnlab-devcontainer-test when done.
Automated validation checks
After a successful build, run these checks with docker run --rm and report pass/fail for each:
| Check | Command | Pass condition |
|---|
| Claude Code installed | docker run --rm dunnlab-devcontainer-test claude --version | Exit code 0, output shows version |
| Shell is zsh | docker run --rm dunnlab-devcontainer-test bash -c 'echo $SHELL' | Output is /bin/zsh |
| Node.js available | docker run --rm dunnlab-devcontainer-test node --version | Output starts with v20 |
| Git delta installed | docker run --rm dunnlab-devcontainer-test delta --version | Exit code 0 |
| Workspace writable | docker run --rm dunnlab-devcontainer-test bash -c 'touch /workspace/t && rm /workspace/t && echo OK' | Output is OK |
| Conda installed | docker run --rm dunnlab-devcontainer-test conda --version | Exit code 0, output shows version |
| Python available | docker run --rm dunnlab-devcontainer-test python --version | Exit code 0, output shows version |
| Firewall script present | docker run --rm dunnlab-devcontainer-test bash -c 'test -x /usr/local/bin/init-firewall.sh && echo OK' | Output is OK |
If any check fails, include the full command output to aid debugging.
Manual-only checks (require full devcontainer)
The following checks require NET_ADMIN/NET_RAW capabilities and the postStartCommand to run, so they can only be validated by opening the test project in VS Code with Dev Containers: Reopen in Container:
- Firewall active:
sudo iptables -L -n | head -20 — should show DROP policies and allowed-domains rules
- Allowed traffic:
curl -s https://api.github.com/zen — should return a GitHub zen phrase
- Blocked traffic:
curl --connect-timeout 5 https://example.com — should fail with connection refused
README.md content for the test project
Write a README.md with this content:
# Devcontainer Test
Temporary project to validate the dunnlab-devcontainer configuration.
Created by `/dunnlab-devcontainer test`. Safe to delete after validation.
## Automated checks (already run)
The following were validated during `docker build` + `docker run`:
- Claude Code installed
- Shell is zsh
- Node.js v20.x available
- Git delta installed
- Workspace writable by node user
- Firewall script present and executable
## Manual checks (require full devcontainer)
Open this folder in VS Code and run **Dev Containers: Reopen in Container**.
Once the container starts, open a terminal and run these checks:
### 1. Firewall is active (postStartCommand ran)
sudo iptables -L -n | head -20
# Should show DROP policies and allowed-domains rules
### 2. Allowed traffic works
curl -s https://api.github.com/zen
# Should return a GitHub zen phrase
curl -s https://api.anthropic.com/ -o /dev/null -w "%{http_code}"
# Should return a status code (not a connection error)
### 3. Blocked traffic is rejected
curl --connect-timeout 5 https://example.com
# Should fail with "Connection refused" or similar
## Cleanup
rm -rf /tmp/dunnlab-devcontainer-test
docker rmi dunnlab-devcontainer-test
Customization guidance
When adding these files to a project, adapt as needed:
Important notes
- The locked-down firewall's default-deny policy means
--dangerously-skip-permissions can be used more safely inside the container, since network access is restricted to whitelisted domains only. The open variant does not provide that protection — treat an open-firewall container the same as running Claude Code on the host for trust purposes.
- The
NET_ADMIN and NET_RAW capabilities are required for either firewall variant to function.
- Docker must be installed on the host machine. Docker Desktop works on macOS and Windows.
- Shell history and Claude configuration persist across container restarts via named volumes.
- Only use devcontainers with trusted repositories — the container does not prevent exfiltration of anything accessible inside it, including Claude Code credentials.