test: read the compose mount graph instead of restating it - #120
Open
dlovell wants to merge 4 commits into
Open
Conversation
Facts about the merged compose mount graph were prose in N files and read by nothing. Production code reads the graph (`mount_point_targets` shells out to `dc config`); the hermetic suite read `docker-compose.yml` only as TEXT — two suites grep it for literals. So mount EXISTENCE was assertable while NESTING and ABSENCE were not assertable at all. Two independent rules rode on that substrate, both green under mutation: - ADR-0001's "the container is NOT bind-mounted to the host credential file", restated in docker-compose.yml, setup-claude.py, dev/devcontainer and lib/host-bridge.sh and enforced in none of them. Re-adding the exact mount the ADR exists to remove left `bash tests/run-all` at exit 0. - "a recursive chown under ~/.claude must not cross into a bind", stated, lost and re-derived across #48 -> #58 -> #61 -> #113 -> #115. #58 added the bind that falsified the comment in a diff that rendered that comment three lines below its own edit, and it was still missed — so the limiter was never diff scope, it was salience, which is why detection-by-reading is the wrong fix. tests/lib/compose-topology.sh parses the compose file set into (kind, target, source) triples with pyyaml and stubbed interpolation — no daemon, so the suite stays hermetic. Compose's short-form colon syntax has to be interpolated BEFORE splitting or `${DEV_WORKSPACE:?required}` tears the entry apart; the reader also emits service-level tmpfs, which `mount_point_targets` does not see. tests/test-compose-topology.sh then asserts what could not previously be asserted: ADR-0001's absence rule; that the mounts under ~/.claude are EXACTLY a declared set, two-directionally, so a new one is red until someone names it and says why; that the only bind nested under a volume is the declared transcript carve-out (#115's topology); and that no project overlay reaches into ~/.claude. The reader re-implements Compose's own rules, making it a restatement in turn, so it is cross-checked against real `docker compose config` output wherever a docker CLI exists — `config` needs no daemon. That check is conditional and skips loudly rather than being silently absent. The property none of the previous mechanisms had: this fails FROM THE SIDE THAT CHANGES. #58 and #81 both edit docker-compose.yml, and the guard goes red in that PR — the only place the fix is cheap. The invariant ledger has no row shape for a conditional precondition, ADR-0005 acts on facts already promoted to invariants, the drift-guard convention triggers on authorial recognition, and #89's sweep used a deontic regex that indicative premises never match. Mutation-tested per ADR-0005 with the runs recorded in the header, including which mutations were already caught before this file and which were not — they are not uniform, and mutation 3 was already red via test-claude-logs.sh. Closes #116 Refs #115, #81 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH
Review of #120 found two ways the guard could be evaded, both verified here by mutation against the pushed head (`bash tests/run-all` -> exit 0 with each applied, i.e. both were genuinely green): 1. `projects/<name>/host-mounts.txt` is a SECOND committed bind channel. `lib/host-mounts.sh` turns those list files into a generated override that lands in the same `services.app.volumes`, so a committed `~/.claude/evil:/home/vscode/.claude/evil` — a bind under the very volume #115's `chown -R` is rooted at — was invisible to a guard that parsed compose files only. The previous revision's own comment named this blind spot for compose.override.yml and left the identical one in the same overlay directory, one file over. host_mount_mounts() now reads them, mirroring read_list()'s comment/blank handling and host-mounts.sh:25's leading-~ expansion, with a synthetic-line assertion so the parser cannot be green by reading nothing. `host-mounts.local.txt` stays out of scope: gitignored per-developer state, not a repo fact. 2. The reader discarded mount options, so ADR-0001's other recorded half — docs/adr/0001:46, "the store is still reachable READ-ONLY via the existing .claude-host mount (host ~/.claude, which includes credentials/)" — could not be checked by anything. Dropping `:ro` makes the host credential store shared-writable from every container, a regression squarely inside the ADR this suite claims to enforce, and it was green. Options are now a fourth column and both `.claude-host*` mounts are pinned `ro`. Also from review: the cross-check gated on `docker compose version` while using `config --format=json` (which arrived ~v2.24), so an older CLI fed empty output to json.load and `set -e` killed the suite mid-run — before the assertion written for exactly that case could be reached. Now it distinguishes the two outcomes that differ in meaning: no docker CLI SKIPs, docker present but unrenderable FAILs, because a cross-check that silently stops running is the failure mode being guarded against. The JSON parse is tolerant so it reports rather than aborts. Nits: tmpfs targets were split before interpolation, inconsistent with the interpolate-then-split lesson the same file documents; the `unguarded` annotation now carries ADR-0005's mandatory colon-form reason; pyyaml is noted as this suite's first non-stdlib dependency (fail-closed, CI has it). 12 -> 14 assertions, and the CLAUDE.md invariant no longer overstates what the reader covers: it names both channels and the options column. Refs #116, #115, #81 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH
…h time
Review round 2 on this PR found the guard still evadable through two committed
channels, both verified green with a `~/.claude` bind injected:
`defaults/compose.override.yml` (a real overlay — DEV_PROJECT_DIR falls back to
it) and `nix/base/compose.nix-base.yml` (appended by dc() on the nix route).
That is the third leak from the same hand-maintained list: rev 1 read
docker-compose.yml only, review found host-mounts.txt, review found these. Each
fix extended the list by hand and the list leaked again. So this one does not
extend it — `committed_compose_files` derives from `git ls-files`, and a
compose file added anywhere in the repo is scanned the day it lands. The
anti-vacuity assertions name the members whose absence caused a real gap, so a
regex that stops matching them cannot pass as "nothing to scan".
Also from review, each verified by mutation:
- host_mount_mounts split on `:` BEFORE interpolating — the exact hazard
compose_mounts documents, reintroduced in the fix for the previous channel
gap. Fixing that exposed a second, deeper error in both readers: an unset
`${VAR:-default}` was expanded to a stub path rather than to DEFAULT, which
is what compose does. `/home/vscode/.clau${X:-de}/two` really mounts under
~/.claude and the stub put it under /home/vscode/.clau/stub/x/, matching no
prefix. Both readers now model compose's actual substitution.
- a colon-free host-mounts line is forwarded verbatim and read by compose as an
anonymous volume at that path; the parser skipped it as malformed.
- the "docker present but unrenderable -> FAIL" branch was unreachable:
pipefail carried docker's status out of the command substitution and set -e
killed the suite at the assignment, before the assertion written for that
case. The previous revision's comment claimed this was fixed; the tolerant
JSON parse only covered exit-0-with-garbage.
The same "fails with no diagnostic" shape turned up twice more while fixing it
and is handled the same way: `git ls-files` failing now yields a clean FAIL
naming the cause instead of aborting at the assignment, and a malformed compose
file names itself instead of raising a bare traceback.
CLAUDE.md's invariant said the graph has TWO committed channels, which was
false; it now says the list is derived and why. The nesting rule was also
stated unqualified while the guard reads only the base file's volumes — binds
under overlay-declared volumes are the same #115 hazard and are now recorded
`unguarded:` rather than implied covered.
14 -> 19 assertions. Mutation record extended to nine, with a note on method:
measure in a fresh copy per mutation, because `cp -a` of this worktree carries
a .git pointing at another worktree's gitdir, so `git checkout` does not
restore and mutations accumulate.
Refs #116, #115, #81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH
…p model
Review round 3 found three more evasions, each verified green on the head SHA
that CI had already passed. All three are the same class the two prior rounds
found: the reader models compose, and the model was narrower than compose.
1. ADR-0001 was enforced BY NAME. The check keyed on the substring
"credential" appearing in a path, so a writable
`${HOME}/.claude:/home/vscode/.claude-rw` — the host store, which contains
.credentials.json — passed every assertion simply by choosing a target that
did not say "credential". The rule the ADR actually states is about the
STORE, so the check is now keyed on the mount SOURCE: nothing whose source
is the host home or under it may be writable, wherever it lands and whatever
it is called. One declared exception, ADR-0001's own: the transcript
carve-out, which must be writable and addresses a per-container leaf. The
check also now reads the DERIVED file set rather than the base file alone,
because an overlay could mount the store at a target outside ~/.claude and
evade both this and the coverage assertion.
2. The reader read `services.app` only, while `dc up -d` starts every service
in the merged config. A sidecar service in a project overlay bind-mounting
the host store under ~/.claude passed 19/19. It now reads every service, and
also top-level named volumes declared with `driver_opts: {o: bind, device:
...}` — a bind in volume clothing, and another way the store can be mounted
without the word "bind" appearing.
3. The interpolation model still diverged from compose on `${VAR-def}`
(colon-less default) and unbraced `$VAR`; both render under ~/.claude for
real compose while the reader left them literal. That is the third instance
of this class, one spelling over from the previous fix — and the model had
by then been copied into both readers, so each fix had to be applied twice.
There is now exactly ONE interpolation definition, shared.
Also: CLAUDE.md described the derivation as `*compose*.y*ml`, which reads as
substring-anywhere while the code anchors `compose` at a path-component start
(`foo-compose.yml` would not be scanned); the invariant now says what the regex
does. The header's "what is asserted" list and mutation count had drifted from
the file and are rewritten, and the method note no longer asserts a cause for
the earlier miscounts that does not reproduce.
Mutation record 9 -> 12 entries, all measured one-copy-per-mutation.
Refs #116, #115, #81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH
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.
The gap
Production code reads the merged compose mount graph (
mount_point_targets→dc config). The hermetic suite readdocker-compose.ymlonly as text — two suites grep it for literals. So mount existence was assertable; nesting and absence were not assertable at all.Two independent rules rode on that substrate, and both were green under mutation:
bash tests/run-allbefore this PR${HOME}/.claude/credentials:/home/vscode/.claude/credentials— the exact mount ADR-0001 exists to removechown -Ris rooted atWhy prose kept failing here
The chown-safety premise has been stated, lost and re-derived five times: #48 encoded "there is no bind under
~/.claude" as a comment, #58 added the transcript bind and falsified it, #61 then added a second recursive bind-unaware chown in a different file, #113 fixed only the ancestor walk, #115 tracks the rest.The detail that decides the fix: #58 rendered the comment it falsified three lines below its own edit. Diff scope was never the limiter — salience was. A premise reads as justification for local behaviour, not as a claim about the file being edited. Humans and review agents fail identically on that, so detection-by-reading is the wrong mechanism.
What this adds
tests/lib/compose-topology.shparses the compose file set into(kind, target, source)triples with pyyaml and stubbed interpolation — no daemon, suite stays hermetic. Two implementation notes that are load-bearing: short-form entries must be interpolated before splitting on:, or${DEV_WORKSPACE:?required}tears the entry apart and silently misclassifies every required mount; and it emits service-leveltmpfs, whichmount_point_targetsdoes not see.tests/test-compose-topology.shasserts what was previously unassertable:docker compose config— the reader re-implements Compose's short-form rules, so it is itself a restatement.configneeds no daemon, but the suite must stay hermetic, so the check is conditional and skips loudly.~/.claudeare exactly a declared set, two-directionally — a new one is red until someone names it and says why.~/.claude.The property that matters
This fails from the side that changes. #58 and #81 both edit
docker-compose.yml; the guard goes red in that PR, which is the only place the fix is cheap. Every prior mechanism required the person editing compose to already know a constraint written elsewhere: the invariant ledger has no row shape for a conditional precondition ("X is safe provided topology Y"), ADR-0005 acts on facts already promoted to invariants, the drift-guard convention triggers on authorial recognition, and #89's sweep used a deontic regex (must|never|…) that indicative premises never match.Verification
bash tests/run-all→ 29 suites, 0 failures, exit 0pre-commit run --all-files→ cleantest-claude-logs.sh; it is kept because it proves the coverage assertion is two-directional, and the header says so rather than implying a gap it did not close.Interactions
chown -Ra defect. It does not fix it.~/.claude; this guard goes red on that PR until they are declared, which is the intended behaviour.Closes #116
Refs #115, #81
🤖 Generated with Claude Code
https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH