This commit is contained in:
Julius Zeidler 2026-04-14 20:11:24 +02:00
commit c01102b641
10 changed files with 554 additions and 0 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.env
*.log
.git
README.md
claude.sh
.gitignore
.env.example
.dockerignore

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
# Copy this file to .env and fill in your values.
# .env is git-ignored — never commit it.
# Required: your Anthropic API key
ANTHROPIC_API_KEY=sk-ant-...
# Optional: mount a host directory as /workspace inside the Claude container.
# If unset, a named Docker volume is used (fully isolated from the host).
# WORKSPACE_DIR=/absolute/path/to/your/project

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.env
*.log

94
CLAUDE.md Normal file
View file

@ -0,0 +1,94 @@
# Project Guidance
This file provides context and guidance for working with this project.
## Project Overview
**docker-claude** runs Claude Code inside a hardened Docker environment with a Squid proxy sidecar. The goal is full host encapsulation: Claude cannot access the host filesystem or network. All egress is routed through an allowlist-enforcing proxy.
## Architecture
Two containers managed by Docker Compose:
- **`claude`** — Claude Code CLI, non-root (UID 1000), isolated to an internal-only Docker network
- **`proxy`** — Squid forward proxy, non-root (UID 13), bridges the internal network to the internet with an egress allowlist
Key Docker network property: `claude-internal` has `internal: true`, meaning Docker adds no default gateway. The `claude` container physically cannot reach the internet without going through the `proxy` container.
## File Structure
```
docker-claude/
├── claude.sh # Control script: start / stop / run / update / logs / status / shell
├── docker-compose.yml # Service definitions and network topology
├── Dockerfile.claude # Claude Code container (node:20-slim, UID 1000)
├── Dockerfile.proxy # Squid proxy sidecar (ubuntu:22.04, UID 13)
├── proxy/
│ └── squid.conf # Squid ACL config — egress allowlist lives here
├── .env.example # Template for ANTHROPIC_API_KEY
├── .gitignore # Excludes .env and logs
├── .dockerignore # Keeps .env out of build context
└── README.md # User documentation
```
## Development Workflow
```bash
chmod +x claude.sh
cp .env.example .env # set ANTHROPIC_API_KEY
./claude.sh start # build + start proxy + launch Claude interactively
./claude.sh update # rebuild images (no cache) after upstream updates
```
## Coding Standards
- Shell scripts use `set -euo pipefail`
- Dockerfiles use `--no-install-recommends` and clean apt caches in the same layer
- No capabilities granted; `no-new-privileges` on all containers
- `.env` is never committed (enforced by `.gitignore` and `.dockerignore`)
- Commit messages follow **Angular format**: `type(scope): summary`
## Extending the Egress Allowlist
Add domains to `proxy/squid.conf` under the `allowed_sites` ACL, then rebuild:
```bash
docker compose -p claude-secure build proxy
./claude.sh stop && ./claude.sh start
```
## Environment Persistence
This sandbox has a persistent environment file at `/etc/sandbox-persistent.sh`.
This file is automatically sourced in all shell contexts:
- **Non-interactive shells**: via `BASH_ENV=/etc/sandbox-persistent.sh`
- **Login shells**: via `/etc/profile.d/sandbox-persistent.sh`
- **Interactive shells**: via `/etc/bash.bashrc` and `~/.bashrc`
## Critical: Shell Completions Must NOT Be in the Persistent Environment File
**NEVER add shell completion scripts to `/etc/sandbox-persistent.sh`.**
Shell completion scripts will completely break the bash tool when sourced via the persistent environment file.
## Network access
There is a firewall in place to restrict outbound network access. If you need http/https access to
an external service, request it by running:
```bash
sbx policy allow network [domain]
```
## Git Authentication
The sandbox proxy handles GitHub authentication automatically by injecting credentials for HTTPS Git operations.
## Additional Notes
- Always read relevant files before making changes
- Run tests after making modifications
- Follow the existing code structure and patterns
- You have sudo permissions, so you can install necessary packages
- npm, pip and uv are already available for package management

30
Dockerfile.claude Normal file
View file

@ -0,0 +1,30 @@
FROM node:20-slim
# Install minimal runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
ca-certificates \
bash \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -g 1000 claude \
&& useradd -u 1000 -g claude -m -s /bin/bash claude
# Install Claude Code globally (runs as root for npm -g, then drops)
RUN npm install -g @anthropic-ai/claude-code
# Workspace directory owned by claude user
RUN mkdir -p /workspace && chown claude:claude /workspace
USER claude
WORKDIR /workspace
# Proxy traffic through sidecar — override at runtime if needed
ENV HTTP_PROXY=http://proxy:3128
ENV HTTPS_PROXY=http://proxy:3128
ENV ALL_PROXY=http://proxy:3128
ENV NO_PROXY=localhost,127.0.0.1
ENTRYPOINT ["claude"]

25
Dockerfile.proxy Normal file
View file

@ -0,0 +1,25 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
squid \
&& rm -rf /var/lib/apt/lists/*
# Give the proxy system user (UID 13) ownership of all Squid paths
RUN mkdir -p /var/spool/squid /var/log/squid \
&& chown -R proxy:proxy /var/spool/squid /var/log/squid /etc/squid
COPY --chown=proxy:proxy proxy/squid.conf /etc/squid/squid.conf
USER proxy
# Initialise cache directories as the proxy user
RUN squid -N -f /etc/squid/squid.conf -z 2>/dev/null || true
EXPOSE 3128
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD /bin/bash -c 'echo >/dev/tcp/127.0.0.1/3128'
CMD ["squid", "-N", "-f", "/etc/squid/squid.conf"]

120
README.md Normal file
View file

@ -0,0 +1,120 @@
# docker-claude
Runs [Claude Code](https://claude.ai/code) inside an isolated Docker environment with a proxy sidecar for controlled egress. Claude cannot reach the host filesystem or network directly.
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Host machine │
│ │
│ claude.sh (control script) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Docker: claude-secure │ │
│ │ │ │
│ │ ┌─────────────┐ claude-internal │ │
│ │ │ claude │◄─────(internal only)───► │ │
│ │ │ (UID 1000) │ │ │ │
│ │ └─────────────┘ ┌──────┴──────┐ │ │
│ │ │ proxy │ │ │
│ │ │ (UID 13) │ │ │
│ │ └──────┬──────┘ │ │
│ │ proxy-external │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ internet (allowlisted) │
└─────────────────────────────────────────────────────┘
```
- **`claude` container** — Claude Code, runs as UID 1000, on `claude-internal` only (no internet route)
- **`proxy` container** — Squid forward proxy, runs as UID 13, bridges `claude-internal` ↔ internet, enforces egress allowlist
- **`claude-internal`** — Docker bridge with `internal: true`; Docker adds no default gateway, so containers on this network cannot reach the internet directly
- **`proxy-external`** — Standard bridge; the proxy sidecar uses this for controlled outbound access
## Prerequisites
- Docker Engine 24+
- Docker Compose v2 plugin (`docker compose version`)
## Setup
```bash
# 1. Clone / copy this repo
git clone <repo> docker-claude && cd docker-claude
# 2. Configure your API key
cp .env.example .env
$EDITOR .env # set ANTHROPIC_API_KEY
# 3. Make the control script executable
chmod +x claude.sh
```
## Usage
```bash
# Build images, start proxy, launch Claude Code interactively
./claude.sh start
# Same as start but skips image rebuild (faster on subsequent runs)
./claude.sh run
# Stop and remove all containers (proxy + any running sessions)
./claude.sh stop
# Rebuild images without cache (e.g. after Claude Code updates)
./claude.sh update
# Tail proxy access logs
./claude.sh logs
# Show container status
./claude.sh status
# Open a debug bash shell inside the Claude container
./claude.sh shell
```
### Working with host files
By default, Claude's workspace is a named Docker volume (`claude-secure-workspace`) — fully isolated from the host.
To mount a specific host directory:
```bash
WORKSPACE_DIR=$HOME/myproject ./claude.sh run
```
The directory is mounted at `/workspace` inside the container.
## Egress allowlist
Edit `proxy/squid.conf` and add domains to the `allowed_sites` ACL:
```squid
acl allowed_sites dstdomain api.anthropic.com
acl allowed_sites dstdomain statsig.anthropic.com
# acl allowed_sites dstdomain api.github.com # uncomment if needed
# acl allowed_sites dstdomain registry.npmjs.org
```
Rebuild the proxy after changes:
```bash
docker compose -p claude-secure build proxy
./claude.sh stop && ./claude.sh start
```
## Security controls
| Control | Claude container | Proxy container |
|---|---|---|
| Non-root user | UID 1000 (`claude`) | UID 13 (`proxy`) |
| `no-new-privileges` | yes | yes |
| All capabilities dropped | yes | yes |
| Direct internet access | no (`internal` network only) | allowlisted only |
| Host filesystem | no mounts by default | none |
| Docker socket | not mounted | not mounted |

163
claude.sh Executable file
View file

@ -0,0 +1,163 @@
#!/usr/bin/env bash
# claude.sh — Manage the isolated Claude Code Docker environment
# Usage: ./claude.sh <command> [args]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
PROJECT="claude-secure"
# ─── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[-]${NC} $*" >&2; }
# ─── Dependency check ─────────────────────────────────────────────────────────
check_deps() {
if ! command -v docker &>/dev/null; then
error "Docker is not installed. https://docs.docker.com/get-docker/"
exit 1
fi
if ! docker compose version &>/dev/null 2>&1; then
error "Docker Compose v2 plugin is required."
exit 1
fi
}
# ─── Environment loading ──────────────────────────────────────────────────────
load_env() {
local env_file="$SCRIPT_DIR/.env"
if [[ -f "$env_file" ]]; then
# shellcheck disable=SC1090
set -a; source "$env_file"; set +a
fi
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
error "ANTHROPIC_API_KEY is not set."
error "Copy .env.example → .env and add your key, or export it in your shell."
exit 1
fi
}
# ─── Workspace volume resolution ──────────────────────────────────────────────
# Default: named Docker volume (fully isolated).
# Override: export WORKSPACE_DIR=/path/to/project before running.
workspace_flag() {
if [[ -n "${WORKSPACE_DIR:-}" ]]; then
local abs
abs="$(realpath "${WORKSPACE_DIR}")"
if [[ ! -d "$abs" ]]; then
error "WORKSPACE_DIR does not exist: $abs"
exit 1
fi
echo "--volume ${abs}:/workspace:z"
else
echo "--volume ${PROJECT}-workspace:/workspace"
fi
}
# ─── Compose wrapper ──────────────────────────────────────────────────────────
dc() { docker compose -f "$COMPOSE_FILE" -p "$PROJECT" "$@"; }
# ─── Commands ─────────────────────────────────────────────────────────────────
cmd_start() {
check_deps
load_env
info "Building images..."
dc build
info "Starting proxy sidecar..."
dc up -d proxy
info "Waiting for proxy health check..."
dc up -d proxy # no-op if already healthy; compose waits via depends_on
info "Launching Claude Code..."
# shellcheck disable=SC2046
dc run --rm $(workspace_flag) claude "$@"
}
cmd_stop() {
check_deps
info "Stopping all containers..."
dc down
info "Done."
}
cmd_run() {
check_deps
load_env
info "Ensuring proxy is running..."
dc up -d proxy
info "Launching Claude Code..."
# shellcheck disable=SC2046
dc run --rm $(workspace_flag) claude "$@"
}
cmd_update() {
check_deps
info "Rebuilding images (no cache)..."
dc build --no-cache
info "Update complete. Run './claude.sh start' to launch."
}
cmd_logs() {
check_deps
local svc="${1:-proxy}"
dc logs -f "$svc"
}
cmd_status() {
check_deps
dc ps
}
cmd_shell() {
check_deps
load_env
warn "Opening debug shell inside Claude container (non-Claude entrypoint)."
# shellcheck disable=SC2046
dc run --rm --entrypoint /bin/bash $(workspace_flag) claude
}
cmd_help() {
cat <<EOF
Usage: $(basename "$0") <command> [args]
Commands:
start [args] Build images, start proxy, launch Claude Code
run [args] Start proxy if needed, launch Claude Code
stop Stop and remove all containers
update Rebuild images without cache
logs [svc] Tail logs (default: proxy)
status Show container status
shell Open a bash shell in the Claude container (debug)
help Show this message
Environment variables:
ANTHROPIC_API_KEY Required. Set in .env or exported in your shell.
WORKSPACE_DIR Optional. Absolute path to mount as /workspace.
Defaults to a named Docker volume (fully isolated).
Examples:
./claude.sh start
WORKSPACE_DIR=\$HOME/myproject ./claude.sh run
./claude.sh logs proxy
./claude.sh shell
EOF
}
# ─── Dispatch ─────────────────────────────────────────────────────────────────
case "${1:-help}" in
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
run) shift; cmd_run "$@" ;;
update) cmd_update ;;
logs) shift; cmd_logs "${1:-}" ;;
status) cmd_status ;;
shell) cmd_shell ;;
help|-h|--help) cmd_help ;;
*)
error "Unknown command: ${1}"
cmd_help
exit 1
;;
esac

59
docker-compose.yml Normal file
View file

@ -0,0 +1,59 @@
services:
# ─── Proxy sidecar ─────────────────────────────────────────────────────────
# Bridges the isolated internal network to the internet.
# Enforces an egress allowlist — see proxy/squid.conf.
proxy:
build:
context: .
dockerfile: Dockerfile.proxy
networks:
- claude-internal # reachable by the claude container
- proxy-external # has outbound internet access
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
- /var/spool/squid
- /var/log/squid
# ─── Claude Code container ─────────────────────────────────────────────────
# No direct internet access. All egress routes through the proxy sidecar.
# Run via "docker compose run --rm claude" (managed by claude.sh).
claude:
build:
context: .
dockerfile: Dockerfile.claude
depends_on:
proxy:
condition: service_healthy
networks:
- claude-internal # only — no route to the internet
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- HTTP_PROXY=http://proxy:3128
- HTTPS_PROXY=http://proxy:3128
- ALL_PROXY=http://proxy:3128
- NO_PROXY=localhost,127.0.0.1
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stdin_open: true
tty: true
# Workspace is injected by claude.sh via --volume flag at run time.
# Default: named Docker volume. Override: set WORKSPACE_DIR on the host.
networks:
# Internal-only: Docker adds no default gateway → no direct internet route
claude-internal:
driver: bridge
internal: true
# External: standard bridge with internet access (proxy only)
proxy-external:
driver: bridge

44
proxy/squid.conf Normal file
View file

@ -0,0 +1,44 @@
# ─────────────────────────────────────────────────────────────────────────────
# Squid forward-proxy sidecar — allowlist-only egress for Claude Code
# ─────────────────────────────────────────────────────────────────────────────
http_port 3128
# PID must be writable by the non-root proxy user
pid_filename /tmp/squid.pid
# ─── Logging (container-friendly: stdout/stderr) ──────────────────────────────
access_log stdio:/dev/stdout combined
cache_log stdio:/dev/stderr
cache_store_log none
# ─── No disk cache ────────────────────────────────────────────────────────────
cache deny all
coredump_dir /var/spool/squid
# ─── ACL Definitions ──────────────────────────────────────────────────────────
acl SSL_ports port 443
acl Safe_ports port 80
acl Safe_ports port 443
acl CONNECT method CONNECT
# ─── Egress allowlist ─────────────────────────────────────────────────────────
# Add domains here as needed. Leading dot matches all subdomains.
acl allowed_sites dstdomain api.anthropic.com
acl allowed_sites dstdomain statsig.anthropic.com
# ─── Access rules ─────────────────────────────────────────────────────────────
# Block requests to non-standard ports
http_access deny !Safe_ports
# Block CONNECT to non-SSL ports
http_access deny CONNECT !SSL_ports
# Allow HTTPS tunnels only to allowlisted destinations
http_access allow CONNECT allowed_sites
# Allow plain HTTP only to allowlisted destinations
http_access allow allowed_sites
# Deny everything else — default deny
http_access deny all