Skip to content

fix(devcontainer): repair bind mount points' daemon-created parents - #113

Open
dlovell wants to merge 4 commits into
mainfrom
fix/bind-mount-point-chown
Open

fix(devcontainer): repair bind mount points' daemon-created parents#113
dlovell wants to merge 4 commits into
mainfrom
fix/bind-mount-point-chown

Conversation

@dlovell

@dlovell dlovell commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Defect

~/.claude/projects inside the container came up root: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_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, mechanically 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 — setup-claude creates the contents as vscode" comment was simply wrong: the directory in question is not created by setup-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 at lib/volume-perms.sh:64-65. So:

  • the ancestor walk is split out of dev_chown_volume_targets into dev_chown_ancestors;
  • a dev_chown_mount_points dispatcher routes volume → guarded recursion + ancestors, bindancestors 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);
  • the compose query emits <kind>:<target>.

Loop variables move off the shared _vp_ prefix: these are POSIX sh globals, 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-27 mounts DEV_MAIN_TREE and DEV_MAIN_GIT at their host paths, and <tree>/.git nests inside <tree>. On a host whose checkout lives under the container home prefix — a host user named vscode, or Codespaces — the ancestor walk starting from the .git bind 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_ancestors now 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 the Verified (ADR-0005 §2) format named in #111.

The compose query is guarded hermetically: its python snippet is extracted from dev/devcontainer at check time and run against a synthetic docker compose config document — derived from the source of truth, not restated. dc config itself needs docker; the snippet is the part that can run in this suite.

Three mutations, each reverted after watching it go red:

  1. the filter reverted 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 type == "volume" → 1 red (only the compose-query assertion can catch this — everything else drives the lib directly)
  2. the bind-mount-point protection dropped → 3 red
  3. the bind) arm routed into the recursive branch → 1 red

Test plan

  • bash tests/run-all → 28 suites, 0 failures, exit 0
  • pre-commit run --all-files → all hooks pass
  • The suite needs no docker daemon, so this is reproducible anywhere.

Review round (pr-reviewer, verdict needs-changes — addressed)

An independent review found that the PR overreached in its claims. dev_chown_volume_targets runs an unfiltered chown -R rooted at a volume, and chown has 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_chown is consulted by dev_chown_ancestors alone. I reproduced it: chown -Rv on 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:

Verified first-hand while addressing the above: the recursion really does descend into the nested bind (chown -Rv on the volume path visits the bind's contents), and docker compose config — which needs no daemon — emits type: bind for all six short-form mounts, volume for claude-home with the transcript bind nested inside it, and never puts tmpfs: entries in volumes at all. That closes the one item this PR originally listed as unverified, and confirms the dispatcher's tmpfs arm 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_chown reset all hold under dash; that the item-8 awk extraction 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:

  • Fail-open assertion. "a direct volume-targets call does not inherit protections" used assert_contains with a needle that is a prefix of the recursive log line, so it matched -R <owner> <tree>/.venv rather than any ancestor chown. Verified by mutation: deleting the _vp_never_chown reset 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.
  • Over-claimed protection. _vp_is_protected was 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, which host-mounts.txt permits (arbitrary <host>:<container>, no depth limit). Now protects under-a-bind paths too, with the deep-nesting case tested.
  • False flat claim. "A bind is never recursed into" was unqualified and wrong, and the scoping bullet pointed at the wrong sentence — the ancestor walk does not recurse at all, so scoping that to it was incoherent. Reworded to the dispatcher.
  • Mutation record misdirected: the header listed 8 items while the body had 9, so "Item 8" resolved to the wrong one.
  • Nit: the chown -R on a named volume crosses into a bind nested under it — the recursive branch is not mount-aware #115 assertion's label implied it witnessed descent; under a stub it observes the unpruned invocation. Relabelled honestly.
  • Nit: the new query-filter ↔ dispatcher coupling had no drift guard, against the repo's own convention. Added, derived — both kind sets are parsed from their own source.

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:

  • The mutation record was wrong again, for the third time — and I found the cause rather than just the numbers. My scratch copies were made with cp -a from this worktree, whose .git is a file pointing at another worktree's gitdir, so git checkout between 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.
  • A second fail-open, same file: "the walk continues past a protected bind" passed with the walk mutated to break instead of skip, because the protected bind was itself an argument and its own walk chowned everything above it. Now drives dev_chown_ancestors directly with a hand-filled exclusion set; mutation 6 pins it.
  • The "derived" kind-set guard derived over a hidden vocabulary — both extractions shared a hardcoded (volume|bind|tmpfs|npipe|cluster), so Compose's image type (v2.35+) was invisible to both and that drift would pass green. Both sides now parse generically; mutation 7 pins it.
  • _vp_is_protected loops forever on a set missing its trailing newline (unreachable today, but this runs as root on every cold start) — termination guard added.
  • Item 8's anti-vacuity assertions could not fire: set -e aborted at the assignment. || true added, 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/projects after the daemon has created it root-owned. That repair runs in setup(), which only dev/devcontainer calls — so an entry path that never goes through it gets the #106 failure back untouched: VS Code "Reopen in Container" (#43), a bare docker compose up, anything driving the compose files directly.

Both image routes now pre-create the directory instead:

  • Dockerfile/home/vscode/.claude/projects added to the existing mkdir -p; the chown -R … /home/vscode/.claude on the next line already covers it.
  • nix/base/Dockerfile.nix-default — a new RUN mkdir -p with an explicit chown. Explicit because the UID remap above it is guarded on USER_UID != 1000, so at the default UID it does not run and the dir would ship root-owned.

A fresh claude-home volume seeds its contents from the image path, so the volume comes up with projects/ already vscode-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 a BASE_IMAGE repin (#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.sh section 10, 47 → 54 assertions.

The directory is derived, not restated: it is read out of docker-compose.yml as the parent of the one mount target that interpolates DEV_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/vscode during the UID remap above these lines, where the directory does not yet exist. A guard that merely grepped for a mkdir and a chown would read that remap as ownership and stay green on an image shipping the dir root-owned — the exact state being guarded against. So precreated locates the mkdir and only accepts a covering chown at a later position.

Four mutations, fresh tree copy each (per the METHOD note now in that file's header):

  1. /projects dropped from the root Dockerfile's mkdir, 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)
  2. the nix tail RUN deleted entirely → 2 red, the same pair for that route — the routes are checked independently, so neither mutation is masked by the other file being correct
  3. that RUN's && chown dropped, mkdir left → 1 red, "chowns it AFTER creating it". This is the mutation that pays for the ordering logic.
  4. the compose target moved to .claude/transcripts/<key> with both Dockerfiles left alone4 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.md records the invariant, typed test:.

Notes for the reviewer

  • Existing containers are not repaired until their next recreate. setup() is cold-start only (gated on is_running), so a container already running keeps the root-owned directory. Recorded in the code comment.
  • Not verified against a live container — no docker daemon in this environment. The dispatch, the protection and the compose query are covered hermetically. The one gap listed here originally — whether dc config --format=json really emits type: bind for the short-form mounts — was closed by the review, which verified it offline against the real compose file.
  • This does not fix Transcript writes die silently and permanently when the host log dir is replaced under a running container; ensure_claude_logs() masks it with a fresh inode #105 (the transcript data loss). That one is about the bind source being unlinked on the host, and only a container recreate repairs a mount in that state.

Closes #106
Refs #115

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH

@dlovell
dlovell force-pushed the fix/bind-mount-point-chown branch 2 times, most recently from 85c54f6 to 9dbd33b Compare August 4, 2026 18:13
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
dlovell force-pushed the fix/bind-mount-point-chown branch from 9dbd33b to 51584c0 Compare August 4, 2026 19:33
`~/.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
dlovell force-pushed the fix/bind-mount-point-chown branch from 51584c0 to 03d7d84 Compare August 4, 2026 19:50
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
dlovell marked this pull request as ready for review August 10, 2026 19:44
t and others added 2 commits August 10, 2026 16:11
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant