Skip to content

test: read the compose mount graph instead of restating it - #120

Open
dlovell wants to merge 4 commits into
mainfrom
test/compose-topology-guard
Open

test: read the compose mount graph instead of restating it#120
dlovell wants to merge 4 commits into
mainfrom
test/compose-topology-guard

Conversation

@dlovell

@dlovell dlovell commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The gap

Production code reads the merged compose mount graph (mount_point_targetsdc config). The hermetic suite read docker-compose.yml only 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:

Mutation bash tests/run-all before this PR
re-add ${HOME}/.claude/credentials:/home/vscode/.claude/credentials — the exact mount ADR-0001 exists to remove exit 0, every suite green
add a bind directly under the volume #115's chown -R is rooted at exit 0, every suite green

Why 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.sh parses 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-level tmpfs, which mount_point_targets does not see.

tests/test-compose-topology.sh asserts what was previously unassertable:

  1. reader anti-vacuity, plus a cross-check against real docker compose config — the reader re-implements Compose's short-form rules, so it is itself a restatement. config needs no daemon, but the suite must stay hermetic, so the check is conditional and skips loudly.
  2. ADR-0001: nothing bind-mounts the host credential store.
  3. coverage: the mounts under ~/.claude are exactly a declared set, two-directionally — a new one is red until someone names it and says why.
  4. nesting: the only bind nested under a volume is the declared transcript carve-out (chown -R on a named volume crosses into a bind nested under it — the recursive branch is not mount-aware #115's topology).
  5. no project overlay reaches into ~/.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 0
  • pre-commit run --all-files → clean
  • Three mutations recorded in the test header per ADR-0005 §2, including which were already caught and which were not — they are not uniform. Mutation 3 (deleting the transcript bind) was already red via test-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

Closes #116
Refs #115, #81

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMRkcVZwXpwBqjMY4bSehH

dlovell and others added 2 commits August 4, 2026 18:19
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
t and others added 2 commits August 4, 2026 19:22
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No guard reads the merged compose mount graph: ADR-0001's "no shared credentials mount" is prose in four files and green under mutation

1 participant