diff --git a/.gitignore b/.gitignore index 4ab3d4f..4c49bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ .env -entrypoint.sh diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b7d0370..0000000 --- a/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/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000..4d0d458 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,61 @@ +# 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. +# +# 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 + +# --- 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 'coder' with the *host* user's UID/GID (passed in by the launcher). +# This is what makes the bind-mounted project at /code writable: the files there +# keep the host's ownership, so the container can only write them if it runs as +# that same ID. It also means files Claude creates come out owned by you on the +# host — not root or some stray ID. The launcher rebuilds this image if the host +# ID ever changes (it reads the labels below to notice), so the default here is +# just a placeholder for a from-scratch build. +ARG HOST_UID=1000 +ARG HOST_GID=1000 +RUN if ! getent group "${HOST_GID}" >/dev/null; then groupadd -g "${HOST_GID}" coder; fi && \ + useradd -m -s /bin/bash -u "${HOST_UID}" -g "${HOST_GID}" coder +LABEL safeclaude.uid="${HOST_UID}" safeclaude.gid="${HOST_GID}" + +# 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..4c7218b 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,88 @@ -# 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 [claude-args...]` | Launch Claude for the project here. Anything extra is passed straight to `claude`. | +| `safeclaude build` | Rebuild the project's container from scratch. | +| `safeclaude init` | Create a starter `.safeclaude/` in the current directory. | +| `safeclaude envs` | List the containers and stored data safeclaude has created. | +| `safeclaude version` | Print the safeclaude version. | -- Bundles need to be updated separately inside the container +safeclaude always works on the project you're in — the current directory, or the +nearest parent that has a `.safeclaude/` folder, so launching from a +subdirectory still finds 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 + 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 + README.md # how this environment works and how to change it +``` + +That `README.md` is also what the sandboxed Claude reads to learn how the +environment is set up — so it can tell you exactly what to change without +digging through your project. + +A few things worth knowing: + +- **Two places setup can live, and the difference matters.** Slow, one-time + 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, 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. + +## 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/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 6551c94..0000000 --- a/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/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/entrypoint.sh.example b/entrypoint.sh.example deleted file mode 100644 index f29fc3e..0000000 --- a/entrypoint.sh.example +++ /dev/null @@ -1,73 +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 - BUNDLE_GEMS__GRAPHQL__PRO="gql_2e0_d66f5103067:70c113ba329" 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/.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..dd7d4ab --- /dev/null +++ b/example/.safeclaude/.gitignore @@ -0,0 +1,2 @@ +.env +cache/ diff --git a/example/.safeclaude/Dockerfile b/example/.safeclaude/Dockerfile new file mode 100644 index 0000000..3814e68 --- /dev/null +++ b/example/.safeclaude/Dockerfile @@ -0,0 +1,50 @@ +# 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), 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 \ + xz-utils + +# Capybara/Selenium look for Chrome at these paths. +ENV CHROME_BIN=/usr/bin/chromium +ENV CHROMEDRIVER=/usr/bin/chromedriver + +# --- 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 + +# 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/README.md b/example/.safeclaude/README.md new file mode 100644 index 0000000..0add8b9 --- /dev/null +++ b/example/.safeclaude/README.md @@ -0,0 +1,35 @@ +# .safeclaude/ — this project's sandboxed environment + +This folder defines the container that `safeclaude` runs Claude in. The container +is built from these files, so changing the environment means editing them on the +host and rebuilding — not installing things inside the running container (which +is a non-root sandbox and gets reset each run). + +## What's here + +- `Dockerfile` — the container image: system packages and pinned language + versions (one Ruby, one Node, etc.). Built once, then cached. +- `hooks/*.sh` — scripts that run at every startup, with the project at `/code`. + Use these for setup that needs your code present or should run each launch + (installing dependencies, starting a service proxy). Keep them safe to re-run. +- `cache/` — scratch space on the host, gitignored. A good home for installed + dependencies, downloads, or "already did this" markers; survives rebuilds and + `docker volume` resets. +- `.env` — secrets passed into the container at runtime (gitignored; copy from + `.env.example`). +- `version` — the safeclaude version this config was created with. + +## How to change the environment + +The container runs as a non-root user with no sudo, so you can't install system +packages from inside it. Instead, edit these files on the host: + +- **Add a system package:** add it to `Dockerfile`, then run `safeclaude build`. +- **Add a language or tool:** install a specific version in `Dockerfile` — pin + it, since a project only needs one. See the repo's `example/` for a worked + Ruby + Node setup. +- **Run setup at startup:** add or edit a script in `hooks/` (no rebuild needed). +- **Add a secret:** put it in `.env` (see `.env.example`). + +After editing the `Dockerfile`, run `safeclaude build` to rebuild. Hook, `.env`, +and `cache/` changes take effect on the next launch with no rebuild. diff --git a/example/.safeclaude/hooks/20-bundle.sh b/example/.safeclaude/hooks/20-bundle.sh new file mode 100755 index 0000000..c7358fa --- /dev/null +++ b/example/.safeclaude/hooks/20-bundle.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Installs gems, but only when Gemfile.lock has changed: it remembers the last +# 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 + +CACHE=/code/.safeclaude/cache +mkdir -p "$CACHE" +LOCK=/code/Gemfile.lock +MARKER="$CACHE/gemfile.sha" +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/.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 new file mode 100644 index 0000000..2bced8b --- /dev/null +++ b/example/README.md @@ -0,0 +1,34 @@ +# Example: a Ruby + Postgres 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 + +- **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, 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` | +| `.env.example` | — | copy to `.env` for a private gem token (kept out of git) | +| `version` | — | the safeclaude version this config was created with | +| `README.md` | — | how this environment works (also read by the sandboxed Claude) | + +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 4f123f5..e2db711 100755 --- a/safeclaude +++ b/safeclaude @@ -1,14 +1,191 @@ #!/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 -if [[ $# -lt 1 ]]; then - echo "Usage: $(basename "$0") [claude-args...]" >&2 - exit 1 -fi - -PROJECT_DIR="$(cd "$1" && pwd)" # resolve to absolute path -shift - +SAFECLAUDE_VERSION="0.1.0" SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +SKELETON_DIR="$SCRIPT_DIR/skeleton" +BASE_IMAGE="safeclaude-base:latest" -PROJECT_DIR="$PROJECT_DIR" docker compose -f "$SCRIPT_DIR/docker-compose.yml" run -w /code --rm claude-code claude "$@" +die() { echo "safeclaude: $*" >&2; exit 1; } + +usage() { + cat >&2 <<'EOF' +safeclaude — run Claude in a locked-down container, one per project + +Usage: + safeclaude [claude-args...] Launch Claude for the project here (args pass to claude) + safeclaude build Rebuild the project's container from scratch + safeclaude init Create a starter .safeclaude/ in the current directory + safeclaude envs List the containers and saved data safeclaude made + safeclaude version Print the safeclaude version + safeclaude help Show this help + +Run from inside a project — the current directory, or the nearest parent that +has a .safeclaude/ folder. +EOF +} + +# Turn a folder name into something Docker is happy to use as a name fragment: +# lowercase, only [a-z0-9_.-], and no leading/trailing separators (Docker rejects +# those). We capture basename into a variable first so the trailing newline is +# gone before tr runs — otherwise tr -c turns that newline into a stray '-'. +proj_name() { + local n; n="$(basename "$1")" + n="$(printf '%s' "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-')" + n="$(printf '%s' "$n" | sed -e 's/^[-_.]*//' -e 's/[-_.]*$//')" + printf '%s' "${n:-project}" +} + +# 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 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.*' -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 +} + +# Build the shared base image, baking in the host user's UID/GID so the container +# can write the bind-mounted project (see Dockerfile.base). We rebuild if the +# image is missing, or if its baked-in IDs don't match the current host user — +# e.g. a different user runs safeclaude, or the repo moved to another machine. +ensure_base() { + local want_uid want_gid + want_uid="$(id -u)"; want_gid="$(id -g)" + if docker image inspect "$BASE_IMAGE" &>/dev/null; then + local have_uid have_gid + have_uid="$(docker image inspect --format '{{index .Config.Labels "safeclaude.uid"}}' "$BASE_IMAGE" 2>/dev/null || true)" + have_gid="$(docker image inspect --format '{{index .Config.Labels "safeclaude.gid"}}' "$BASE_IMAGE" 2>/dev/null || true)" + [ "$have_uid" = "$want_uid" ] && [ "$have_gid" = "$want_gid" ] && return 0 + echo "[safeclaude] base image user ${have_uid:-?}:${have_gid:-?} != host $want_uid:$want_gid — rebuilding ..." >&2 + else + echo "[safeclaude] building base image $BASE_IMAGE ..." >&2 + fi + docker build -t "$BASE_IMAGE" \ + --build-arg HOST_UID="$want_uid" --build-arg HOST_GID="$want_gid" \ + -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() { + # Always operate on the current directory's project; every argument is for claude. + local proj; proj="$(find_project_root ".")" \ + || die "no .safeclaude/ found here or in 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) + + # 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. +- How this environment is set up and how to change it is documented in /code/.safeclaude/README.md — read it before advising on environment changes. +- 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." + + # Run as the host user so writes to the mounted /code (and its cache/) land with + # the right ownership. The base image creates this exact UID/GID as 'coder', so + # HOME and the home volume resolve correctly. Still non-root, still locked down. + exec docker run --rm "${tty_args[@]}" \ + --user "$(id -u):$(id -g)" \ + --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 --append-system-prompt "$sys_prompt" "$@" +} + +cmd_build() { + local proj; proj="$(find_project_root ".")" \ + || die "no .safeclaude/ found here or in 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 sc; sc="$(pwd)/.safeclaude" + [ -e "$sc" ] && die ".safeclaude/ already exists at $sc" + [ -d "$SKELETON_DIR" ] || die "skeleton not found at $SKELETON_DIR" + + # Copy the template files (see skeleton/), then add the bits that are generated + # rather than templated: the cache dir and the version stamp. + mkdir -p "$sc" + cp -R "$SKELETON_DIR/." "$sc/" + mkdir -p "$sc/cache" + echo "$SAFECLAUDE_VERSION" > "$sc/version" + + echo "[safeclaude] created $sc" + 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" +} + +main() { + local cmd="${1:-run}" + case "$cmd" in + build) cmd_build ;; + init) cmd_init ;; + envs|ls|list) cmd_envs ;; + version|-v|--version) echo "safeclaude $SAFECLAUDE_VERSION" ;; + help|-h|--help) usage ;; + run) shift || true; cmd_run "$@" ;; # tolerate no args (bare `safeclaude`) + *) cmd_run "$@" ;; # default: all args go to claude + esac +} + +main "$@" diff --git a/skeleton/.env.example b/skeleton/.env.example new file mode 100644 index 0000000..063145d --- /dev/null +++ b/skeleton/.env.example @@ -0,0 +1,4 @@ +# 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 diff --git a/skeleton/.gitignore b/skeleton/.gitignore new file mode 100644 index 0000000..dd7d4ab --- /dev/null +++ b/skeleton/.gitignore @@ -0,0 +1,2 @@ +.env +cache/ diff --git a/skeleton/Dockerfile b/skeleton/Dockerfile new file mode 100644 index 0000000..bf6acc9 --- /dev/null +++ b/skeleton/Dockerfile @@ -0,0 +1,16 @@ +# 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 +# +# 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. diff --git a/skeleton/README.md b/skeleton/README.md new file mode 100644 index 0000000..0add8b9 --- /dev/null +++ b/skeleton/README.md @@ -0,0 +1,35 @@ +# .safeclaude/ — this project's sandboxed environment + +This folder defines the container that `safeclaude` runs Claude in. The container +is built from these files, so changing the environment means editing them on the +host and rebuilding — not installing things inside the running container (which +is a non-root sandbox and gets reset each run). + +## What's here + +- `Dockerfile` — the container image: system packages and pinned language + versions (one Ruby, one Node, etc.). Built once, then cached. +- `hooks/*.sh` — scripts that run at every startup, with the project at `/code`. + Use these for setup that needs your code present or should run each launch + (installing dependencies, starting a service proxy). Keep them safe to re-run. +- `cache/` — scratch space on the host, gitignored. A good home for installed + dependencies, downloads, or "already did this" markers; survives rebuilds and + `docker volume` resets. +- `.env` — secrets passed into the container at runtime (gitignored; copy from + `.env.example`). +- `version` — the safeclaude version this config was created with. + +## How to change the environment + +The container runs as a non-root user with no sudo, so you can't install system +packages from inside it. Instead, edit these files on the host: + +- **Add a system package:** add it to `Dockerfile`, then run `safeclaude build`. +- **Add a language or tool:** install a specific version in `Dockerfile` — pin + it, since a project only needs one. See the repo's `example/` for a worked + Ruby + Node setup. +- **Run setup at startup:** add or edit a script in `hooks/` (no rebuild needed). +- **Add a secret:** put it in `.env` (see `.env.example`). + +After editing the `Dockerfile`, run `safeclaude build` to rebuild. Hook, `.env`, +and `cache/` changes take effect on the next launch with no rebuild. diff --git a/skeleton/hooks/10-setup.sh b/skeleton/hooks/10-setup.sh new file mode 100755 index 0000000..eafd3a5 --- /dev/null +++ b/skeleton/hooks/10-setup.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# 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 diff --git a/skeleton/hooks/README.md b/skeleton/hooks/README.md new file mode 100644 index 0000000..e513e9c --- /dev/null +++ b/skeleton/hooks/README.md @@ -0,0 +1,12 @@ +# 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.