From 88903a45e051dfab56b430435353c45dd8c331c7 Mon Sep 17 00:00:00 2001 From: Jaxxen Date: Fri, 25 Sep 2026 04:31:56 +0000 Subject: [PATCH] wrapper: restore u+w on staged ~/.claude config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cp -aL preserves /nix/store's read-only modes, so home-manager-managed skills/agents dirs landed in the seed as 0555. That made the exit-time rm -rf of the scratch dir fail with 'Permission denied', and — worse — made the defense-in-depth find -delete of a nested .credentials.json fail silently, leaking it into the seed. chmod -R u+w the staged copy before the strip, secure-fail if any .credentials.json survives, and chmod the scratch dir before cleanup. tests/host.sh now uses a read-only fake store so this regresses loudly. Co-authored-by: Claude --- docs/src/developing/gotchas.md | 10 ++++++++++ docs/src/security/invariants.md | 5 ++++- tests/host.sh | 9 ++++++++- wrapper/ccvm.sh | 11 +++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/src/developing/gotchas.md b/docs/src/developing/gotchas.md index e3e24a4..bae0a6a 100644 --- a/docs/src/developing/gotchas.md +++ b/docs/src/developing/gotchas.md @@ -92,3 +92,13 @@ intact. Never rebuild the argv by string-splitting. `wrapper/ccvm.sh` is built via `writeShellApplication`, so **shellcheck runs at build** — keep it clean (and the `set -euo pipefail` it injects in mind). + +## `cp -a` copies `/nix/store`'s read-only modes + +home-manager config is symlinked into `/nix/store` (dirs `0555`, files `0444`), and `cp -aL` +preserves those modes into the seed. A read-only staged dir breaks two things: the nested +`.credentials.json` strip (unlinking needs a writable parent, so `find -delete` fails and the +credential leaks into the seed), and the exit-time `rm -rf` of the scratch dir (`Permission +denied` on exit). The wrapper runs `chmod -R u+w` on the staged copy before the strip, and again +on the scratch dir before cleanup. `tests/host.sh` makes its fake store read-only so a regression +shows up in the test. diff --git a/docs/src/security/invariants.md b/docs/src/security/invariants.md index c300fc2..b7b4903 100644 --- a/docs/src/security/invariants.md +++ b/docs/src/security/invariants.md @@ -22,7 +22,10 @@ exclusion is by omission, not by filter. Two defenses reinforce this: 1. The per-item `cp -aL` only copies the listed paths, so the credential is never touched. 2. A defense-in-depth `find $SEED/claude-config -name .credentials.json -delete` strips any nested - one that a directory `cp` might drag in. + one that a directory `cp` might drag in. The staged copy is `chmod -R u+w`'d first (a + read-only `/nix/store` parent dir would otherwise make the delete fail silently), and the + wrapper **secure-fails** (`die`) if any `.credentials.json` survives the strip. See + [Gotchas](../developing/gotchas.md). The guest lays staged items into a fresh **tmpfs** `~/.claude` at boot. Claude starts unauthenticated; the user's `/login` or API key authenticates it ephemerally. This also avoids OAuth diff --git a/tests/host.sh b/tests/host.sh index 020224e..009bdb7 100755 --- a/tests/host.sh +++ b/tests/host.sh @@ -30,7 +30,7 @@ no() { } WORK="$(mktemp -d)" -trap 'rm -rf "$WORK"' EXIT +trap 'chmod -R u+w "$WORK" 2>/dev/null; rm -rf "$WORK"' EXIT export XDG_RUNTIME_DIR="$WORK/run" mkdir -p "$XDG_RUNTIME_DIR" @@ -62,6 +62,10 @@ printf '%s\n' "$SETTINGS_MARKER" >"$HM_STORE/.claude/settings.json" printf '{"oauth":"%s"}\n' "$CRED_MARKER" >"$HM_STORE/.claude/.credentials.json" printf '{"oauth":"%s"}\n' "$NESTED_CRED_MARKER" >"$HM_STORE/agents/.credentials.json" printf 'agent body\n' >"$HM_STORE/agents/helper.md" +# Real /nix/store paths are read-only (dirs 0555, files 0444) and `cp -a` preserves that mode, so +# the fake store is too: the wrapper must restore u+w on the staged copy, or the nested credential +# strip (`find -delete`) and the exit-time `rm -rf` of the seed both fail with EACCES. +chmod -R a-w "$HM_STORE" FAKE_HOME="$WORK/home" mkdir -p "$FAKE_HOME/.claude" @@ -138,6 +142,9 @@ fi [[ ! -e "$CFGOUT/agents/.credentials.json" ]] && ok "share.agents: nested .credentials.json stripped from agents/ copy" || no "share.agents: .credentials.json present inside agents/ copy" +[[ -z "$(find "$CFGOUT" ! -perm -u+w 2>/dev/null)" ]] && + ok "share.*: staged read-only store content is owner-writable (seed removable on exit)" || + no "share.*: read-only entries in seed/claude-config — exit-time rm -rf would fail" [[ -d "$CFGOUT/skills" && -f "$CFGOUT/skills/my-skill.md" ]] && ok "share.skills: skills/ dir staged" || no "share.skills: skills/ not staged" diff --git a/wrapper/ccvm.sh b/wrapper/ccvm.sh index 968094b..5afab25 100644 --- a/wrapper/ccvm.sh +++ b/wrapper/ccvm.sh @@ -157,6 +157,8 @@ cleanup() { elif [[ ${DRYRUN:-0} == 1 ]]; then : # dry run prints and keeps $TMP itself; leave it for the caller to inspect/remove. else + # u+w first: anything staged from a read-only /nix/store source is otherwise undeletable. + chmod -R u+w "$TMP" 2>/dev/null || true rm -rf "$TMP" fi fi @@ -588,9 +590,18 @@ if [[ -d $CLAUDEDIR ]]; then [[ -d "$CLAUDEDIR/config" ]] && cp -aL "$CLAUDEDIR/config" "$CFGOUT/config" 2>/dev/null || true fi + # cp -a preserves /nix/store's read-only modes (dirs 0555, files 0444). Restore owner write on + # the staged copy, or BOTH the credential strip below (unlink needs a writable parent dir) and + # the exit-time `rm -rf $TMP` fail with EACCES. + chmod -R u+w "$CFGOUT" + # Defense in depth: strip any .credentials.json a directory copy dragged in at any depth. # The credential must never reach the on-disk seed. Invariant: grep $SEED for the credential -> 0. + # Secure-fail: if any copy survives the delete, refuse to boot rather than stage it. find "$CFGOUT" -name '.credentials.json' -delete 2>/dev/null || true + if [[ -n "$(find "$CFGOUT" -name '.credentials.json' -print -quit 2>/dev/null)" ]]; then + die "could not strip a .credentials.json from the staged ~/.claude config; refusing to continue" + fi fi # ~/.claude.json (home-root, distinct from ~/.claude/ dir) is config, but it CAN carry MCP