Compare commits

...

7 Commits

22 changed files with 601 additions and 214 deletions

1
.gitignore vendored
View File

@ -1,2 +1 @@
.env .env
entrypoint.sh

View File

@ -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-<arch>.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"]

61
Dockerfile.base Normal file
View File

@ -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-<arch>.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"]

View File

@ -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 ```bash
# 1. Build the image # Put the launcher on your PATH (any dir on your PATH works)
docker compose build ln -s "$(readlink -f ./safeclaude)" ~/.local/bin/safeclaude
# 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 .
``` ```
## Security notes That's it — the first launch builds what it needs automatically.
- Runs as a non-root user (`coder`, uid 1001) ## Use
- 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
## 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.

View File

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

26
entrypoint.sh Executable file
View File

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

View File

@ -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:]' </code/.ruby-version)
elif [ -f "$RBENV_ROOT/version" ]; then
RUBY_VERSION=$(cat "$RBENV_ROOT/version")
else
RUBY_VERSION="3.3.6"
fi
# Install the Ruby version if it isn't already built.
if ! rbenv versions --bare 2>/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 "$@"

View File

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

2
example/.safeclaude/.gitignore vendored Normal file
View File

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

View File

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

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

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

View File

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

View File

@ -0,0 +1 @@
0.1.0

34
example/README.md Normal file
View File

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

View File

@ -1,14 +1,191 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
if [[ $# -lt 1 ]]; then SAFECLAUDE_VERSION="0.1.0"
echo "Usage: $(basename "$0") <path-to-project> [claude-args...]" >&2
exit 1
fi
PROJECT_DIR="$(cd "$1" && pwd)" # resolve to absolute path
shift
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"
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 "$@"

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.