Compare commits

...

5 Commits

17 changed files with 302 additions and 191 deletions

View File

@ -5,18 +5,24 @@
# This image runs as root so project Dockerfiles can install system packages # 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, # 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. # 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: # Just the basics every project needs:
# - curl/ca-certificates: for downloads (the Claude installer, git-spice) # - curl/ca-certificates: for downloads (the Claude installer, git-spice)
# - git + ripgrep: required by Claude Code # - 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
ca-certificates \ ca-certificates \
git \ git \
ripgrep \ ripgrep \
bash \ bash
&& rm -rf /var/lib/apt/lists/*
# --- git-spice (a tool for stacked pull requests, handy in the Claude workflow) --- # --- git-spice (a tool for stacked pull requests, handy in the Claude workflow) ---
# Releases are named git-spice.Linux-<arch>.tar.gz, and `uname -m` gives the arch. # Releases are named git-spice.Linux-<arch>.tar.gz, and `uname -m` gives the arch.
@ -31,9 +37,18 @@ RUN ARCH=$(uname -m) && \
rm -rf /tmp/gs-install rm -rf /tmp/gs-install
# --- the user Claude runs as --- # --- the user Claude runs as ---
# We create this user so the home folder is owned by the same ID the launcher # We create 'coder' with the *host* user's UID/GID (passed in by the launcher).
# runs as. Without it, the container couldn't write to its own home. # This is what makes the bind-mounted project at /code writable: the files there
RUN useradd -m -s /bin/bash -u 1001 coder # 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. # Claude installs itself into one of these folders, so add them to PATH.
ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH" ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH"

View File

@ -28,13 +28,15 @@ safeclaude # launch Claude in this project's container
| Command | What it does | | Command | What it does |
| --- | --- | | --- | --- |
| `safeclaude [PATH] [claude-args...]` | Launch Claude for a project (default: current dir). Anything extra is passed straight to `claude`. | | `safeclaude [claude-args...]` | Launch Claude for the project here. Anything extra is passed straight to `claude`. |
| `safeclaude build [PATH]` | Rebuild the project's container from scratch. | | `safeclaude build` | Rebuild the project's container from scratch. |
| `safeclaude init [PATH]` | Create a starter `.safeclaude/` folder. | | `safeclaude init` | Create a starter `.safeclaude/` in the current directory. |
| `safeclaude envs` | List the containers and stored data safeclaude has created. | | `safeclaude envs` | List the containers and stored data safeclaude has created. |
| `safeclaude version` | Print the safeclaude version. |
A "project" is any folder (or a parent of it) that has a `.safeclaude/` folder, safeclaude always works on the project you're in — the current directory, or the
so you can launch from a subdirectory and it'll still find the right one. nearest parent that has a `.safeclaude/` folder, so launching from a
subdirectory still finds the right one.
## What goes in `.safeclaude/` ## What goes in `.safeclaude/`
@ -42,20 +44,31 @@ so you can launch from a subdirectory and it'll still find the right one.
.safeclaude/ .safeclaude/
Dockerfile # which system packages / language versions this project needs Dockerfile # which system packages / language versions this project needs
hooks/ # setup scripts that run each time the container starts 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) .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: A few things worth knowing:
- **Two places setup can live, and the difference matters.** Slow, one-time - **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 these get cached, so they don't repeat. Anything that needs your actual code
present, or that should persist between runs (installing dependencies, present, or that should persist between runs (installing dependencies,
starting a database proxy), goes in a `hooks/` script that runs at launch. starting a database proxy), goes in a `hooks/` script that runs at launch.
- **It only rebuilds when something changed.** safeclaude remembers what it - **It only rebuilds when something changed.** safeclaude remembers what it
already built, so a normal launch starts right up with no waiting. 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 - **Hooks are safe to run every time.** They check before doing work, so a
starter dependency hook only reinstalls when your lockfile actually changed. 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 See [`example/`](example/) for a real, filled-in Ruby + Postgres setup you can
copy from. copy from.

View File

@ -1 +1,2 @@
.env .env
cache/

View File

@ -5,27 +5,46 @@
FROM safeclaude-base:latest FROM safeclaude-base:latest
# System packages: what's needed to build Ruby, talk to Postgres, proxy the # 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \ libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \
libpq-dev \ libpq-dev \
socat \ socat \
chromium chromium-driver \ chromium chromium-driver \
&& rm -rf /var/lib/apt/lists/* xz-utils
# Capybara/Selenium look for Chrome at these paths. # Capybara/Selenium look for Chrome at these paths.
ENV CHROME_BIN=/usr/bin/chromium ENV CHROME_BIN=/usr/bin/chromium
ENV CHROMEDRIVER=/usr/bin/chromedriver ENV CHROMEDRIVER=/usr/bin/chromedriver
# rbenv and nvm get installed by the hooks at launch instead of here, because # --- Ruby (one pinned version) ---
# they live in the home folder — and that folder is swapped in fresh each run, so # A project only ever needs one Ruby, so we install it straight into /usr/local
# anything we installed there now would just be thrown away. Here we only set the # instead of running a version manager. ruby-build (the tool rbenv uses under the
# paths and shell setup; the two lines below do nothing until a hook installs the # hood) does the download + compile. To change versions, bump RUBY_VERSION and
# matching tool. # rebuild with `safeclaude build`.
ENV RBENV_ROOT=/home/coder/.rbenv ARG RUBY_VERSION=3.3.6
ENV NVM_DIR=/home/coder/.nvm RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git /tmp/ruby-build && \
ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH" 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 && \ # Gems install into the project's cache folder (which lives on the host), so they
echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc # 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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -1,15 +1,19 @@
#!/bin/bash #!/bin/bash
# Installs gems, but only when Gemfile.lock has changed: it remembers the last # 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 # version it installed, so an unchanged lockfile is a near-instant no-op.
# 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 set -euo pipefail
[ -f /code/Gemfile ] || exit 0 [ -f /code/Gemfile ] || exit 0
eval "$(rbenv init - bash)"
CACHE=/code/.safeclaude/cache
mkdir -p "$CACHE"
LOCK=/code/Gemfile.lock LOCK=/code/Gemfile.lock
MARKER="$HOME/.safeclaude-deps/gemfile.sha" MARKER="$CACHE/gemfile.sha"
mkdir -p "$(dirname "$MARKER")"
CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )" CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )"
if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then

View File

@ -0,0 +1 @@
0.1.0

View File

@ -1,8 +1,8 @@
# Example: a Ruby + Postgres project # Example: a Ruby + Postgres project
A filled-in `.safeclaude/` for a typical Rails-style app — Ruby (via rbenv), the A filled-in `.safeclaude/` for a typical Rails-style app — a pinned Ruby and
Postgres client, and headless Chrome for browser tests. It's here as a reference Node, the Postgres client, and headless Chrome for browser tests. It's here as a
you can copy from when setting up your own project. reference you can copy from when setting up your own project.
## How to use it ## How to use it
@ -16,13 +16,19 @@ you can copy from when setting up your own project.
| File | When it runs | What it does | | File | When it runs | What it does |
| --- | --- | --- | | --- | --- | --- |
| `.safeclaude/Dockerfile` | once, then cached | installs system packages, sets up Chrome and the Ruby/Node version managers | | `.safeclaude/Dockerfile` | once, then cached | installs system packages, a pinned Ruby + Node, Chrome, and bundler |
| `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/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/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) | | `.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) |
The pattern to take away: slow, one-time installs go in the **Dockerfile** so A couple of things to take away:
they're cached; anything that needs your code or has to stick around between
runs goes in a **hook**. - 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.

View File

@ -5,7 +5,9 @@
# .safeclaude/ folder. We find it, build the container once, then reuse it. # .safeclaude/ folder. We find it, build the container once, then reuse it.
set -euo pipefail set -euo pipefail
SAFECLAUDE_VERSION="0.1.0"
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
SKELETON_DIR="$SCRIPT_DIR/skeleton"
BASE_IMAGE="safeclaude-base:latest" BASE_IMAGE="safeclaude-base:latest"
die() { echo "safeclaude: $*" >&2; exit 1; } die() { echo "safeclaude: $*" >&2; exit 1; }
@ -15,18 +17,28 @@ usage() {
safeclaude — run Claude in a locked-down container, one per project safeclaude — run Claude in a locked-down container, one per project
Usage: Usage:
safeclaude [PATH] [claude-args...] Launch Claude for a project (default: current dir) safeclaude [claude-args...] Launch Claude for the project here (args pass to claude)
safeclaude build [PATH] Rebuild the project's container from scratch safeclaude build Rebuild the project's container from scratch
safeclaude init [PATH] Create a starter .safeclaude/ folder safeclaude init Create a starter .safeclaude/ in the current directory
safeclaude envs List the containers and saved data safeclaude made safeclaude envs List the containers and saved data safeclaude made
safeclaude version Print the safeclaude version
safeclaude help Show this help safeclaude help Show this help
A project is any folder (or a parent of it) that has a .safeclaude/ folder. Run from inside a project — the current directory, or the nearest parent that
has a .safeclaude/ folder.
EOF EOF
} }
# Turn a folder name into something Docker is happy to use as a name. # Turn a folder name into something Docker is happy to use as a name fragment:
proj_name() { basename "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-'; } # 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 # 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. # .safeclaude/) — so you can launch from a subdirectory and still find it.
@ -41,21 +53,37 @@ find_project_root() {
} }
# Fingerprint the project's setup so we can tell when it changed. If the # 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 # fingerprint matches what we already built, we skip rebuilding. We skip .env
# out on purpose — changing a secret shouldn't force a rebuild.) # (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() { context_hash() {
local sc="$1/.safeclaude" 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 | sort -z | xargs -0 sha256sum
docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true
} | sha256sum | cut -c1-12 } | 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() { ensure_base() {
docker image inspect "$BASE_IMAGE" &>/dev/null && return 0 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 echo "[safeclaude] building base image $BASE_IMAGE ..." >&2
docker build -t "$BASE_IMAGE" -f "$SCRIPT_DIR/Dockerfile.base" "$SCRIPT_DIR" >&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 # Build the project's container if we haven't already (force=1 rebuilds it
@ -71,13 +99,9 @@ ensure_project_image() {
} }
cmd_run() { cmd_run() {
local target="${1:-.}"; [ $# -ge 1 ] && shift || true # Always operate on the current directory's project; every argument is for claude.
# If the first argument isn't a folder, it was meant for claude — so hand it local proj; proj="$(find_project_root ".")" \
# back to claude and use the current directory as the project. || die "no .safeclaude/ found here or in any parent — run: safeclaude init"
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 ensure_base
local image; image="$(ensure_project_image "$proj")" local image; image="$(ensure_project_image "$proj")"
@ -87,8 +111,25 @@ cmd_run() {
[ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env") [ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env")
[ -t 0 ] && tty_args=(-it) [ -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[@]}" \ exec docker run --rm "${tty_args[@]}" \
--user 1001:1001 \ --user "$(id -u):$(id -g)" \
--cap-drop ALL \ --cap-drop ALL \
--security-opt no-new-privileges:true \ --security-opt no-new-privileges:true \
--add-host host.docker.internal:host-gateway \ --add-host host.docker.internal:host-gateway \
@ -96,12 +137,12 @@ cmd_run() {
-v "${proj}:/code" \ -v "${proj}:/code" \
-w /code \ -w /code \
"${env_args[@]}" \ "${env_args[@]}" \
"$image" claude "$@" "$image" claude --append-system-prompt "$sys_prompt" "$@"
} }
cmd_build() { cmd_build() {
local proj; proj="$(find_project_root "${1:-.}")" \ local proj; proj="$(find_project_root ".")" \
|| die "no .safeclaude/ found in '${1:-.}' or any parent — run: safeclaude init" || die "no .safeclaude/ found here or in any parent — run: safeclaude init"
ensure_base ensure_base
ensure_project_image "$proj" 1 >/dev/null ensure_project_image "$proj" 1 >/dev/null
echo "[safeclaude] env ready for $(proj_name "$proj")" echo "[safeclaude] env ready for $(proj_name "$proj")"
@ -117,97 +158,33 @@ cmd_envs() {
} }
cmd_init() { cmd_init() {
local target="${1:-.}" local sc; sc="$(pwd)/.safeclaude"
[ -d "$target" ] || die "not a directory: $target"
local sc; sc="$(cd "$target" && pwd)/.safeclaude"
[ -e "$sc" ] && die ".safeclaude/ already exists at $sc" [ -e "$sc" ] && die ".safeclaude/ already exists at $sc"
mkdir -p "$sc/hooks" [ -d "$SKELETON_DIR" ] || die "skeleton not found at $SKELETON_DIR"
cat > "$sc/Dockerfile" <<'EOF' # Copy the template files (see skeleton/), then add the bits that are generated
# safeclaude builds this on top of its shared base. Everything here happens once # rather than templated: the cache dir and the version stamp.
# and is cached, so it won't slow down your day-to-day launches. mkdir -p "$sc"
FROM safeclaude-base:latest cp -R "$SKELETON_DIR/." "$sc/"
mkdir -p "$sc/cache"
# Add the system packages and language versions your project needs below. echo "$SAFECLAUDE_VERSION" > "$sc/version"
# (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 "[safeclaude] created $sc"
echo " - edit Dockerfile to add the packages your project needs" echo " - edit Dockerfile to add the packages and language versions you need"
echo " - turn on a hook: mv hooks/10-deps.sh.example hooks/10-deps.sh" echo " - edit hooks/10-setup.sh for any startup setup"
echo " - then run: safeclaude $target" echo " - then run: safeclaude"
} }
main() { main() {
local cmd="${1:-run}" local cmd="${1:-run}"
case "$cmd" in case "$cmd" in
build) shift; cmd_build "${1:-.}" ;; build) cmd_build ;;
init) shift; cmd_init "${1:-.}" ;; init) cmd_init ;;
envs|ls|list) cmd_envs ;; envs|ls|list) cmd_envs ;;
version|-v|--version) echo "safeclaude $SAFECLAUDE_VERSION" ;;
help|-h|--help) usage ;; help|-h|--help) usage ;;
run) shift; cmd_run "$@" ;; run) shift || true; cmd_run "$@" ;; # tolerate no args (bare `safeclaude`)
*) cmd_run "$@" ;; # default: PATH and/or claude args *) cmd_run "$@" ;; # default: all args go to claude
esac esac
} }

4
skeleton/.env.example Normal file
View File

@ -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

2
skeleton/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
cache/

16
skeleton/Dockerfile Normal file
View File

@ -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.

35
skeleton/README.md Normal file
View File

@ -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.

17
skeleton/hooks/10-setup.sh Executable file
View File

@ -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

12
skeleton/hooks/README.md Normal file
View File

@ -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.