Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/src/developing/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion docs/src/security/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion tests/host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions wrapper/ccvm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading