From d2c2139d87d8b4ee4dd5f272b4b2f51b67faa6bb Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 20 Jun 2026 17:05:09 -0400 Subject: [PATCH] run as user outside container --- Dockerfile.base | 15 ++++++++++++--- safeclaude | 26 ++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Dockerfile.base b/Dockerfile.base index 4274051..4d0d458 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -37,9 +37,18 @@ RUN ARCH=$(uname -m) && \ rm -rf /tmp/gs-install # --- the user Claude runs as --- -# We create this user so the home folder is owned by the same ID the launcher -# runs as. Without it, the container couldn't write to its own home. -RUN useradd -m -s /bin/bash -u 1001 coder +# 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" diff --git a/safeclaude b/safeclaude index 536e795..e2db711 100755 --- a/safeclaude +++ b/safeclaude @@ -65,10 +65,25 @@ context_hash() { } | 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() { - docker image inspect "$BASE_IMAGE" &>/dev/null && return 0 - echo "[safeclaude] building base image $BASE_IMAGE ..." >&2 - docker build -t "$BASE_IMAGE" -f "$SCRIPT_DIR/Dockerfile.base" "$SCRIPT_DIR" >&2 + 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 @@ -110,8 +125,11 @@ cmd_run() { - 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 1001:1001 \ + --user "$(id -u):$(id -g)" \ --cap-drop ALL \ --security-opt no-new-privileges:true \ --add-host host.docker.internal:host-gateway \