Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Controlled Autonomy: Setting Up Secure AI Coding Agents for Your Development Team

A practical guide to sandboxing Claude Code with DevContainers -- standardized environments, scoped permissions, and reproducible configuration for team workflows.


Table of Contents


Quick Start

For those who want to get running first and read later:

# 1. Create the devcontainer in your repo
mkdir -p .devcontainer
# Add Dockerfile, devcontainer.json, init-firewall.sh (see Step-by-Step Setup)

# 2. Add Claude Code config
mkdir -p .claude/skills .claude/rules
# Add .claude/settings.json (permissions)
# Add .mcp.json (MCP servers)
# Add CLAUDE.md (project instructions)

# 3. Open in VS Code
code .
# Cmd+Shift+P -> "Dev Containers: Reopen in Container"

# 4. Authenticate MCP servers
# Type /mcp, select Figma, click Authenticate
# Type /mcp, select Supabase, click Authenticate

# 5. Start coding
claude --dangerously-skip-permissions

Or add Claude Code to any existing devcontainer without a custom Dockerfile:

{
  "features": {
    "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}
  }
}

The rest of this guide explains why each piece exists and how to customize it.


Prerequisites


Why This Guide Exists

AI coding agents are force multipliers. They refactor, generate tests, wire up boilerplate, and handle the mechanical work that consumes a disproportionate share of engineering hours. But every capability they offer requires a corresponding permission: file system access, command execution, network calls, access to environment variables. For an individual developer on a personal project, the tradeoff is straightforward. For a team shipping production code -- with shared credentials, deployment pipelines, and compliance requirements -- it demands architecture.

The default setup has two failure modes:

  1. Too open -- The agent operates with the same access as the developer. It can write anywhere, execute arbitrary commands, and reach any network endpoint.
  2. Too locked down -- Every command requires manual approval. The overhead negates the productivity gain. Developers disable protections out of friction.

Treating AI agent setup as an infrastructure problem -- not an individual preference -- changes the outcome. This guide covers how to set up a standardized, sandboxed environment for AI coding agents across a development team. We use Claude Code and DevContainers as the reference implementation, but the principles apply broadly: isolate the execution environment, enforce a default-deny network policy, scope file system access to the project boundary, and version the entire configuration so every engineer gets the same guardrails on clone.


The Architecture: Wide Read, Narrow Write

Claude Code should have maximum context (read) with minimum blast radius (write).

 INPUTS (read-only)                              OUTPUT (read-write)
 ================                                ==================

 +------------------+
 | MCP: Figma       |---+
 | (design tokens)  |   |
 +------------------+   |
                        |
 +------------------+   |
 | MCP: Supabase    |---+
 | (read_only=true) |   |
 +------------------+   |     +------------------------+
                        +---->|                        |     +------------------+
 +------------------+   |     |     Claude Code        |---->| /workspace       |
 | MCP: Context7    |---+     |     (inside container) |     | (your project)   |
 | (library docs)   |   |     |                        |     +------------------+
 +------------------+   +---->|                        |        ONLY writable
                        |     +------------------------+        persistent path
 +------------------+   |              |
 | MCP: shadcn      |---+              |
 | (component reg.) |   |              |
 +------------------+   |         BLOCKED:
                        |         - Host filesystem
 +------------------+   |         - Other repos
 | CLI: git, npm,   |---+         - Home directory
 | npx, node, pnpm  |            - Unwhitelisted network
 +------------------+

 +------------------+
 | MCP: Playwright  |--+
 | (browser testing)|
 +------------------+

This is enforced at four layers:

Layer What it does Enforced by
1. Container isolation Separate filesystem and process space from host Docker
2. Network firewall Default-deny outbound; only whitelisted domains iptables (init-firewall.sh)
3. Filesystem mounts Project dir = read-write; everything else absent Docker bind mounts
4. Claude Code permissions Tool-level allow/deny rules + OS sandbox .claude/settings.json

No single layer is sufficient. Together, they create defense in depth.


Why Inside the Repo

The devcontainer configuration (.devcontainer/) belongs inside your repository. This is not a personal preference -- it's the only approach that works for teams.

Factor Inside repo Outside repo
Team onboarding Clone + "Reopen in Container" -- done Every dev sets up their own environment
Consistency Version-controlled; everyone runs the same thing Diverges silently across machines
GitHub Codespaces Required -- Codespaces reads .devcontainer/ from the repo Not supported
CI/CD reuse Same container definition runs in CI CI can't access dev-local configs
Code review Container changes go through PR review No visibility into what changed
Config evolution Add a dependency? Update Dockerfile in the same PR Hope everyone remembers to update locally

The one legitimate reason to go outside: you don't own the repo (open-source contribution, vendor code). For everything else, commit it.

What about personal preferences?

Shell themes, editor keybindings, font size -- these are not devcontainer concerns. Use a dotfiles repo and VS Code Settings Sync. The devcontainer defines the team standard; your dotfiles define your taste.


Project Structure

Here's the complete file tree for a Next.js project with Claude Code devcontainer:

your-nextjs-app/
  .devcontainer/
    devcontainer.json          # Container config
    Dockerfile                 # Image definition + CLIs
    init-firewall.sh           # Network whitelist

  .claude/
    settings.json              # Permission rules (committed, team-shared)
    settings.local.json        # Personal overrides (NOT committed -- see .gitignore)
    skills/
      generate-page/
        SKILL.md               # Custom slash command: /generate-page
    agents/
      frontend-reviewer.md     # Custom subagent
    rules/
      code-style.md            # Always-loaded instructions
      api-patterns.md          # Loaded when touching src/api/**

  .mcp.json                    # MCP servers (committed, team-shared)
  CLAUDE.md                    # Project instructions for Claude

  src/                         # Your Next.js app
  package.json
  next.config.ts
  ...

Step-by-Step Setup

Step 1: The Dockerfile

Install your stack's CLIs here. This is where you answer "how do I add a CLI?"

# .devcontainer/Dockerfile
FROM node:20

# System tools
RUN apt-get update && apt-get install -y --no-install-recommends \
    git sudo zsh fzf jq vim curl unzip gnupg2 gh \
    iptables ipset iproute2 dnsutils aggregate \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

# Workspace
RUN mkdir -p /workspace /home/node/.claude \
    && chown -R node:node /workspace /home/node/.claude

# Shell history persistence
RUN mkdir /commandhistory && touch /commandhistory/.bash_history \
    && chown -R node /commandhistory

ENV DEVCONTAINER=true
WORKDIR /workspace

USER node
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
ENV SHELL=/bin/zsh

# ============================================
# ADD YOUR CLIs HERE
# ============================================

# Claude Code
ARG CLAUDE_CODE_VERSION=latest
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}

# Supabase CLI (skip if using Convex)
RUN npm install -g supabase

# Convex CLI (skip if using Supabase)
RUN npm install -g convex

# pnpm (if your project uses it)
RUN npm install -g pnpm

# shadcn does NOT need a global install -- use: pnpm dlx shadcn@latest
# Playwright does NOT need a global install -- used via MCP or npx

# ============================================
# Firewall setup
# ============================================
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

How to add a CLI: Add a RUN npm install -g <package> line in the marked section. Rebuild the container. That's it. If a CLI doesn't need a global install (like shadcn), use npx or pnpm dlx at runtime instead.

Step 2: The Container Config

// .devcontainer/devcontainer.json
{
  "name": "Next.js + Claude Code",
  "build": {
    "dockerfile": "Dockerfile",
    "args": {
      "CLAUDE_CODE_VERSION": "latest"
    }
  },
  "runArgs": [
    "--cap-drop=ALL",
    "--cap-add=NET_ADMIN",
    "--cap-add=NET_RAW",
    "--security-opt=no-new-privileges:true"
  ],

  // YOUR PROJECT: the only writable persistent path
  "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
  "workspaceFolder": "/workspace",

  "mounts": [
    // Claude state -- survives container rebuilds
    "source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
    // Shell history -- survives container rebuilds
    "source=claude-history-${devcontainerId},target=/commandhistory,type=volume",
    // Git identity -- read-only from host
    "source=${localEnv:HOME}/.gitconfig,target=/home/node/.gitconfig,type=bind,readonly"
  ],

  "remoteUser": "node",
  "postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
  "waitFor": "postStartCommand",
  "postCreateCommand": "npm install",

  "forwardPorts": [3000],
  "portsAttributes": {
    "3000": { "label": "Next.js Dev", "onAutoForward": "notify" }
  },

  "containerEnv": {
    "NODE_OPTIONS": "--max-old-space-size=4096",
    "CLAUDE_CONFIG_DIR": "/home/node/.claude",
    "NEXT_TELEMETRY_DISABLED": "1",
    // Neutralize VS Code IPC sockets that agents could exploit to escape the sandbox
    "VSCODE_IPC_HOOK_CLI": "",
    "VSCODE_GIT_IPC_HANDLE": "",
    "GIT_ASKPASS": ""
  },

  // Pass secrets from host env vars (never hardcode them)
  "remoteEnv": {
    "SUPABASE_URL": "${localEnv:SUPABASE_URL}",
    "SUPABASE_ANON_KEY": "${localEnv:SUPABASE_ANON_KEY}"
  },

  "customizations": {
    "vscode": {
      "extensions": [
        "anthropic.claude-code",
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "bradlc.vscode-tailwindcss",
        "eamodio.gitlens"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "terminal.integrated.defaultProfile.linux": "zsh"
      }
    }
  }
}
Mount Type Why
/workspace bind, read-write Your project. The only place Claude writes. Visible on host instantly.
~/.gitconfig bind, read-only Claude commits with your identity but can't modify your git config.
~/.claude named volume Persists Claude state between rebuilds. Not on your host.
/commandhistory named volume Shell history survives restarts. Disposable.
~/.ssh not mounted SSH keys are deliberately excluded. The agent cannot git push to remotes that require SSH auth. If your workflow requires pushing, mount the key read-only and add a deny rule for Bash(git push *) in .claude/settings.json to keep the decision explicit.
Everything else not mounted The container simply cannot see your host home, other repos, or system files.

Step 3: The Firewall

Important: Every MCP server or external service you add in later steps needs its domain whitelisted here. If an MCP server can't connect, check this file first.

Customize the domain whitelist for your stack:

#!/bin/bash
# .devcontainer/init-firewall.sh

set -euo pipefail

# Preserve Docker DNS
DOCKER_DNS_RULES=$(iptables-save 2>/dev/null | grep "127.0.0.11" || true)

# Flush existing rules
iptables -F
iptables -X
ipset destroy allowed-domains 2>/dev/null || true

# Restore Docker DNS
if [ -n "$DOCKER_DNS_RULES" ]; then
  echo "$DOCKER_DNS_RULES" | iptables-restore --noflush
fi

# Allow basics
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# Create IP set for allowed domains
ipset create allowed-domains hash:net

# ============================================
# WHITELIST YOUR DOMAINS HERE
# ============================================

whitelist() {
  for ip in $(dig +short "$1" | grep -E '^[0-9]'); do
    ipset add allowed-domains "$ip/32" 2>/dev/null || true
  done
}

# Claude Code (required)
whitelist api.anthropic.com
whitelist statsig.anthropic.com
whitelist sentry.io

# Package registries
whitelist registry.npmjs.org
whitelist registry.yarnpkg.com

# GitHub
for cidr in $(curl -s https://api.github.com/meta | jq -r '.web[], .api[], .git[]' 2>/dev/null); do
  ipset add allowed-domains "$cidr" 2>/dev/null || true
done

# Supabase (skip if using Convex)
whitelist api.supabase.com
whitelist mcp.supabase.com
# Add your project's Supabase URL:
# whitelist <your-project>.supabase.co

# Convex (skip if using Supabase)
whitelist api.convex.dev

# Figma MCP
whitelist mcp.figma.com
whitelist api.figma.com

# VS Code extensions
whitelist marketplace.visualstudio.com
whitelist vscode.blob.core.windows.net

# Vercel (if deploying)
# whitelist api.vercel.com
# whitelist vercel.com

# Context7 (library docs)
whitelist mcp.context7.com

# ============================================

# Allow host network (Docker host access)
HOST_IP=$(ip route | grep default | awk '{print $3}')
ipset add allowed-domains "$HOST_IP/32" 2>/dev/null || true

# Default deny
iptables -P OUTPUT DROP
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited

echo "Firewall initialized. Allowed domains loaded."

Step 4: MCP Servers

MCP servers give Claude Code read access to external systems. Define them in .mcp.json at the project root so the whole team shares them.

// .mcp.json (committed to repo)
{
  "mcpServers": {
    "figma": {
      "type": "http",
      "url": "https://mcp.figma.com/mcp"
    },
    "supabase": {
      "type": "http",
      "url": "https://mcp.supabase.com/mcp?read_only=true"
    },
    "shadcn": {
      "command": "npx",
      "args": ["shadcn@latest", "mcp"]
    },
    "context7": {
      "command": "npx",
      "args": ["-y", "@upstash/context7-mcp"]
    },
    "playwright": {
      "command": "npx",
      "args": ["@playwright/mcp@latest", "--headless"]
    }
  }
}

After opening the container, run /mcp inside Claude Code to authenticate the HTTP servers (Figma, Supabase) via OAuth.

How to add an MCP server: Two ways.

Via CLI:

# Add to project scope (writes to .mcp.json, committed to git)
claude mcp add --transport http figma --scope project https://mcp.figma.com/mcp

# Add a stdio server to project scope
claude mcp add --transport stdio shadcn --scope project -- npx shadcn@latest mcp

# Add to user scope (only you, across all projects)
claude mcp add --transport http notion --scope user https://mcp.notion.com/mcp

# Add to local scope (only you, only this project -- default)
claude mcp add --transport http my-debug-server https://localhost:8080/mcp

Or edit .mcp.json directly (for project scope).

MCP servers for a Next.js stack

Server Transport Access What it does
Figma HTTP Read-write Pull design tokens, frames, layout data. Push generated UI back to canvas.
Supabase HTTP Read-only with ?read_only=true Query tables, inspect schema, read migrations. Add ?features=database,docs to limit scope.
Convex stdio Read-write (prod disabled by default) Query data, inspect functions, read logs. Use --disable-tools data,run to restrict further.
shadcn stdio Read-only Look up component registry, get install commands, read component docs.
Context7 stdio/HTTP Read-only Fetch up-to-date docs for any library (Next.js, React, Tailwind, etc.).
Playwright stdio Read-write Headless browser automation for testing. Use --allowed-hosts to restrict navigation.
Notion HTTP Read-write Read project specs, PRDs, meeting notes.
GitHub HTTP Read-write PRs, issues, code search.
Sentry HTTP Read-only Error monitoring, stack traces.

Step 5: Claude Code Permissions

This file works alongside .mcp.json from Step 4. The mcp__<server-name>__* rules below reference the server names you defined in .mcp.json (e.g., mcp__figma__* matches all tools from the "figma" server).

// .claude/settings.json (committed to repo, shared with team)
{
  "permissions": {
    "allow": [
      "Read",
      "Bash(git *)",
      "Bash(npm *)",
      "Bash(pnpm *)",
      "Bash(npx *)",
      "Bash(node *)",
      "Bash(jq *)",
      "Bash(supabase *)",
      "Bash(convex *)",
      "mcp__figma__*",
      "mcp__supabase__*",
      "mcp__shadcn__*",
      "mcp__context7__*",
      "mcp__playwright__*"
    ],
    "deny": [
      "Bash(rm -rf *)",
      "Bash(curl * | *)",
      "Bash(wget *)",
      "Read(./.env)",
      "Read(./.env.*)"
    ]
  }
}

Step 6: Project Instructions (CLAUDE.md)

<!-- CLAUDE.md at project root (committed) -->
# My Next.js App

## Stack
- Next.js 15 (App Router)
- TypeScript
- Tailwind CSS + shadcn/ui
- Supabase (auth, database, storage)
- Deployed on Vercel

## Conventions
- Use `pnpm` for package management
- Components go in `src/components/ui/` (shadcn) or `src/components/` (custom)
- Server actions in `src/app/_actions/`
- Use `pnpm dlx shadcn@latest add <component>` to add shadcn components
- Run `pnpm dev` to start the dev server on port 3000
- Run `pnpm test` to run tests

## Do NOT
- Modify `.env.local` -- credentials are managed outside this repo
- Push directly to main -- always create a feature branch

Step 7: Custom Skills

Skills are slash commands your team can invoke with /skill-name. They live in .claude/skills/.

<!-- .claude/skills/generate-page/SKILL.md -->
---
name: generate-page
description: Scaffold a new Next.js page with loading, error, and layout files
argument-hint: <route-path>
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---

Generate a new Next.js App Router page at the route: $ARGUMENTS

Steps:
1. Check if the route already exists in src/app/
2. Create the page.tsx, loading.tsx, and error.tsx files
3. Use the existing layout patterns from @src/app/layout.tsx
4. Use shadcn components for UI -- check available ones with the shadcn MCP
5. If the page needs data, create a server action in src/app/_actions/
6. Run `pnpm lint` to verify no errors

Usage inside Claude Code: /generate-page dashboard/settings

How to add a skill: Create a folder in .claude/skills/<name>/ with a SKILL.md file. That's it. The skill appears immediately as /<name> in Claude Code.

Step 8: Update .gitignore

Add these lines to your .gitignore to keep personal config out of version control:

# Claude Code local settings (personal overrides, not shared)
.claude/settings.local.json

Step 9: Verify Your Setup

After opening the container ("Reopen in Container"), run these checks:

# 1. Confirm you're inside the container
echo $DEVCONTAINER  # Should print "true"

# 2. Confirm Claude Code is installed
claude --version

# 3. Confirm the firewall is active (should fail)
curl -s --max-time 3 https://example.com && echo "FAIL: firewall not active" || echo "OK: firewall blocking"

# 4. Confirm whitelisted domains work (should succeed)
curl -s --max-time 3 https://api.anthropic.com > /dev/null && echo "OK: Anthropic API reachable" || echo "FAIL: check firewall"

# 5. Confirm MCP servers are connected
# Inside Claude Code, type: /mcp
# All servers should show "connected" or prompt for authentication

# 6. Confirm write isolation
touch /tmp/test-write && echo "OK: /tmp writable" || echo "FAIL"
touch /workspace/test-write && rm /workspace/test-write && echo "OK: /workspace writable" || echo "FAIL"

If step 3 does not fail (i.e., example.com is reachable), the firewall did not initialize. Check that postStartCommand ran successfully and that the container has NET_ADMIN capability.


The Scope Confusion: User vs Project vs Local

When you add an MCP server, permission rule, hook, or skill, Claude Code asks which scope. Here's the decision tree:

Should the whole team have this?
  ├─ YES → Project scope (.claude/settings.json or .mcp.json)
  │         Committed to git. Everyone gets it on clone.
  │
  └─ NO → Is it for all your projects, or just this one?
           ├─ ALL projects → User scope (~/.claude/settings.json)
           │                  Personal. Never committed.
           │
           └─ JUST THIS ONE → Local scope (.claude/settings.local.json)
                               Personal + gitignored.

Where everything lives on disk

Thing User (you, all projects) Project (team, this repo) Local (you, this repo)
Settings ~/.claude/settings.json .claude/settings.json .claude/settings.local.json
MCP servers ~/.claude.json .mcp.json ~/.claude.json (per-project path)
Skills ~/.claude/skills/<name>/SKILL.md .claude/skills/<name>/SKILL.md --
Agents ~/.claude/agents/<name>.md .claude/agents/<name>.md --
Rules ~/.claude/rules/*.md .claude/rules/*.md --
Instructions ~/.claude/CLAUDE.md ./CLAUDE.md --
Hooks In ~/.claude/settings.json In .claude/settings.json In .claude/settings.local.json

Rules of thumb

What you're adding Best scope Why
Figma MCP, Supabase MCP, shadcn MCP Project Everyone on the team needs the same data sources
Notion MCP (your personal workspace) User Your Notion, not the team's
A debug MCP on localhost:8080 Local Temporary, only on your machine
Bash(pnpm *) permission Project Team standard
Bash(my-personal-script *) permission Local Just you
/generate-page skill Project Team workflow
/my-snippets skill User Personal productivity
Code style rules Project (.claude/rules/) Consistency
"I prefer terse responses" User (~/.claude/CLAUDE.md) Personal preference

Precedence

Settings are evaluated in this order. First match wins. Deny always beats allow.

1. Managed (IT/admin, cannot be overridden)
2. Command line flags
3. Local  (.claude/settings.local.json)
4. Project (.claude/settings.json)
5. User   (~/.claude/settings.json)

Security Model

The four layers described in The Architecture are configured across Steps 1-5 above. Here is what each layer stops and what it does not:

Layer Stops Does not stop
Container isolation Host filesystem access, process interference, system-level damage Anything reachable inside the container
Network firewall Exfiltration to unknown domains, arbitrary curl/wget Traffic to whitelisted domains (data could be sent to an allowed endpoint)
Filesystem mounts Reads/writes to host home, SSH keys, other repos Reads/writes within /workspace (your project)
Claude Code permissions Specific destructive commands (rm -rf), reading .env files Commands not covered by deny rules

With all four layers in place, you can run claude --dangerously-skip-permissions to eliminate permission prompts. The container IS the sandbox. This is the intended workflow for unattended and batch operations.

Caveat from Anthropic: DevContainers do not prevent a malicious project from exfiltrating anything accessible inside the container, including Claude Code credentials. Only use with trusted repositories.

Hardening details

The devcontainer.json in Step 2 includes three hardening measures that go beyond the Anthropic reference implementation:

Measure What it does Why it matters
--cap-drop=ALL + selective --cap-add Drops all Linux capabilities, then re-adds only NET_ADMIN and NET_RAW (required for iptables). Without this, the container retains default capabilities like CHOWN, DAC_OVERRIDE, FOWNER, SETUID, etc. -- any of which could be exploited for privilege escalation.
--security-opt=no-new-privileges:true Prevents processes inside the container from gaining additional privileges via setuid binaries or capability escalation. Even if an attacker drops a setuid binary into /workspace, it cannot escalate to root.
VSCODE_IPC_HOOK_CLI="", VSCODE_GIT_IPC_HANDLE="", GIT_ASKPASS="" Neutralizes VS Code IPC sockets exposed inside the container. An agent with shell access could use these sockets to execute commands in the host VS Code process -- effectively escaping the sandbox. Clearing them closes this attack vector. Credit: Daniel Demmel.

SSH keys and git push

SSH keys (~/.ssh) are deliberately not mounted into the container. This means:

  • The agent cannot git push to remotes that require SSH authentication.
  • git commit works normally (uses .gitconfig for identity).
  • If your workflow requires the agent to push (e.g., for automated PR creation), mount the key read-only and add an explicit deny rule:
// In .claude/settings.json -- allow push only via approved patterns
"deny": ["Bash(git push --force *)"]

The safest pattern is to let the agent commit locally, then have the developer (or CI) handle the push.

Firewall reliability

The init-firewall.sh script resolves domain names to IPs at container start. Two caveats:

  1. IP rotation: Cloud services rotate IPs. If an MCP server stops working after a container restart, rebuild the container (Ctrl+Shift+P > "Rebuild Container") to re-resolve IPs.
  2. Verification: The Anthropic reference implementation includes a verification step that tests blocked vs allowed domains after initialization. Add this to your script for production use.

Known limitations

Limitation Detail
MCP OAuth in containers Some MCP servers with browser-based OAuth (e.g., Linear) may fail to complete the auth flow inside a devcontainer. Workaround: authenticate on the host first, then pass the token via remoteEnv.
Domain fronting The firewall filters by IP, not by SNI/hostname. A whitelisted IP serving multiple domains could be used to reach an unintended service. This is a known limitation of IP-based filtering.
Shared kernel DevContainers share the host kernel (unlike VMs or Docker Sandbox). For maximum isolation against untrusted code, consider hypervisor-level solutions.
Bash deny rules are fragile Bash(rm -rf *) can be bypassed by rewriting the command (e.g., find . -delete). Deny rules are a speed bump, not a wall. The container boundary is the real enforcement layer.

Before and After

Concern Before (bare metal) After (secure devcontainer)
Filesystem access Claude reads/writes anywhere your user can Writes only to /workspace. Host home, SSH keys, other repos invisible
Network access Unrestricted. Can curl anything Default-deny. Only Anthropic API, npm, GitHub, Figma, Supabase reachable
Permission fatigue Approve every command, or --dangerously-skip-permissions on bare metal (risky) --dangerously-skip-permissions safely. The container is the sandbox
Team consistency "Works on my machine." Different Node, pnpm, CLI versions .devcontainer/ committed. Clone + Reopen = identical for everyone
Onboarding Install Node, pnpm, supabase CLI, configure auth, install extensions... Open in VS Code. "Reopen in Container." Done
MCP servers Each dev configures their own .mcp.json committed. Same data sources for everyone
Skills & workflows Knowledge lives in one person's head .claude/skills/ committed. /generate-page works for everyone
Credential isolation ~/.env, ~/.aws, ~/.ssh all accessible Only explicitly passed env vars are visible
Blast radius rm -rf / destroys your actual files rm -rf / destroys the container. Rebuild in 30 seconds
Scope confusion Claude asks "user or project?" and you guess Clear decision tree. Team stuff = project. Personal = user. Temp = local
CI/CD parity Dev and CI run on different environments CI uses the same Dockerfile
Auditability No visibility into what Claude did Git history, container logs, hooks can log every tool call

Cheat Sheet: How to Add Things

I want to add... How Where it goes
A CLI (supabase, convex, pnpm) RUN npm install -g <pkg> in Dockerfile. Rebuild container. .devcontainer/Dockerfile
A CLI that doesn't need global install (shadcn) Just use npx or pnpm dlx at runtime. Nothing to install. --
An MCP server for the team claude mcp add --scope project --transport http <name> <url> .mcp.json
An MCP server just for me claude mcp add --transport http <name> <url> (default = local scope) ~/.claude.json
A permission rule for the team Edit .claude/settings.json > permissions.allow or permissions.deny .claude/settings.json
A custom slash command Create .claude/skills/<name>/SKILL.md .claude/skills/
A personal slash command Create ~/.claude/skills/<name>/SKILL.md ~/.claude/skills/
Project instructions Edit CLAUDE.md at project root ./CLAUDE.md
Personal instructions Edit ~/.claude/CLAUDE.md ~/.claude/CLAUDE.md
A code style rule Create .claude/rules/<topic>.md .claude/rules/
A hook (e.g. lint on save) Add to .claude/settings.json > hooks .claude/settings.json
A whitelisted domain Add to init-firewall.sh > whitelist <domain>. Rebuild container. .devcontainer/init-firewall.sh
A VS Code extension Add to devcontainer.json > customizations.vscode.extensions .devcontainer/devcontainer.json
A forwarded port (e.g. 54321 for Supabase Studio) Add to devcontainer.json > forwardPorts .devcontainer/devcontainer.json
An env var from host Add to devcontainer.json > remoteEnv as ${localEnv:VAR_NAME} .devcontainer/devcontainer.json

CI/CD Authentication

The devcontainer gives you CI/CD parity -- the same Dockerfile runs locally and in CI. But CI pipelines run non-interactively, so you need headless authentication.

# In CI, pass the API key as an environment variable
export ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}

# Or use an OAuth token for Claude Pro/Team accounts
export CLAUDE_CODE_OAUTH_TOKEN=${{ secrets.CLAUDE_OAUTH_TOKEN }}

# Run Claude Code in non-interactive mode
claude -p "run all tests and report failures" --dangerously-skip-permissions --max-turns 10

For MCP servers that require OAuth (Figma, Supabase), generate long-lived tokens and pass them via remoteEnv or CI secrets rather than relying on browser-based auth flows.


Key Takeaways

  1. Put .devcontainer/ inside your repo. It's the only way to guarantee consistency, support Codespaces, and keep config under code review.

  2. Wide read, narrow write. MCP servers and CLIs provide read-only context. The container restricts writes to your project folder.

  3. Four layers of defense. Container isolation, network firewall, filesystem mounts, Claude Code permissions. No single layer is enough.

  4. The container replaces permission fatigue. Use --dangerously-skip-permissions safely inside it.

  5. Scope = audience. Team stuff goes in project scope (.claude/settings.json, .mcp.json). Personal stuff goes in user scope (~/.claude/). Temporary stuff goes in local scope (.claude/settings.local.json).

  6. CLIs go in the Dockerfile. MCP servers go in .mcp.json. Skills go in .claude/skills/. Three different things, three different places, one clear pattern.


Further Reading

This guide focuses on team workflow and the Claude Code ecosystem. For deeper dives into specific areas:

Topic Resource Why read it
Low-level container hardening Daniel Demmel: Coding Agents in Secured VS Code Dev Containers IPC socket hardening, Docker socket proxy, privilege escalation prevention. The deepest security treatment in the field. Complementary to this guide.
Security audit tooling Trail of Bits: claude-code-devcontainer Production-grade CLI (devc), session analytics, headless OAuth, multi-client deployment patterns. Built by a security firm.
Hypervisor-level isolation Docker Sandbox Separate kernel, stronger than devcontainers. For teams reviewing untrusted code or needing VM-grade isolation without the VM overhead.
Threat modeling for AI agents NVIDIA: Practical Security Guidance for Sandboxing Agentic Workflows Framework for reasoning about agent sandboxing. Covers the "lethal trifecta" (LLM + code execution + untrusted content).
Safely running agents (tutorial) Andrea Bizzotto: How to Safely Run AI Agents Inside a DevContainer Accessible introduction. Good framing of "shift from supervisor to architect."
MCP security CoSAI: Practical Guide to MCP Security OAuth risks, confused deputy problem, SSRF via MCP metadata discovery.

References

Claude Code documentation

Anthropic reference implementations

MCP servers

DevContainers and Docker

Security references

About

Reference DevContainer for running Claude Code securely in team environments. Wide read, narrow write architecture with hardened firewall, MCP servers, and scoped permissions.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages