commit c01102b641fea6d88fe66d3782d5348191cea3eb Author: Julius Zeidler Date: Tue Apr 14 20:11:24 2026 +0200 initial diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba76e9d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.env +*.log +.git +README.md +claude.sh +.gitignore +.env.example +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d8a6d6f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2334d82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9af5a32 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Dockerfile.claude b/Dockerfile.claude new file mode 100644 index 0000000..ca847d5 --- /dev/null +++ b/Dockerfile.claude @@ -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"] diff --git a/Dockerfile.proxy b/Dockerfile.proxy new file mode 100644 index 0000000..cfec906 --- /dev/null +++ b/Dockerfile.proxy @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..dde60b1 --- /dev/null +++ b/README.md @@ -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 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 | diff --git a/claude.sh b/claude.sh new file mode 100755 index 0000000..52bcc3a --- /dev/null +++ b/claude.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# claude.sh — Manage the isolated Claude Code Docker environment +# Usage: ./claude.sh [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 < [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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..baadf41 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/proxy/squid.conf b/proxy/squid.conf new file mode 100644 index 0000000..6ef039f --- /dev/null +++ b/proxy/squid.conf @@ -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