fix(devcontainer): repair bind mount points' daemon-created parents - #113
Open
dlovell wants to merge 4 commits into
Open
fix(devcontainer): repair bind mount points' daemon-created parents#113dlovell wants to merge 4 commits into
dlovell wants to merge 4 commits into
Conversation
dlovell
force-pushed
the
fix/bind-mount-point-chown
branch
2 times, most recently
from
August 4, 2026 18:13
85c54f6 to
9dbd33b
Compare
This was referenced Aug 4, 2026
dlovell
added a commit
that referenced
this pull request
Aug 4, 2026
Three review rounds this week produced nine findings. Two had a structural cause with a mechanical fix; this is those two. The other seven needed a reader, and nothing here pretends otherwise. 1. Fail-open assertions. `assert_contains` matches a needle that is a PREFIX of a longer line, so an assertion about `<owner> /a/b` is satisfied by an unrelated `-R <owner> /a/b/c` and passes while the behaviour it names has stopped. That shipped in tests/test-volume-chown-guard.sh (caught in review on #113) in a file that already documented the trap in a comment — the knowledge was present and the affordance was not. Suites needing line anchoring were wrapping `grep -qxF` in assert_true/assert_false, which works but loses the expected/got diagnostic and leaves each author to rediscover the problem. assert_line/assert_no_line make the safe form the ergonomic one. tests/test-harness-assertions.sh pins the DIFFERENCE between the two families, since that difference is the only reason the safe pair exists. 2. Mutation records prove the suite has teeth, not the assertions. Measured on tests/test-volume-chown-guard.sh with its five recorded mutations: 9 of 46 assertions ever went red — and the one that was fail-open sat in the other 37 with nothing pointing at it. tests/mutation-coverage runs mutations in throwaway copies and reports the never-red set as a worklist. It also reports NO-OP mutations, which matters more than it sounds: a silently non-matching sed otherwise reads as a passing mutation run that tested nothing — it caught one of mine while writing this. The pr-reviewer agent gains the three rubrics that produced the highest-value findings in those rounds and were, every time, typed ad hoc into the invoking prompt: treat the PR's own claims as unverified assertions (over- AND under-claims are findings); sweep new assertions for vacuity, with the shapes that have actually shipped here; and check whether a new guard reads all CHANNELS and FIELDS of the substrate it claims to cover, reporting only when it claims coverage it lacks. Leaving those in prompts made review quality depend on the invoker remembering — the "convention only its author knows" failure this repo already documents, applied to review itself. CLAUDE.md gets two lines and no more. The session's central finding is that prose without a reader drifts, so the response to it should not be mostly prose: agent definitions are read at the moment they are relevant, and a harness function cannot be forgotten. tests/test-nix-user-sync.sh migrates its one hand-rolled site. The remaining `grep -qxF` uses in test-dockerignore-lib-allowlist and test-image-fingerprint are plain loop conditions, not assertions, and want no migration. tests/test-volume-chown-guard.sh is NOT migrated here: it is in flight on #113. It should move once that lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH
dlovell
force-pushed
the
fix/bind-mount-point-chown
branch
from
August 4, 2026 19:33
9dbd33b to
51584c0
Compare
`~/.claude/projects` came up root:root 755 inside the container, so the container user could not create anything in it — including the per-project memory directory Claude expects at ~/.claude/projects/<key>/memory/. `named_volume_targets` filtered the merged compose config to `type == "volume"`, so the transcript bind at ~/.claude/projects/<key> never reached the chown driver. Its parent is precisely a directory the DAEMON creates as root to host that mount point — the case lib/volume-perms.sh already documents for volumes, identical for a bind; only the type filter excluded it. The compensating `chown vscode:vscode /home/vscode/.claude` in setup_claude() does not reach it either, and its "Top-level is enough" comment was simply wrong: the contents in question are not created by setup-claude, they are created by the daemon. Binds cannot just be handed to the existing function: it does a guarded `chown -R`, which for a bind walks onto the HOST side and rewrites ownership of real files — the opposite of the invariant the lib states. So the ancestor walk is split out of dev_chown_volume_targets into dev_chown_ancestors, and a dev_chown_mount_points dispatcher routes `volume` -> guarded recursion + ancestors, `bind` -> ancestors only, anything else -> nothing (the recursive branch is the destructive one, so an untaught mount type must not reach it). The ancestor walk additionally refuses to chown any path that is itself a bind mount point. That is not a hypothetical: docker-compose.yml mounts DEV_MAIN_TREE and DEV_MAIN_GIT at their HOST paths and <tree>/.git nests inside <tree>, so on a host whose checkout lives under the container home prefix (a host user named `vscode`; Codespaces) the walk up from the .git bind would have reached the worktree bind and rewritten ownership of the user's real repo directory. Bind targets are collected in a first pass so the protection cannot depend on the order compose emits mounts in. Loop variables move off the shared `_vp_` prefix: these are POSIX sh globals, so a caller and callee sharing a prefix would clobber the caller's iterator. Scope, stated rather than assumed away: this covers the ancestor walk only. The RECURSIVE branch is rooted at a volume and `chown -R` has no mount awareness, so it still descends through a bind nested under that volume — the claude-home / transcript-bind topology itself. That predates this change and is latent only incidentally (the images pre-create ~/.claude user-owned and setup_claude re-chowns the top every up, so the guard's precondition is not normally met). Filed as #115 with options, recorded as an accepted `unguarded:` invariant, and the suite pins the current unsafe behaviour rather than asserting the safe one — that assertion is #115's red-to-green target. Guards (ADR-0005), all mutation-tested with the mutations recorded in the test header: the bind/volume dispatch, the bind-mount-point protection including the nested-bind case, and the compose query itself — its python snippet is lifted out of dev/devcontainer and run against a synthetic config document, so narrowing the filter back to volumes fails hermetically instead of only in a container. tests/test-volume-chown-guard.sh: 21 -> 41 assertions. Note for existing containers: setup() is cold-start only, so a container already running keeps the root-owned directory until its next recreate. Closes #106 Refs #115 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH
dlovell
force-pushed
the
fix/bind-mount-point-chown
branch
from
August 4, 2026 19:50
51584c0 to
03d7d84
Compare
dlovell
added a commit
that referenced
this pull request
Aug 6, 2026
…ch-commit history (#122) Three commits, one story: what an audit is required to leave behind, and what the auditor is able to read. ## 1. `810dbad` — ADR-0006 amendment: committing an audit report is optional Reverses the "audits get a baseline" requirement. Closure is untouched — every surviving shape is still filed, landed, or recorded as an accepted `unguarded:` invariant — but the report itself is no longer mandatory. Why: the report is the second copy and the weaker one. A finding's durable form is an issue, a landed guard, or an `unguarded:` invariant, and all three live where the next reader already looks; a committed report restates that in prose nothing keeps true. It is the restate-over-derive shape ADR-0005 rejects, applied to the auditor's own output. A mandatory artifact also taxes the run that was already easiest to skip — the failure mode is not a missing file, it is a skipped audit. The cost is accepted explicitly rather than waved off: with no guaranteed prior report there is no guaranteed baseline, so measurements may not form a time series and a checked-and-dropped list may be re-litigated. Recorded in `unguarded:` form with a revisit trigger. Existing reports and the directory stay. ## 2. `934e999` — the auditor reads branch commits; the vocabulary is derived, not named **Branch history as an input.** `main` is squash-merged, so a hunk fixed three times on a branch lands as one clean commit and the fix-the-fix chain is unreachable from `git log`. Both audit agents excluded that history — `pr-reviewer` reviews the net `base...head` diff and deliberately anchors its history probe to `origin/main`, and the auditor is repo-scoped — so nobody read it. This PR's own subject demonstrates the loss. #90, which created these agents, contains: ``` 1254335 fix(agents): repair the log command; drop the ADR-0005 hard dependency ab9e77d fix(agents): anchor the repeat-offender log to origin/main; tidy audit ``` Two corrections to the same three lines of prose. On `main` that is one commit, `ba15520`. `gh pr view <n> --json commits` still returns the pre-squash sequence after merge, so this needs no pre-merge window. Scoped to PRs the other probes already implicated, so it stays one API call per candidate rather than per PR in the window. The squash fact is written as a check, not an assertion, so a change of merge strategy is noticed instead of assumed. Also adds "a set of merged PRs" to the scope list, making a retrospective over recent reviews a first-class scope. **Vocabulary drift.** #100 (`04e9eee`) typed the invariant ledger into the ADR-0005 vocabulary and touched `CLAUDE.md` only. `structural-auditor.md` reads that ledger and still named `(—)` in two places — a marker with zero remaining instances — so an agent following the instruction searched for a token that no longer exists and would have skipped every accepted-risk invariant in the repo. Silent under-coverage, in the agent whose job is finding unguarded couplings. Fixed by removing the copy rather than guarding it: the cross-section bullet names the categories and defers to the ledger's own header for the markers. A legacy untyped citation is now itself a finding, matching "type it when you touch it". **`pr-reviewer`** gains a note that the `origin/main` anchor also puts the branch's own commits out of scope, and that this is a handoff to the auditor. Without it the exclusion reads as an oversight and the next editor removes the anchor, restoring the self-trigger `ab9e77de` fixed. ## 3. `0d35e4c` — stop enumerating the audit reports by name The amendment named the two files in `docs/audits/` as "the existing reports". #112 commits a third under the now-optional rule, so that list goes stale on merge — a second copy of a directory listing, which is the shape the amendment itself argues against. De-enumerated in all three places with the reason stated inline. ## Verification - `pre-commit run --files` on all three files: clean. The `check-gitignore-agents` probe passes, confirming the agent files can actually be staged (ADR-0003). - The `gh pr view` command was **run as written** before being embedded. The two commands this file has shipped previously were both wrong on first landing; embedding a third unverified one would have repeated the exact failure this PR documents. - No test covers `.claude/agents/`, which is the point of the issue below. ## Related - #121 — the finding this work produced, filed rather than fixed here: `.claude/agents/*.md` ships shell commands and ledger vocabulary with no guard, and both halves have now broken once. This PR fixes the two symptoms; the mechanism is open. Deliberately **not** `Closes` — and #121 argues accept-with-a-reason is a legitimate disposal. - Merge-tested against all seven open PRs. Clean against `main`, #112, #113, #110, #109, #108. - **#117** conflicts with this branch only (it is clean against `main`): it rewrites the "Guards follow ADR-0005" bullet while `810dbad` extends the "A structural audit…" bullet directly beneath it. Adjacent lines, independent content — whoever merges second keeps both. - **#81** already conflicts with `main` independently of this branch, but note it edits the *same sentence* `810dbad` does: it replaces "ADR-0004 is reserved by #81" with the real entry while this adds ADR-0006. Both additions must survive the resolution; taking either side wholesale drops an ADR from the index. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011KsnnuPRJW5sUPppm3RSRo --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: t <t@t>
… root The ancestor walk added in this PR repairs the transcript bind's parent after the daemon has created it root-owned. It only runs from setup(), so an entry path that never calls dev/devcontainer — VS Code "Reopen in Container" (#43), a bare `docker compose up` — still gets #106 back. Pre-creating the directory in both image routes removes the failure instead of repairing it: a fresh claude-home volume seeds from the image path, so it comes up vscode-owned and the daemon has nothing left to create under it. The nix route carries this in the tail build rather than in nix/base/flake.nix, where the sibling home dirs are pre-created: a fix in the base reaches nobody until a republish AND a BASE_IMAGE repin (#83), while the tail build runs for every project image. Its chown is explicit because the remap chown above it is guarded on USER_UID != 1000 and does not run at the default UID. Guard: tests/test-volume-chown-guard.sh gains section 10 (47 -> 54 assertions). The directory is derived from docker-compose.yml as the parent of the one target interpolating DEV_CONTAINER_PROJECT_KEY, not restated. The mkdir/chown ordering is checked, not just their presence: both Dockerfiles chown -R /home/vscode during the UID remap above these lines, so a position-blind check would read that as coverage and stay green on an image shipping the dir root-owned. Verified (ADR-0005 §2), four mutations, fresh tree copy each: 8. /projects dropped from the root Dockerfile's mkdir -> 2 red 9. the nix tail RUN deleted -> 2 red 10. that RUN's `&& chown` dropped, mkdir left -> 1 red (the ordering) 11. the compose target moved to .claude/transcripts/<key>, both Dockerfiles untouched -> 4 red, naming the new path bash tests/run-all -> 0 failures; pre-commit (shellcheck, hadolint) clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8Q8kBDc2TtCF7ZzSwD3iX
dlovell
marked this pull request as ready for review
August 10, 2026 19:44
Section 10 of tests/test-volume-chown-guard.sh reads Dockerfile TEXT: a mkdir exists, a later chown covers it. That is a proxy. The fact it stands in for — the BUILT image ships the directory writable by the container user — needs a daemon, so per ADR-0005 it is typed `ci:` and gets a real check rather than a second textual restatement. dev/check-image-mount-parents derives the directory (the transcript mount's target) and the owner (the service's `user:`) from docker-compose.yml, then runs #106's own probe — mkdir inside it as that user — against a built image. A stat comparison would pass on a same-named owner with no write bit; only the mkdir answers the question the bug asked. Wired into both build jobs: docker-build.yml on the classic image, nix-base.yml on the tail image, per arch. Verified against real images built locally: - current tree -> ok, exit 0 - /projects dropped from the mkdir -> "does not ship ... at all", exit 1 - dir created after the chown, so it ships root-owned -> mkdir EACCES, exit 1, naming #106 The last case is the one a stat-only check could have gotten wrong and the textual guard cannot see at all. The hermetic suite keeps what it can hold of this half: both workflows must invoke the checker (a checker no job runs is the #83 shape), the checker must be executable (#129's shape), and it must not hardcode the path it derives. 54 -> 58 assertions. Verified (ADR-0005 §2), three more mutations, fresh copy each: 12. the step deleted from docker-build.yml -> 1 red (the two workflows are asserted separately, so one route cannot hide behind the other) 13. the derivation replaced by the literal it derives -> 1 red. Worth noting this mutation does NOT break the checker against today's images, which is why a literal there would go unnoticed until the mount moved 14. the checker made non-executable -> 1 red Also listed in the workflows' trigger paths, so an edit to the checker is exercised by a build instead of first running on someone else's PR, and added to the extensionless-shellcheck list. bash tests/run-all -> 0 failures; pre-commit run --all-files -> all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8Q8kBDc2TtCF7ZzSwD3iX
An independent review of the two preceding commits found that section 10
of tests/test-volume-chown-guard.sh could be satisfied while the image
was broken, four ways. All four reproduced at 58 passed, 0 failed before
being closed:
- a COMMENT naming the mkdir and chown, with the nix tail's RUN
deleted. `precreated` read comment lines as code. This is the #97
shape the ledger records verbatim — textual containment satisfied by
a comment naming the path — recurring inside a guard written just
after reading that entry. It is also the likeliest future edit: the
file's own comment calls flake.nix "the obvious home" for it.
- `chown root:root` on the right path at the right position. The check
accepted any chown and never looked at operand 0.
- a LATER `RUN chown root:root <path>` with the correct block left
intact. Only the last writer of ownership is what ships.
- the checker's `run:` step deleted from docker-build.yml while its
trigger-path entry gained a trailing comment. The unanchored grep
matched the paths entry — and that entry was added by the previous
commit, so this PR built the hole itself.
Fixes, all deriving rather than restating: comment lines are stripped
before continuations are joined (as the builder does); the walk keeps
looking so the LAST covering chown decides; its owner operand is
compared against the compose service `user:`; the workflow assertion is
anchored on a `run:` line.
nix/base/Dockerfile.nix-default now chowns by NAME (vscode:vscode) rather
than "$USER_UID:$USER_GID". The remap above has already renumbered
vscode, so it is equivalent — and it states the identity compose runs as,
which is what lets the guard compare against docker-compose.yml instead
of accepting any chown.
Also from the review:
- the coverage claim in CLAUDE.md and Dockerfile said the image half
makes this "never wrong in the first place". It does so for a FRESH
claude-home volume only: an existing volume holding a root-owned
projects/ is untouched by a rebuild and still needs the walk, a
clean or a reset. Qualified in both places. Verified against a real
daemon: pre-existing volume + new image still reproduces #106.
- "the daemon has nothing left to create" was literally false — it
still creates the <key> mount point root-owned inside the volume.
Nothing needs to write there (the bind shadows it at runtime), so
the claim is now scoped to what the user actually needs.
- dev/check-image-mount-parents reported infrastructure failures as
the #106 diagnosis: a missing image or an unresolvable --user exits
non-zero exactly as an unwritable directory does. Both now
pre-flighted and named as CI wiring problems.
Guard: 58 -> 59 assertions (the derived compose user). Mutations 15-18
recorded with their measured counts; 8-14 re-measured after the parser
changed, counts unchanged.
bash tests/run-all -> 0 failures; pre-commit run --all-files -> all pass.
Checker re-verified against a freshly built classic image (exit 0) and
against a bogus tag (exit 1, named as wiring, not #106).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8Q8kBDc2TtCF7ZzSwD3iX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Defect
~/.claude/projectsinside the container came uproot:root 755, so the container user could not create anything in it — including the per-project memory directory Claude expects at~/.claude/projects/<key>/memory/.named_volume_targetsfiltered the merged compose config totype == "volume", so the transcript bind at~/.claude/projects/<key>never reached the chown driver. Its parent is precisely a directory the daemon creates as root to host that mount point — the caselib/volume-perms.shalready documents for volumes, mechanically identical for a bind; only the type filter excluded it.The compensating
chown vscode:vscode /home/vscode/.claudeinsetup_claude()does not reach it either, and its "Top-level is enough — setup-claude creates the contents as vscode" comment was simply wrong: the directory in question is not created bysetup-claude, it is created by the daemon. That comment is corrected here rather than left standing.Fix
Binds cannot just be handed to the existing function: it does a guarded
chown -R, which for a bind walks onto the host side and rewrites ownership of real files — the opposite of the invariant the lib states atlib/volume-perms.sh:64-65. So:dev_chown_volume_targetsintodev_chown_ancestors;dev_chown_mount_pointsdispatcher routesvolume→ guarded recursion + ancestors,bind→ ancestors only, anything else → nothing (the recursive branch is the destructive one, so a mount type the lib has not been taught about must not reach it by default);<kind>:<target>.Loop variables move off the shared
_vp_prefix: these are POSIXshglobals, so a caller and callee sharing a prefix would let the callee clobber the caller's iterator mid-walk.That repairs the directory after the daemon has created it. A second half — shipping it in the image so it is never created wrong — was added later; see Second half: the image ships the directory below.
Deviation from the patch proposed in the issue — please review this part closely
The issue's patch would have introduced a new host-side write, so this PR adds something that proposal did not have.
docker-compose.yml:26-27mountsDEV_MAIN_TREEandDEV_MAIN_GITat their host paths, and<tree>/.gitnests inside<tree>. On a host whose checkout lives under the container home prefix — a host user namedvscode, or Codespaces — the ancestor walk starting from the.gitbind reaches the worktree bind and chowns the user's real repo directory. Ancestors-only is not sufficient protection on its own: a bind mount point can be reached as an ancestor.So
dev_chown_ancestorsnow refuses to chown any path that is itself a bind mount point, skipping it while continuing the walk past it (the dirs above a bind are still container-side and still need repair). Bind targets are collected in a first pass, so the protection cannot depend on the order compose emits mounts in.This also makes #81's nested memory bind safe: its first ancestor is the transcript bind mount point.
Guards (ADR-0005)
tests/test-volume-chown-guard.sh: 21 → 47 assertions, three new sections, mutation record in the header per theVerified (ADR-0005 §2)format named in #111.The compose query is guarded hermetically: its python snippet is extracted from
dev/devcontainerat check time and run against a syntheticdocker compose configdocument — derived from the source of truth, not restated.dc configitself needs docker; the snippet is the part that can run in this suite.Three mutations, each reverted after watching it go red:
type == "volume"→ 1 red (only the compose-query assertion can catch this — everything else drives the lib directly)bind)arm routed into the recursive branch → 1 redTest plan
bash tests/run-all→ 28 suites, 0 failures, exit 0pre-commit run --all-files→ all hooks passReview round (pr-reviewer, verdict
needs-changes— addressed)An independent review found that the PR overreached in its claims.
dev_chown_volume_targetsruns an unfilteredchown -Rrooted at a volume, andchownhas no mount awareness, so it still descends through a bind nested under that volume — the claude-home / transcript-bind topology this PR is about._vp_never_chownis consulted bydev_chown_ancestorsalone. I reproduced it:chown -Rvon the volume path visits the nested bind's contents.The recursion predates this PR (
origin/main:80, unchanged here). What this PR did was assert in four places that it could not happen. Fixed by scoping the claims rather than by widening the change:dev/devcontainer,CLAUDE.md, and thesetup_claudecomment) now say ancestor walk, and say explicitly that the recursion is not coveredCLAUDE.mdrecords the residual as an acceptedunguarded:invariant with the reason, per ADR-0005 §1Verified first-hand while addressing the above: the recursion really does descend into the nested bind (
chown -Rvon the volume path visits the bind's contents), anddocker compose config— which needs no daemon — emitstype: bindfor all six short-form mounts,volumeforclaude-homewith the transcript bind nested inside it, and never putstmpfs:entries involumesat all. That closes the one item this PR originally listed as unverified, and confirms the dispatcher'stmpfsarm is defensive only.Reported by the review but not re-verified here, so weigh them as its word rather than established: that the
case-with-quoted-expansion membership test, the<kind>:<target>split against colon-bearing paths, the global-prefix discipline and the_vp_never_chownreset all hold underdash; that the item-8awkextraction fails closed; and that Compose normalises trailing-slash targets (which would otherwise be a fail-open in the exact-string match).Review round 2 (independent, verdict
needs-changes— addressed)A second
pr-reviewer, given no knowledge of round 1's findings, found six. The serious one was mine, in the guard itself:"a direct volume-targets call does not inherit protections"usedassert_containswith a needle that is a prefix of the recursive log line, so it matched-R <owner> <tree>/.venvrather than any ancestor chown. Verified by mutation: deleting the_vp_never_chownreset leaves the suite at 41 passed, 0 failed while the leak is live. The assertion whose entire job was catching that reported PASS. Now line-anchored — and it is the same trap this file documents 150 lines earlier for the home-prefix case. A second, milder instance ("a volume target's ancestors are still chowned") fixed the same way._vp_is_protectedwas exact-match on mount points, so a path inside a bind but not itself a mount point was unprotected — reachable when one bind nests two levels under another, whichhost-mounts.txtpermits (arbitrary<host>:<container>, no depth limit). Now protects under-a-bind paths too, with the deep-nesting case tested.All five mutations re-run and the recorded counts corrected to what the runs actually produced (they were not what I predicted). 21 → 47 assertions.
Review round 3 (independent, fable — addressed)
Five findings, none in the shipped lib (which survived every scenario the reviewer constructed under real
dash). All in the guard and its record:cp -afrom this worktree, whose.gitis a file pointing at another worktree's gitdir, sogit checkoutbetween mutations silently failed to restore and mutations accumulated, inflating every hand-measured count. Re-measured in fresh copies: M3 and M4 are 1 red each, not 2. Record rewritten with a METHOD note so the next person doesn't repeat it."the walk continues past a protected bind"passed with the walk mutated tobreakinstead of skip, because the protected bind was itself an argument and its own walk chowned everything above it. Now drivesdev_chown_ancestorsdirectly with a hand-filled exclusion set; mutation 6 pins it.(volume|bind|tmpfs|npipe|cluster), so Compose'simagetype (v2.35+) was invisible to both and that drift would pass green. Both sides now parse generically; mutation 7 pins it._vp_is_protectedloops forever on a set missing its trailing newline (unreachable today, but this runs as root on every cold start) — termination guard added.set -eaborted at the assignment.|| trueadded, and mutation 1 now reds that assertion, proving it works.41 → 47 assertions, seven recorded mutations, all measured in isolation.
Second half: the image ships the directory (
0f21a58)Everything above repairs
~/.claude/projectsafter the daemon has created it root-owned. That repair runs insetup(), which onlydev/devcontainercalls — so an entry path that never goes through it gets the #106 failure back untouched: VS Code "Reopen in Container" (#43), a baredocker compose up, anything driving the compose files directly.Both image routes now pre-create the directory instead:
Dockerfile—/home/vscode/.claude/projectsadded to the existingmkdir -p; thechown -R … /home/vscode/.claudeon the next line already covers it.nix/base/Dockerfile.nix-default— a newRUN mkdir -pwith an explicitchown. Explicit because the UID remap above it is guarded onUSER_UID != 1000, so at the default UID it does not run and the dir would ship root-owned.A fresh
claude-homevolume seeds its contents from the image path, so the volume comes up withprojects/alreadyvscode-owned and the daemon has nothing left to create under it.Why the nix half is in the tail build, not
nix/base/flake.nix. The flake is where the sibling home dirs (.claude,.cache,.ssh) are pre-created, so that is the obvious home for it — but a fix there reaches nobody until a republish and aBASE_IMAGErepin (#83, the same trap that left #70 live on ghcr and unreachable). The tail build rebuilds for every project image, so the fix is live as soon as this merges. Noted in the code comment, since the asymmetry with its siblings is otherwise a puzzle.This does not replace the ancestor walk — a container built before this change still needs it, and the walk is what covers any other nested bind a project overlay declares.
Guard
tests/test-volume-chown-guard.shsection 10, 47 → 54 assertions.The directory is derived, not restated: it is read out of
docker-compose.ymlas the parent of the one mount target that interpolatesDEV_CONTAINER_PROJECT_KEY(that variable is what makes the target a per-project subdirectory), so moving or renaming the transcript mount moves the guard with it.The check is order-aware, which is the part worth reviewing. Both Dockerfiles run
chown -R /home/vscodeduring the UID remap above these lines, where the directory does not yet exist. A guard that merely grepped for amkdirand achownwould read that remap as ownership and stay green on an image shipping the dir root-owned — the exact state being guarded against. Soprecreatedlocates themkdirand only accepts a coveringchownat a later position.Four mutations, fresh tree copy each (per the METHOD note now in that file's header):
/projectsdropped from the root Dockerfile'smkdir, back to the pre-~/.claude/projects left root-owned: bind mount targets are excluded from the volume chown, so the daemon-created parent is never repaired #106 form → 2 red (once the mkdir is gone there is no creation for the chown probe to cover, so both assertions for that route fail)RUNdeleted entirely → 2 red, the same pair for that route — the routes are checked independently, so neither mutation is masked by the other file being correctRUN's&& chowndropped,mkdirleft → 1 red,"chowns it AFTER creating it". This is the mutation that pays for the ordering logic..claude/transcripts/<key>with both Dockerfiles left alone → 4 red, and the failure names print/home/vscode/.claude/transcripts— which is what shows the expectation is read out of compose rather than restated in the assertions.bash tests/run-all→ 0 failures, exit 0.pre-commit(shellcheck, hadolint) clean on the changed files.CLAUDE.mdrecords the invariant, typedtest:.Notes for the reviewer
setup()is cold-start only (gated onis_running), so a container already running keeps the root-owned directory. Recorded in the code comment.dc config --format=jsonreally emitstype: bindfor the short-form mounts — was closed by the review, which verified it offline against the real compose file.Closes #106
Refs #115
🤖 Generated with Claude Code
https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH