diff --git a/Dockerfile.base b/Dockerfile.base index a280273..4274051 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -5,18 +5,24 @@ # 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 +# +# Debian (slim) as the base: small, stable, and its apt packages behave +# predictably in a container — notably `chromium` is a real package here, unlike +# on Ubuntu where it's a snap stub that won't run in a container. +FROM debian:bookworm-slim # Just the basics every project needs: # - curl/ca-certificates: for downloads (the Claude installer, git-spice) # - git + ripgrep: required by Claude Code +# We leave apt's package lists in place (no cleanup) so a project Dockerfile can +# install packages without re-fetching them — though it should still run its own +# `apt-get update` first, since this base image may be days or weeks old. RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ ca-certificates \ git \ ripgrep \ - bash \ - && rm -rf /var/lib/apt/lists/* + bash # --- 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. diff --git a/README.md b/README.md index e10c486..08a47a9 100644 --- a/README.md +++ b/README.md @@ -42,20 +42,26 @@ so you can launch from a subdirectory and it'll still find the right one. .safeclaude/ Dockerfile # which system packages / language versions this project needs hooks/ # setup scripts that run each time the container starts + cache/ # scratch space on the host, gitignored (dependencies, downloads…) .env # secrets, kept out of git (.env.example shows the format) + version # the safeclaude version this config was created with ``` 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` — + installs (system packages, a pinned language version) 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. +- **Hooks are safe to run every time.** They check before doing work, so a + launch with nothing to do is near-instant. +- **`cache/` is your scratch space.** It lives on the host and is gitignored, so + it survives rebuilds and `docker volume` resets without ending up in your repo + — a good home for installed dependencies, downloads, or "already did this" + markers. See [`example/`](example/) for a real, filled-in Ruby + Postgres setup you can copy from. diff --git a/example/.safeclaude/.gitignore b/example/.safeclaude/.gitignore index 4c49bd7..dd7d4ab 100644 --- a/example/.safeclaude/.gitignore +++ b/example/.safeclaude/.gitignore @@ -1 +1,2 @@ .env +cache/ diff --git a/example/.safeclaude/Dockerfile b/example/.safeclaude/Dockerfile index 2dd06a9..3814e68 100644 --- a/example/.safeclaude/Dockerfile +++ b/example/.safeclaude/Dockerfile @@ -5,27 +5,46 @@ 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). +# database (socat), run browser tests (headless Chrome), and unpack the Node +# download below (xz-utils). 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/* + xz-utils # 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" +# --- Ruby (one pinned version) --- +# A project only ever needs one Ruby, so we install it straight into /usr/local +# instead of running a version manager. ruby-build (the tool rbenv uses under the +# hood) does the download + compile. To change versions, bump RUBY_VERSION and +# rebuild with `safeclaude build`. +ARG RUBY_VERSION=3.3.6 +RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git /tmp/ruby-build && \ + PREFIX=/usr/local /tmp/ruby-build/install.sh && \ + rm -rf /tmp/ruby-build && \ + ruby-build "$RUBY_VERSION" /usr/local && \ + gem install bundler --no-document -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 +# Gems install into the project's cache folder (which lives on the host), so they +# persist between runs and survive container/volume resets. The bundle hook +# relies on this too. BUNDLE_PATH is set here so both the hook and your app see it. +ENV BUNDLE_PATH=/code/.safeclaude/cache/bundle + +# --- Node (one pinned version) --- +# Same idea: download one Node and unpack it into /usr/local. Bump NODE_VERSION +# and rebuild to change it. +ARG NODE_VERSION=22.11.0 +RUN arch="$(uname -m)" && \ + case "$arch" in \ + x86_64) narch=x64 ;; \ + aarch64) narch=arm64 ;; \ + *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ + esac && \ + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${narch}.tar.xz" \ + | tar -xJ -C /usr/local --strip-components=1 diff --git a/example/.safeclaude/hooks/10-ruby.sh b/example/.safeclaude/hooks/10-ruby.sh deleted file mode 100755 index d890e98..0000000 --- a/example/.safeclaude/hooks/10-ruby.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/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 deleted file mode 100644 index 8a33b7e..0000000 --- a/example/.safeclaude/hooks/15-node.sh.example +++ /dev/null @@ -1,15 +0,0 @@ -#!/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 index 1e35dc3..c7358fa 100755 --- a/example/.safeclaude/hooks/20-bundle.sh +++ b/example/.safeclaude/hooks/20-bundle.sh @@ -1,15 +1,19 @@ #!/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. +# version it installed, so an unchanged lockfile is a near-instant no-op. +# +# Both the gems (via BUNDLE_PATH, set in the Dockerfile) and the marker below +# live in /code/.safeclaude/cache — on the host, so they persist between runs +# and stay out of git. Ruby is already on PATH from the Dockerfile, so there's +# no version manager to initialize here. set -euo pipefail [ -f /code/Gemfile ] || exit 0 -eval "$(rbenv init - bash)" +CACHE=/code/.safeclaude/cache +mkdir -p "$CACHE" LOCK=/code/Gemfile.lock -MARKER="$HOME/.safeclaude-deps/gemfile.sha" -mkdir -p "$(dirname "$MARKER")" +MARKER="$CACHE/gemfile.sha" CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )" if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then diff --git a/example/.safeclaude/version b/example/.safeclaude/version new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/example/.safeclaude/version @@ -0,0 +1 @@ +0.1.0 diff --git a/example/README.md b/example/README.md index 919d8bf..2095890 100644 --- a/example/README.md +++ b/example/README.md @@ -1,8 +1,8 @@ # 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. +A filled-in `.safeclaude/` for a typical Rails-style app — a pinned Ruby and +Node, 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 @@ -16,13 +16,18 @@ you can copy from when setting up your own project. | 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) | +| `.safeclaude/Dockerfile` | once, then cached | installs system packages, a pinned Ruby + Node, Chrome, and bundler | | `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) | +| `version` | — | the safeclaude version this config was created with | -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**. +A couple of things to take away: + +- Slow, one-time installs go in the **Dockerfile** so they're cached. Pin one + Ruby and one Node there directly — a project only needs one of each, so a + version manager would just be overhead. Anything that needs your code present, + or has to stick around between runs, goes in a **hook**. +- `cache/` is the project's scratch space, on the host and gitignored. Here the + gems install into it (`BUNDLE_PATH`), so they survive container and volume + resets without ever touching your repo. diff --git a/safeclaude b/safeclaude index ab990b4..26c298d 100755 --- a/safeclaude +++ b/safeclaude @@ -5,6 +5,7 @@ # .safeclaude/ folder. We find it, build the container once, then reuse it. set -euo pipefail +SAFECLAUDE_VERSION="0.1.0" SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" BASE_IMAGE="safeclaude-base:latest" @@ -19,6 +20,7 @@ Usage: 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 version Print the safeclaude version safeclaude help Show this help A project is any folder (or a parent of it) that has a .safeclaude/ folder. @@ -41,12 +43,13 @@ find_project_root() { } # 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.) +# fingerprint matches what we already built, we skip rebuilding. We skip .env +# (changing a secret shouldn't force a rebuild) and cache/ (it's scratch space +# the hooks write to constantly — hashing it would rebuild on every run). context_hash() { local sc="$1/.safeclaude" { - find "$sc" -type f -not -name '.env' -not -name '.env.*' -print0 \ + find "$sc" -type f -not -name '.env' -not -name '.env.*' -not -path "$sc/cache/*" -print0 \ | sort -z | xargs -0 sha256sum docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true } | sha256sum | cut -c1-12 @@ -87,6 +90,19 @@ cmd_run() { [ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env") [ -t 0 ] && tty_args=(-it) + # Tell the inner Claude where it is and what it can/can't do. The host path is + # included so it can give the developer exact instructions. Built here (not in + # the container) because only the launcher knows the real host path. + local sys_prompt + sys_prompt="You are running inside safeclaude, a sandboxed Docker container — not directly on the host machine. + +- This project is mounted at /code. On the host it lives at: ${proj} +- You run as the non-root user 'coder'. You cannot use sudo or install system packages. +- The .safeclaude/ directory configures this container. NEVER edit anything under .safeclaude/ yourself — instead, tell the developer exactly what to change and have them do it on the host. +- To add a system package: ask the developer to add it to .safeclaude/Dockerfile and run 'safeclaude build' on the host. +- To add setup that runs at container startup: ask the developer to add a script to .safeclaude/hooks/. +- /home/coder persists between runs. Edits under /code are real and shared with the host. Everything else is discarded when the container exits." + exec docker run --rm "${tty_args[@]}" \ --user 1001:1001 \ --cap-drop ALL \ @@ -96,7 +112,7 @@ cmd_run() { -v "${proj}:/code" \ -w /code \ "${env_args[@]}" \ - "$image" claude "$@" + "$image" claude --append-system-prompt "$sys_prompt" "$@" } cmd_build() { @@ -121,7 +137,10 @@ cmd_init() { [ -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" + mkdir -p "$sc/hooks" "$sc/cache" + + # Record which safeclaude version created this, for future reference. + echo "$SAFECLAUDE_VERSION" > "$sc/version" cat > "$sc/Dockerfile" <<'EOF' # safeclaude builds this on top of its shared base. Everything here happens once @@ -135,12 +154,11 @@ FROM safeclaude-base:latest # # 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/* +# libffi-dev libyaml-dev libpq-dev socat chromium chromium-driver # -# 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. +# Tip: pin specific language versions here (install one Ruby, one Node, etc.) +# rather than a version manager — a project only needs one. See the repo's +# example/ for how. EOF cat > "$sc/hooks/README.md" <<'EOF' @@ -158,28 +176,24 @@ 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' + cat > "$sc/hooks/10-setup.sh" <<'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. +# Runs every time the container starts, with your project at /code. Put your +# project's startup setup here — installing dependencies, preparing services, +# and so on. It's empty by default; add what you need. +# +# Keep it safe to run every launch: check whether something is already done +# before doing it, so repeat launches stay quick. +# +# Need to keep things between runs? Write them to /code/.safeclaude/cache. That +# folder lives on the host (not inside the container), so it survives rebuilds +# and `docker volume` resets, and it's gitignored so it won't land in your repo. +# It's a good home for installed dependencies, downloads, or "already did this" +# markers. +# +# For example, you might install gems into the cache, run `npm install`, or wait +# for a service to come up. See the repo's example/ for a worked version. 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' @@ -191,11 +205,12 @@ EOF cat > "$sc/.gitignore" <<'EOF' .env +cache/ 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 " - edit Dockerfile to add the packages and language versions you need" + echo " - edit hooks/10-setup.sh for any startup setup" echo " - then run: safeclaude $target" } @@ -205,6 +220,7 @@ main() { build) shift; cmd_build "${1:-.}" ;; init) shift; cmd_init "${1:-.}" ;; envs|ls|list) cmd_envs ;; + version|-v|--version) echo "safeclaude $SAFECLAUDE_VERSION" ;; help|-h|--help) usage ;; run) shift; cmd_run "$@" ;; *) cmd_run "$@" ;; # default: PATH and/or claude args