From c01102b641fea6d88fe66d3782d5348191cea3eb Mon Sep 17 00:00:00 2001 From: Julius Zeidler Date: Tue, 14 Apr 2026 20:11:24 +0200 Subject: [PATCH] initial --- .dockerignore | 8 +++ .env.example | 9 +++ .gitignore | 2 + CLAUDE.md | 94 ++++++++++++++++++++++++++ Dockerfile.claude | 30 +++++++++ Dockerfile.proxy | 25 +++++++ README.md | 120 +++++++++++++++++++++++++++++++++ claude.sh | 163 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 59 ++++++++++++++++ proxy/squid.conf | 44 ++++++++++++ 10 files changed, 554 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile.claude create mode 100644 Dockerfile.proxy create mode 100644 README.md create mode 100755 claude.sh create mode 100644 docker-compose.yml create mode 100644 proxy/squid.conf 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