From 4b3df1ddaed3e418ba7950f4c846b8683dadd087 Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 20 Jun 2026 10:18:18 -0400 Subject: [PATCH] migrate to the new tiered structure --- .gitignore | 1 - Dockerfile.base | 46 ++++ README.md | 86 ++++++-- entrypoint.sh | 26 +++ example/.safeclaude/.env.example | 3 + example/.safeclaude/.gitignore | 1 + example/.safeclaude/Dockerfile | 31 +++ example/.safeclaude/hooks/10-ruby.sh | 31 +++ example/.safeclaude/hooks/15-node.sh.example | 15 ++ example/.safeclaude/hooks/20-bundle.sh | 21 ++ example/.safeclaude/hooks/30-pg-proxy.sh | 15 ++ example/Dockerfile | 69 ------ example/README.md | 28 +++ example/docker-compose.yml | 40 ---- example/entrypoint.sh.example | 78 ------- example/safeclaude | 14 -- safeclaude | 214 +++++++++++++++++++ 17 files changed, 495 insertions(+), 224 deletions(-) create mode 100644 Dockerfile.base create mode 100755 entrypoint.sh create mode 100644 example/.safeclaude/.env.example create mode 100644 example/.safeclaude/.gitignore create mode 100644 example/.safeclaude/Dockerfile create mode 100755 example/.safeclaude/hooks/10-ruby.sh create mode 100644 example/.safeclaude/hooks/15-node.sh.example create mode 100755 example/.safeclaude/hooks/20-bundle.sh create mode 100755 example/.safeclaude/hooks/30-pg-proxy.sh delete mode 100644 example/Dockerfile create mode 100644 example/README.md delete mode 100644 example/docker-compose.yml delete mode 100644 example/entrypoint.sh.example delete mode 100755 example/safeclaude create mode 100755 safeclaude diff --git a/.gitignore b/.gitignore index 4ab3d4f..4c49bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ .env -entrypoint.sh diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000..a280273 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,46 @@ +# safeclaude-base — the shared base every project builds on. Keep this generic: +# project-specific packages and language versions go in each project's own +# .safeclaude/Dockerfile (which starts with `FROM safeclaude-base`), not here. +# +# This image runs as root so project Dockerfiles can install system packages +# freely. Don't worry — the launcher locks things down at run time (normal user, +# no special privileges), so nothing Claude does actually runs as root. +FROM node:22-slim + +# Just the basics every project needs: +# - curl/ca-certificates: for downloads (the Claude installer, git-spice) +# - git + ripgrep: required by Claude Code +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + git \ + ripgrep \ + bash \ + && rm -rf /var/lib/apt/lists/* + +# --- git-spice (a tool for stacked pull requests, handy in the Claude workflow) --- +# Releases are named git-spice.Linux-.tar.gz, and `uname -m` gives the arch. +RUN ARCH=$(uname -m) && \ + GS_VERSION=$(curl -fsSL https://api.github.com/repos/abhinav/git-spice/releases/latest \ + | grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/') && \ + mkdir -p /tmp/gs-install && \ + curl -fsSL "https://github.com/abhinav/git-spice/releases/download/v${GS_VERSION}/git-spice.Linux-${ARCH}.tar.gz" \ + | tar -xz -C /tmp/gs-install && \ + find /tmp/gs-install -maxdepth 1 -type f -executable -exec cp {} /usr/local/bin/gs \; && \ + chmod +x /usr/local/bin/gs && \ + rm -rf /tmp/gs-install + +# --- the user Claude runs as --- +# We create this user so the home folder is owned by the same ID the launcher +# runs as. Without it, the container couldn't write to its own home. +RUN useradd -m -s /bin/bash -u 1001 coder + +# Claude installs itself into one of these folders, so add them to PATH. +ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH" + +COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh + +WORKDIR /code + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["bash"] diff --git a/README.md b/README.md index e4bfadd..e10c486 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,75 @@ -# Claude Code — Dockerized +# safeclaude -A minimal, guardrailed container for running Claude Code. +Run Claude Code inside a locked-down container, one per project. Your code is +shared with the container so Claude can edit it, but everything Claude *runs* +stays boxed in — so a bad command can't touch the rest of your machine. -## Setup +It works like a language version manager (think `rvm`/`nvm`/`pyenv`): this repo +holds only the **shared plumbing**, and each project keeps its own setup in a +small `.safeclaude/` folder. When you launch, safeclaude reads that folder, +builds the container once, and reuses it after that. + +## Install ```bash -# 1. Build the image -docker compose build - -# 2. Link the binary where it's accessible -ln -s `readlink -f ./safeclaude` `readlink -f ~/.local/bin` - -# 3. Run against your code -cd ~/zenmaid-webapp && safeclaude . +# Put the launcher on your PATH (any dir on your PATH works) +ln -s "$(readlink -f ./safeclaude)" ~/.local/bin/safeclaude ``` -## Security notes +That's it — the first launch builds what it needs automatically. -- Runs as a non-root user (`coder`, uid 1001) -- All Linux capabilities are dropped except `NET_BIND_SERVICE` -- Privilege escalation is disabled (`no-new-privileges`) -- The container has no network restrictions beyond what Docker provides — - add a custom network or `--network none` with `--add-host` if you want - to lock that down further +## Use -## Limitations & future updates +```bash +cd ~/my-project +safeclaude init # create a .safeclaude/ folder, then edit it for your project +safeclaude # launch Claude in this project's container +``` -- System package requirement changes require updates to the Dockerfile and a restart/rebuild - the claude user can't make these changes itself due to restricted access. +| Command | What it does | +| --- | --- | +| `safeclaude [PATH] [claude-args...]` | Launch Claude for a project (default: current dir). Anything extra is passed straight to `claude`. | +| `safeclaude build [PATH]` | Rebuild the project's container from scratch. | +| `safeclaude init [PATH]` | Create a starter `.safeclaude/` folder. | +| `safeclaude envs` | List the containers and stored data safeclaude has created. | -- Bundles need to be updated separately inside the container +A "project" is any folder (or a parent of it) that has a `.safeclaude/` folder, +so you can launch from a subdirectory and it'll still find the right one. -- The setup is fairly tightly married to a ruby/psql application and would need to be tweaked to be configurable for other environments or platforms +## What goes in `.safeclaude/` + +``` +.safeclaude/ + Dockerfile # which system packages / language versions this project needs + hooks/ # setup scripts that run each time the container starts + .env # secrets, kept out of git (.env.example shows the format) +``` + +A few things worth knowing: + +- **Two places setup can live, and the difference matters.** Slow, one-time + installs (system packages, a language toolchain) go in the `Dockerfile` — + these get cached, so they don't repeat. Anything that needs your actual code + present, or that should persist between runs (installing dependencies, + starting a database proxy), goes in a `hooks/` script that runs at launch. +- **It only rebuilds when something changed.** safeclaude remembers what it + already built, so a normal launch starts right up with no waiting. +- **Hooks are safe to run every time.** They check before doing work — e.g. the + starter dependency hook only reinstalls when your lockfile actually changed. + +See [`example/`](example/) for a real, filled-in Ruby + Postgres setup you can +copy from. + +## Security + +These are applied every launch, by safeclaude itself — a project's own config +can't loosen them: + +- Claude runs as a normal user, not root, so it can't make system-wide changes. +- The container is stripped of special privileges it doesn't need. +- It can't gain new privileges partway through. +- Each project gets its own private storage, so one project can't see or break + another's setup. + +Network access is otherwise normal. If you want to cut it off, that's a small +change in the launcher. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..0306505 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# This runs every time a container starts. Keep it generic — anything specific +# to a project (language versions, installing dependencies, and so on) belongs +# in that project's .safeclaude/hooks/, not here. +set -e + +# Install Claude if it isn't already here. The home folder is saved between +# runs, so this only actually downloads the first time. +if ! command -v claude &>/dev/null; then + echo "[safeclaude] Claude Code not found — installing..." + curl -fsSL https://claude.ai/install.sh | bash +fi + +# Run the project's setup scripts, in name order. They're read straight from the +# project folder, so editing one takes effect next launch — no rebuild needed. +# If a script fails we stop here, rather than start in a half-set-up container. +HOOK_DIR="/code/.safeclaude/hooks" +if [ -d "$HOOK_DIR" ]; then + for hook in "$HOOK_DIR"/*.sh; do + [ -e "$hook" ] || continue + echo "[safeclaude] hook: $(basename "$hook")" + bash "$hook" + done +fi + +exec "$@" diff --git a/example/.safeclaude/.env.example b/example/.safeclaude/.env.example new file mode 100644 index 0000000..d39450c --- /dev/null +++ b/example/.safeclaude/.env.example @@ -0,0 +1,3 @@ +# Copy to .env (gitignored) for secrets injected into the container at runtime. +# Private gem registry token used by hooks/20-bundle.sh: +# BUNDLE_GEMS__GRAPHQL__PRO=user:token diff --git a/example/.safeclaude/.gitignore b/example/.safeclaude/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/example/.safeclaude/.gitignore @@ -0,0 +1 @@ +.env diff --git a/example/.safeclaude/Dockerfile b/example/.safeclaude/Dockerfile new file mode 100644 index 0000000..2dd06a9 --- /dev/null +++ b/example/.safeclaude/Dockerfile @@ -0,0 +1,31 @@ +# Example .safeclaude/Dockerfile for a Ruby + Postgres app. +# +# Everything here runs once when the container is built (and is cached), so it +# won't slow down launches. You're root during the build, so apt just works. +FROM safeclaude-base:latest + +# System packages: what's needed to build Ruby, talk to Postgres, proxy the +# database (socat), and run browser tests (headless Chrome). +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \ + libpq-dev \ + socat \ + chromium chromium-driver \ + && rm -rf /var/lib/apt/lists/* + +# Capybara/Selenium look for Chrome at these paths. +ENV CHROME_BIN=/usr/bin/chromium +ENV CHROMEDRIVER=/usr/bin/chromedriver + +# rbenv and nvm get installed by the hooks at launch instead of here, because +# they live in the home folder — and that folder is swapped in fresh each run, so +# anything we installed there now would just be thrown away. Here we only set the +# paths and shell setup; the two lines below do nothing until a hook installs the +# matching tool. +ENV RBENV_ROOT=/home/coder/.rbenv +ENV NVM_DIR=/home/coder/.nvm +ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH" + +RUN echo '[ -d "$RBENV_ROOT/bin" ] && eval "$(rbenv init - bash)"' >> /etc/bash.bashrc && \ + echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc diff --git a/example/.safeclaude/hooks/10-ruby.sh b/example/.safeclaude/hooks/10-ruby.sh new file mode 100755 index 0000000..d890e98 --- /dev/null +++ b/example/.safeclaude/hooks/10-ruby.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Sets up rbenv and the project's Ruby. Safe to run every launch — it only does +# real work the first time, or when the Ruby version changes. +set -euo pipefail + +# Grab rbenv (cloned into the home folder so it sticks around between runs). +if [ ! -d "$RBENV_ROOT/bin" ]; then + echo "[ruby] installing rbenv..." + git clone --depth=1 https://github.com/rbenv/rbenv.git "$RBENV_ROOT" + git clone --depth=1 https://github.com/rbenv/ruby-build.git "$RBENV_ROOT/plugins/ruby-build" +fi +eval "$(rbenv init - bash)" + +# Pick the Ruby version: the project's .ruby-version wins, then whatever rbenv +# was last set to, then a sensible default. +if [ -f /code/.ruby-version ]; then + RUBY_VERSION="$(tr -d '[:space:]' < /code/.ruby-version)" +elif [ -f "$RBENV_ROOT/version" ]; then + RUBY_VERSION="$(cat "$RBENV_ROOT/version")" +else + RUBY_VERSION="3.3.6" +fi + +if ! rbenv versions --bare 2>/dev/null | grep -qx "$RUBY_VERSION"; then + echo "[ruby] installing Ruby $RUBY_VERSION (first time only — takes a few minutes)..." + rbenv install "$RUBY_VERSION" +fi +rbenv global "$RUBY_VERSION" + +# Make sure bundler is installed for this Ruby. +gem list bundler -i &>/dev/null || gem install bundler --no-document diff --git a/example/.safeclaude/hooks/15-node.sh.example b/example/.safeclaude/hooks/15-node.sh.example new file mode 100644 index 0000000..8a33b7e --- /dev/null +++ b/example/.safeclaude/hooks/15-node.sh.example @@ -0,0 +1,15 @@ +#!/bin/bash +# OPTIONAL — only needed if your project pins a Node version via .nvmrc (the base +# already includes a working Node). Rename to 15-node.sh to turn it on. +set -euo pipefail + +if [ ! -s "$NVM_DIR/nvm.sh" ]; then + echo "[node] installing nvm..." + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh \ + | NVM_DIR="$NVM_DIR" PROFILE=/dev/null bash +fi +. "$NVM_DIR/nvm.sh" + +if [ -f /code/.nvmrc ]; then + ( cd /code && nvm install && nvm use ) +fi diff --git a/example/.safeclaude/hooks/20-bundle.sh b/example/.safeclaude/hooks/20-bundle.sh new file mode 100755 index 0000000..1e35dc3 --- /dev/null +++ b/example/.safeclaude/hooks/20-bundle.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Installs gems, but only when Gemfile.lock has changed: it remembers the last +# version it installed (saved between runs), so an unchanged lockfile is a +# near-instant no-op. +set -euo pipefail + +[ -f /code/Gemfile ] || exit 0 +eval "$(rbenv init - bash)" + +LOCK=/code/Gemfile.lock +MARKER="$HOME/.safeclaude-deps/gemfile.sha" +mkdir -p "$(dirname "$MARKER")" +CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )" + +if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then + echo "[bundle] installing gems..." + # The token below (for a private gem source) comes from .safeclaude/.env — see + # .env.example. It's fine to leave unset if your project doesn't need it. + ( cd /code && BUNDLE_GEMS__GRAPHQL__PRO="${BUNDLE_GEMS__GRAPHQL__PRO:-}" bundle install ) + echo "$CUR" > "$MARKER" +fi diff --git a/example/.safeclaude/hooks/30-pg-proxy.sh b/example/.safeclaude/hooks/30-pg-proxy.sh new file mode 100755 index 0000000..353bd8c --- /dev/null +++ b/example/.safeclaude/hooks/30-pg-proxy.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Makes the host machine's Postgres reachable at the usual 127.0.0.1:5432 inside +# the container, so your database settings work the same in or out of Docker. +set -euo pipefail + +# If something's already answering on 5432 (like a proxy from a previous launch), +# leave it be. We test the port with a built-in bash trick so we don't have to +# install extra tools just to check. +if (exec 3<>/dev/tcp/127.0.0.1/5432) 2>/dev/null; then + exec 3>&- # port answered — already running +else + echo "[pg] proxying 127.0.0.1:5432 -> host.docker.internal:5432" + socat TCP-LISTEN:5432,bind=127.0.0.1,fork,reuseaddr \ + TCP:host.docker.internal:5432 & +fi diff --git a/example/Dockerfile b/example/Dockerfile deleted file mode 100644 index b7d0370..0000000 --- a/example/Dockerfile +++ /dev/null @@ -1,69 +0,0 @@ -FROM node:22-slim - -# System dependencies: -# - curl/ca-certificates: downloads (Claude Code installer, nvm, git-spice) -# - git + ripgrep: Claude Code requirements -# - build-essential, libssl-dev, libreadline-dev, zlib1g-dev: rbenv/ruby-build deps -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - ca-certificates \ - git \ - ripgrep \ - bash \ - build-essential \ - libssl-dev \ - libreadline-dev \ - zlib1g-dev \ - libffi-dev \ - libyaml-dev \ - libpq-dev \ - socat \ - chromium \ - chromium-driver \ - && rm -rf /var/lib/apt/lists/* - -# Selenium/Capybara discover Chrome via these env vars. -# chromium-driver installs chromedriver at /usr/bin/chromedriver. -ENV CHROME_BIN=/usr/bin/chromium -ENV CHROMEDRIVER=/usr/bin/chromedriver - -# --- rbenv (installed into home volume on first run, via entrypoint) --- -# RBENV_ROOT points into the home volume so the install persists across rebuilds. -ENV RBENV_ROOT=/home/coder/.rbenv -ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH" - -# Initialise rbenv shims for all bash sessions (no-op if not yet installed) -RUN echo '[ -d "$RBENV_ROOT/bin" ] && eval "$(rbenv init - bash)"' >> /etc/bash.bashrc - -# --- nvm (installed into home volume on first run, via entrypoint) --- -# NVM_DIR points into the home volume so the install persists across rebuilds. -ENV NVM_DIR=/home/coder/.nvm - -# Source nvm for all bash sessions (nvm.sh is a no-op if not yet installed) -RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc - -# --- git-spice --- -# Releases are named git-spice.Linux-.tar.gz; uname -m gives the right arch directly. -RUN ARCH=$(uname -m) && \ - GS_VERSION=$(curl -fsSL https://api.github.com/repos/abhinav/git-spice/releases/latest \ - | grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/') && \ - mkdir -p /tmp/gs-install && \ - curl -fsSL "https://github.com/abhinav/git-spice/releases/download/v${GS_VERSION}/git-spice.Linux-${ARCH}.tar.gz" \ - | tar -xz -C /tmp/gs-install && \ - find /tmp/gs-install -maxdepth 1 -type f -executable -exec cp {} /usr/local/bin/gs \; && \ - chmod +x /usr/local/bin/gs && \ - rm -rf /tmp/gs-install - -# --- non-root user --- -RUN useradd -m -s /bin/bash -u 1001 coder - -# Claude Code native installer lands in ~/.local/bin or ~/.claude/bin -ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH" - -COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh - -USER coder -WORKDIR /code - -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] -CMD ["bash"] diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..919d8bf --- /dev/null +++ b/example/README.md @@ -0,0 +1,28 @@ +# Example: a Ruby + Postgres project + +A filled-in `.safeclaude/` for a typical Rails-style app — Ruby (via rbenv), the +Postgres client, and headless Chrome for browser tests. It's here as a reference +you can copy from when setting up your own project. + +## How to use it + +- **Start fresh, then borrow:** run `safeclaude init` in your project for a blank + starter, then copy whatever pieces you need from here. (Recommended — you only + pull in what applies to you.) +- **Copy the whole thing:** `cp -r .../safeclaude/example/.safeclaude ~/your-project/` + and tweak from there. + +## What's where + +| File | When it runs | What it does | +| --- | --- | --- | +| `.safeclaude/Dockerfile` | once, then cached | installs system packages, sets up Chrome and the Ruby/Node version managers | +| `hooks/10-ruby.sh` | each launch | installs the project's Ruby and bundler (skips if already done) | +| `hooks/20-bundle.sh` | each launch | runs `bundle install`, but only when `Gemfile.lock` changed | +| `hooks/30-pg-proxy.sh` | each launch | lets the app reach the host's Postgres at the usual `127.0.0.1:5432` | +| `hooks/15-node.sh.example` | off by default | optional Node setup — rename to `15-node.sh` to turn on | +| `.env.example` | — | copy to `.env` for a private gem token (kept out of git) | + +The pattern to take away: slow, one-time installs go in the **Dockerfile** so +they're cached; anything that needs your code or has to stick around between +runs goes in a **hook**. diff --git a/example/docker-compose.yml b/example/docker-compose.yml deleted file mode 100644 index 6551c94..0000000 --- a/example/docker-compose.yml +++ /dev/null @@ -1,40 +0,0 @@ -services: - claude-code: - build: . - image: claude-code:local - container_name: claude-code - - volumes: - # Fixed home volume — persists Claude Code install, config, and credentials - # across container restarts and image rebuilds. - - claude-home:/home/coder - - # Swappable project folder — override PROJECT_DIR to point at any directory: - # PROJECT_DIR=/path/to/myproject docker compose run --rm claude-code - - ${PROJECT_DIR:-./code}:/code - - # Drop all Linux capabilities except NET_BIND_SERVICE, which socat needs - # to proxy port 5432 on 127.0.0.1 inside the container. - cap_drop: - - ALL - cap_add: - - NET_BIND_SERVICE - security_opt: - - no-new-privileges:true - - # Allow the container to reach the host's network (e.g. a local postgres). - # On Linux, host.docker.internal isn't automatic — this creates it. - # On Mac/Windows Docker Desktop it's already available but this is harmless. - extra_hosts: - - "host.docker.internal:host-gateway" - - # Interactive terminal so `claude` works properly - stdin_open: true - tty: true - - working_dir: /code - -volumes: - claude-home: - # Named volume — Docker manages it; survives `docker compose down` - # (use `docker compose down -v` to wipe it along with the install) diff --git a/example/entrypoint.sh.example b/example/entrypoint.sh.example deleted file mode 100644 index a80704d..0000000 --- a/example/entrypoint.sh.example +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash -set -e - -# Install nvm if not already present in the home volume. -if [ ! -s "$NVM_DIR/nvm.sh" ]; then - echo "nvm not found — running installer..." - curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh | - NVM_DIR="$NVM_DIR" PROFILE=/dev/null bash - echo "nvm installed successfully." -fi -. "$NVM_DIR/nvm.sh" - -# Install rbenv + ruby-build if not already present in the home volume. -if [ ! -d "$RBENV_ROOT/bin" ]; then - echo "rbenv not found — installing..." - git clone --depth=1 https://github.com/rbenv/rbenv.git "$RBENV_ROOT" - git clone --depth=1 https://github.com/rbenv/ruby-build.git "$RBENV_ROOT/plugins/ruby-build" - echo "rbenv installed successfully." -fi -eval "$(rbenv init - bash)" - -# Determine the desired Ruby version: prefer .ruby-version in the workspace, -# fall back to the rbenv global setting, then a hardcoded default. -if [ -f "/code/.ruby-version" ]; then - RUBY_VERSION=$(tr -d '[:space:]' /dev/null | grep -qx "$RUBY_VERSION"; then - echo "Ruby $RUBY_VERSION not found — installing (this may take a few minutes)..." - rbenv install "$RUBY_VERSION" - echo "Ruby $RUBY_VERSION installed." -fi -rbenv global "$RUBY_VERSION" - -# Ensure bundler is available for this Ruby version. -if ! gem list bundler -i &>/dev/null; then - gem install bundler --no-document -fi - -# Install gem dependencies for the linked workspace so rspec (and other gems) -# are available without needing an explicit `bundle install` step. -if [ -f "/code/Gemfile" ]; then - echo "Gemfile found — running bundle install..." - pushd /code - # TODO: Elaborate or expand — wire secrets (e.g. a private gem registry token) - # in from the environment instead of hardcoding. Compose's auto-loaded .env only - # does YAML interpolation (next to the compose file), not container injection; - # injecting needs `environment:`/`env_file:`. In the project-local model, point - # this at the mounted project's .safeclaude/.env so secrets live with the project. - BUNDLE_GEMS__GRAPHQL__PRO="${BUNDLE_GEMS__GRAPHQL__PRO:-}" bundle install - popd -fi - -# Install Claude Code if not already present in the home volume. -# Because the home directory is a volume, this install persists across -# container restarts and rebuilds. -if ! command -v claude &>/dev/null; then - echo "Claude Code not found — running installer..." - curl -fsSL https://claude.ai/install.sh | bash - echo "Claude Code installed successfully." -else - echo "Claude Code $(claude --version 2>/dev/null || echo '(version unknown)') ready." -fi - -# Proxy host postgres to 127.0.0.1:5432 inside the container so the app can -# use the same DATABASE_URL whether running inside or outside Docker. -if ! ss -tlnp 2>/dev/null | grep -q ':5432'; then - echo "Starting postgres proxy 127.0.0.1:5432 -> host.docker.internal:5432" - socat TCP-LISTEN:5432,bind=127.0.0.1,fork,reuseaddr \ - TCP:host.docker.internal:5432 & -fi - -exec "$@" diff --git a/example/safeclaude b/example/safeclaude deleted file mode 100755 index 4f123f5..0000000 --- a/example/safeclaude +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -lt 1 ]]; then - echo "Usage: $(basename "$0") [claude-args...]" >&2 - exit 1 -fi - -PROJECT_DIR="$(cd "$1" && pwd)" # resolve to absolute path -shift - -SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" - -PROJECT_DIR="$PROJECT_DIR" docker compose -f "$SCRIPT_DIR/docker-compose.yml" run -w /code --rm claude-code claude "$@" diff --git a/safeclaude b/safeclaude new file mode 100755 index 0000000..ab990b4 --- /dev/null +++ b/safeclaude @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# safeclaude — run Claude in a locked-down container, one per project. +# +# Like nvm/pyenv, but for whole environments: each project keeps its setup in a +# .safeclaude/ folder. We find it, build the container once, then reuse it. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +BASE_IMAGE="safeclaude-base:latest" + +die() { echo "safeclaude: $*" >&2; exit 1; } + +usage() { + cat >&2 <<'EOF' +safeclaude — run Claude in a locked-down container, one per project + +Usage: + safeclaude [PATH] [claude-args...] Launch Claude for a project (default: current dir) + safeclaude build [PATH] Rebuild the project's container from scratch + safeclaude init [PATH] Create a starter .safeclaude/ folder + safeclaude envs List the containers and saved data safeclaude made + safeclaude help Show this help + +A project is any folder (or a parent of it) that has a .safeclaude/ folder. +EOF +} + +# Turn a folder name into something Docker is happy to use as a name. +proj_name() { basename "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-'; } + +# Look in this folder, then its parents, for a project (one that has a +# .safeclaude/) — so you can launch from a subdirectory and still find it. +find_project_root() { + local dir; dir="$(cd "$1" 2>/dev/null && pwd)" || return 1 + while [ -n "$dir" ] && [ "$dir" != "/" ]; do + [ -f "$dir/.safeclaude/Dockerfile" ] && { echo "$dir"; return 0; } + dir="$(dirname "$dir")" + done + [ -f "/.safeclaude/Dockerfile" ] && { echo "/"; return 0; } + return 1 +} + +# Fingerprint the project's setup so we can tell when it changed. If the +# fingerprint matches what we already built, we skip rebuilding. (We leave .env +# out on purpose — changing a secret shouldn't force a rebuild.) +context_hash() { + local sc="$1/.safeclaude" + { + find "$sc" -type f -not -name '.env' -not -name '.env.*' -print0 \ + | sort -z | xargs -0 sha256sum + docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true + } | sha256sum | cut -c1-12 +} + +ensure_base() { + docker image inspect "$BASE_IMAGE" &>/dev/null && return 0 + echo "[safeclaude] building base image $BASE_IMAGE ..." >&2 + docker build -t "$BASE_IMAGE" -f "$SCRIPT_DIR/Dockerfile.base" "$SCRIPT_DIR" >&2 +} + +# Build the project's container if we haven't already (force=1 rebuilds it +# regardless). Prints the name of the image so the caller can run it. +ensure_project_image() { + local proj="$1" force="${2:-0}" image + image="safeclaude-$(proj_name "$proj"):$(context_hash "$proj")" + if [ "$force" = "1" ] || ! docker image inspect "$image" &>/dev/null; then + echo "[safeclaude] building $image ..." >&2 + docker build -t "$image" "$proj/.safeclaude" >&2 + fi + echo "$image" +} + +cmd_run() { + local target="${1:-.}"; [ $# -ge 1 ] && shift || true + # If the first argument isn't a folder, it was meant for claude — so hand it + # back to claude and use the current directory as the project. + if [ ! -d "$target" ]; then set -- "$target" "$@"; target="."; fi + + local proj; proj="$(find_project_root "$target")" \ + || die "no .safeclaude/ found in '$target' or any parent — run: safeclaude init" + + ensure_base + local image; image="$(ensure_project_image "$proj")" + + local home_vol="safeclaude-home-$(proj_name "$proj")" + local env_args=() tty_args=() + [ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env") + [ -t 0 ] && tty_args=(-it) + + exec docker run --rm "${tty_args[@]}" \ + --user 1001:1001 \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --add-host host.docker.internal:host-gateway \ + -v "${home_vol}:/home/coder" \ + -v "${proj}:/code" \ + -w /code \ + "${env_args[@]}" \ + "$image" claude "$@" +} + +cmd_build() { + local proj; proj="$(find_project_root "${1:-.}")" \ + || die "no .safeclaude/ found in '${1:-.}' or any parent — run: safeclaude init" + ensure_base + ensure_project_image "$proj" 1 >/dev/null + echo "[safeclaude] env ready for $(proj_name "$proj")" +} + +cmd_envs() { + echo "Built env images:" + docker images --filter 'reference=safeclaude-*' \ + --format ' {{.Repository}}:{{.Tag}} ({{.Size}}, {{.CreatedSince}})' || true + echo + echo "Home volumes:" + docker volume ls --filter 'name=safeclaude-home-' --format ' {{.Name}}' || true +} + +cmd_init() { + local target="${1:-.}" + [ -d "$target" ] || die "not a directory: $target" + local sc; sc="$(cd "$target" && pwd)/.safeclaude" + [ -e "$sc" ] && die ".safeclaude/ already exists at $sc" + mkdir -p "$sc/hooks" + + cat > "$sc/Dockerfile" <<'EOF' +# safeclaude builds this on top of its shared base. Everything here happens once +# and is cached, so it won't slow down your day-to-day launches. +FROM safeclaude-base:latest + +# Add the system packages and language versions your project needs below. +# (You're root during the build, so apt just works.) +# +# Example — Ruby + Postgres client + headless Chrome (see the repo's example/): +# +# RUN apt-get update && apt-get install -y --no-install-recommends \ +# build-essential libssl-dev libreadline-dev zlib1g-dev \ +# libffi-dev libyaml-dev libpq-dev socat chromium chromium-driver \ +# && rm -rf /var/lib/apt/lists/* +# +# Tip: tools that need to stick around between runs (rbenv, nvm, installed gems) +# should be set up in hooks/, not here — the home folder is swapped in fresh each +# run, so anything installed into it during the build would just disappear. +EOF + + cat > "$sc/hooks/README.md" <<'EOF' +# Hooks + +Each `*.sh` file here runs when the container starts, with your project available +at `/code`. They run in name order, so prefix them with numbers (`10-`, `20-`) +to control the sequence. + +Because they run every launch, keep them quick and make them safe to re-run — +check whether the work is already done before doing it. If one fails, startup +stops rather than continuing half-configured. + +Edits take effect on the next launch (no rebuild needed). Rename a +`*.sh.example` file to `*.sh` to turn it on. +EOF + + cat > "$sc/hooks/10-deps.sh.example" <<'EOF' +#!/bin/bash +# Installs your dependencies, but only when they've actually changed: it +# remembers the last lockfile it saw (saved between runs) and skips if it matches. +set -euo pipefail + +install_if_changed() { + local lockfile="$1"; shift + [ -f "$lockfile" ] || return 0 + local marker="$HOME/.safeclaude-deps/$(echo "$lockfile" | tr / _).sha" + mkdir -p "$(dirname "$marker")" + local cur; cur="$(sha256sum "$lockfile" | cut -d' ' -f1)" + if [ "$(cat "$marker" 2>/dev/null || true)" != "$cur" ]; then + echo "[deps] $lockfile changed — installing..." + "$@" && echo "$cur" > "$marker" + fi +} + +# Uncomment the ones your project uses: +# install_if_changed /code/Gemfile.lock bash -lc 'cd /code && bundle install' +# install_if_changed /code/package-lock.json bash -lc 'cd /code && npm ci' +# install_if_changed /code/requirements.txt bash -lc 'pip install -r /code/requirements.txt' +EOF + + cat > "$sc/.env.example" <<'EOF' +# Put secrets here, then copy this file to .env (which stays out of git). +# Anything in .env is handed to the container when it starts — for example, a +# token for a private gem source: +# BUNDLE_GEMS__GRAPHQL__PRO=user:token +EOF + + cat > "$sc/.gitignore" <<'EOF' +.env +EOF + + echo "[safeclaude] created $sc" + echo " - edit Dockerfile to add the packages your project needs" + echo " - turn on a hook: mv hooks/10-deps.sh.example hooks/10-deps.sh" + echo " - then run: safeclaude $target" +} + +main() { + local cmd="${1:-run}" + case "$cmd" in + build) shift; cmd_build "${1:-.}" ;; + init) shift; cmd_init "${1:-.}" ;; + envs|ls|list) cmd_envs ;; + help|-h|--help) usage ;; + run) shift; cmd_run "$@" ;; + *) cmd_run "$@" ;; # default: PATH and/or claude args + esac +} + +main "$@"