Skip to content

Commit ec4086a

Browse files
tools: absence-scan — the fixture hygiene classes as an importable scanner + CLI (git-range mode for the push guard)
The five absence classes lived inside harvest-scrub-relations.test.mjs §6 and fired at test time. The cost they guard against is paid at push time: a harvested fixture carrying capture identifiers reached a public PR, and a published history cannot be scrubbed. This extracts them — predicates, walker and LEDGER allowlist unchanged — so the dotfiles pre-push hook can run the same check at that boundary. §6 now imports them; its assertions are untouched. Findings never echo the matched bytes: class, file, JSON path and length only. A reporter that prints the leak has moved it, not found it. Classes carry a scope. The three that describe a SANITIZED HARVEST keep §6's own scope (test/fixtures/harvested); the two byte-level ones apply anywhere. Measured basis: all five at repo width produced 219 findings, ~205 of them hand-authored synthetic test data — a guard that fires on those trains the override reflex that kills it. Verified: 20/20 harvest-scrub-relations, 14/14 absence-scan, 60/60 harvest family. Mutant matrix over all five classes — each goes red on its own seeded defect and green when exactly that predicate is neutered; an absence check over a clean corpus cannot bite itself without the seed. Two leaks surfaced, both already published and neither fixed here (see docs/code-reviews/absence-guard-report.md findings 1 and 2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 97867f3 commit ec4086a

4 files changed

Lines changed: 1093 additions & 94 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
# Absence guard — fixture hygiene classes as a tool, and a push-boundary guard
2+
3+
Built 2026-08-01. Two deliverables, sequential: `tools/absence-scan.mjs` in
4+
this repo (the classes, importable + runnable), and `git/hooks/pre-push` in
5+
the dotfiles repo (the same classes in front of every push of this repo
6+
family). Both unpushed at the time of writing.
7+
8+
---
9+
10+
## 1. What was built
11+
12+
### `tools/absence-scan.mjs` (fork, new)
13+
14+
An **extraction** of `test/harvest-scrub-relations.test.mjs` §6 — the five
15+
absence classes, the string walker, and the LEDGER allowlist. Nothing was
16+
tightened and nothing loosened; every predicate is the one §6 encoded on
17+
2026-07-31, restated from
18+
`docs/directives/fixture-sanitization-directive.md` rather than read back out
19+
of `tools/harvest.mjs` (same-parentage expectations pin the bug they should
20+
catch).
21+
22+
Exports: `CLASSES` (each with `name`, `scope`, `why`, `applies`, `violates`),
23+
`strings` (walker), `scanDocument`, `scanContent`, `scanFile`, `scanName`,
24+
`scanGitRange`, `isAllowlisted`, plus the individual regexes.
25+
26+
Class names, mapped to §6's letters:
27+
28+
| §6 | class name | what it is |
29+
|---|---|---|
30+
| a | `b64-run` | base64-alphabet run > 200 chars |
31+
| b | `nested-payload` | a `source.data` that is not a `data_<sha10>` token |
32+
| c | `live-timestamp` | a whole-string ISO instant outside the fixed epoch |
33+
| d | `capture-uuid` | an 8-4-4-4-12 UUID in any string |
34+
| d | `capture-uuid-filename` | a UUID or an 8-hex `s-` prefix in a filename |
35+
| e | `raw-content` | a `text`/`thinking`/`content` string that is not a token |
36+
37+
**Findings never echo the match.** A finding carries `{class, file, path,
38+
length}` and, for `b64-run`, the run length. Nothing else. A leak reporter
39+
that prints the leak into a terminal, a hook transcript or a CI log has moved
40+
the leak, not found it. This is asserted mechanically
41+
(`test/absence-scan.test.mjs`: "a finding never carries the matched bytes",
42+
and the CLI-level `assert.ok(!bad.stdout.includes(FAKE_UUID))`).
43+
44+
**The third answer** (`docs/dev-loop.md`, "A checker has THREE answers"): a
45+
file that does not parse is neither skipped nor passed — it is scanned as raw
46+
bytes (fail closed, so the byte-level classes still apply) and named on a
47+
`degraded:` line. Same for a base ref git cannot resolve: the scan degrades to
48+
"everything at the new ref" and says so, rather than erroring.
49+
50+
**CLI contract** (pinned; the hook depends on it):
51+
52+
```
53+
node tools/absence-scan.mjs <file...>
54+
node tools/absence-scan.mjs --git-range <old>..<new> # <old> may be EMPTY
55+
# exit 0 = clean, 2 = findings, 1 = internal error
56+
```
57+
58+
### `test/harvest-scrub-relations.test.mjs` §6 (fork, rewired)
59+
60+
§6's local `B64_RUN` / `UUID` / `ISO_INSTANT` / `EPOCH_*` / `CONTENT_KEYS` /
61+
`WRAP` / `strings()` are gone; the section now imports `scanDocument`,
62+
`scanName`, `isAllowlisted`, `CLASS_NAMES` from the tool and asserts on the
63+
returned findings. Assertions are unchanged (`deepEqual(hits, [])` plus the
64+
same vacuity floors, now read off each class's per-class `seen` counter).
65+
§§1–5 are untouched — their local `TOKEN` / `wellFormed` / `DATA_TOKEN` are
66+
the *sanitizer's* spec, stated from the documented token contract, and moving
67+
them would have changed their parentage.
68+
69+
The LEDGER exclusion is no longer a `startsWith("LEDGER-")` in the test: both
70+
the test and the hook now ask `isAllowlisted()`, so they cannot drift into
71+
disagreeing about what is accepted.
72+
73+
### `test/absence-scan.test.mjs` (fork, new)
74+
75+
14 tests: per-class seeded-defect bites, the no-echo property, the filename
76+
class, scope behaviour, the degraded path, the allowlist, CLI red/green, CLI
77+
exit-1 on no arguments, and four git-range tests against real scratch
78+
repositories (red in range / green before it, `EMPTY` = new-branch push,
79+
deleted files skipped, unresolvable base ref degrades).
80+
81+
### `git/hooks/pre-push` (dotfiles, new)
82+
83+
Global dispatcher in the pre-commit neighbour's shape: German docstring
84+
stating WHY, silent `exit 0` in foreign repos, repo-local hook chaining with
85+
the local exit code winning, `--test` bite battery, `git push --no-verify`
86+
named as the audited escape.
87+
88+
- **Activation:** any configured remote URL of the pushing repo contains
89+
`claude-code-cache-fix`, unioned with the URL git passes as `argv[2]` (a
90+
`git push <url> <ref>` need not be in the config — the union is the
91+
fail-closed direction).
92+
- **Ranges:** stdin lines `<local ref> <local sha> <remote ref> <remote sha>`.
93+
All-zero remote sha → `EMPTY..<local>`; all-zero **local** sha is a ref
94+
*deletion*, nothing is transferred, so it is skipped.
95+
- **Scanner resolution:** the pushing repo's own `tools/absence-scan.mjs`,
96+
else `~/dev/vendor/claude-code-cache-fix/tools/absence-scan.mjs` (machine
97+
binding, precedent `CACHE_FIX_PROXY_TREE_PIN`).
98+
- **Fail-closed deviation, deliberate:** an *active* repo with neither
99+
scanner resolvable is **blocked**, naming both paths and the escape. A leak
100+
guard that fails open on its own misconfiguration protects nothing and does
101+
not say so. Genuine unclarity (no git, unreadable stdin) stays fail-open per
102+
the house pattern. Stated in the docstring.
103+
- **Chaining deviation, deliberate:** the chained repo-local `pre-push` gets
104+
the consumed ref lines back on stdin, not `/dev/null` as in `pre-commit`. A
105+
`pre-push` without its ref lines is broken by construction. The hang
106+
protection is kept (closed pipe + 120 s timeout).
107+
108+
---
109+
110+
## 2. Checks run, with real output
111+
112+
### The rewired suite
113+
114+
```
115+
$ node --test test/harvest-scrub-relations.test.mjs
116+
ℹ tests 20 / pass 20 / fail 0
117+
```
118+
119+
### The new CLI suite
120+
121+
```
122+
$ node --test test/absence-scan.test.mjs
123+
ℹ tests 14 / pass 14 / fail 0
124+
```
125+
126+
One real red on the way there, kept as evidence that the seeded bites
127+
discriminate: the first `b64-run` seed put the long run in a `text` field and
128+
tripped two classes —
129+
130+
```
131+
✖ every class goes RED on its own seeded defect, and only that class
132+
AssertionError: b64-run's seeded defect must not trip a second class
133+
actual: [ 'b64-run', 'raw-content' ] expected: [ 'b64-run' ]
134+
```
135+
136+
A base64 run inside a content field genuinely *is* both an unsanitized payload
137+
and untokenized content; the seed moved to a `signature` field.
138+
139+
### Harvest family, after the rewire
140+
141+
```
142+
$ node --test test/harvest.test.mjs test/harvest-pin.test.mjs \
143+
test/harvest-scrub-relations.test.mjs test/absence-scan.test.mjs
144+
ℹ tests 60 / pass 60 / fail 0
145+
```
146+
147+
### MUTANT run — all five classes
148+
149+
An absence assertion over a *clean* corpus cannot bite itself: neuter its
150+
predicate and it still passes, because there was nothing to find. So each
151+
class was bitten in two steps in an isolated tree (`cp -r tools`, the rewired
152+
test, the real fixtures, a symlink to `proxy/`; the repo was never touched):
153+
seed the corpus with that class's defect, then neuter exactly that class's
154+
`violates` (`CLASSES.find(c => c.name === "<cls>").violates = () => null;`).
155+
156+
```
157+
CLASS: b64-run
158+
--- 1. seeded defect, real predicates (expect RED) ---
159+
✖ absence (a): no committed fixture carries a base64 run longer than 200 characters
160+
ℹ pass 19 ℹ fail 1 exit=1
161+
--- 2. same seeded defect, b64-run predicate neutered (expect GREEN) ---
162+
ℹ pass 20 ℹ fail 0 exit=0
163+
164+
CLASS: nested-payload
165+
✖ absence (b): every source.data in the corpus is a data_ token
166+
ℹ pass 19 ℹ fail 1 exit=1
167+
neutered: ℹ pass 20 ℹ fail 0 exit=0
168+
169+
CLASS: live-timestamp
170+
✖ absence (c): every whole-string ISO instant lies in the fixed-epoch family
171+
ℹ pass 19 ℹ fail 1 exit=1
172+
neutered: ℹ pass 20 ℹ fail 0 exit=0
173+
174+
CLASS: capture-uuid
175+
✖ absence (d): no 8-4-4-4-12 UUID appears anywhere in the corpus
176+
ℹ pass 19 ℹ fail 1 exit=1
177+
neutered: ℹ pass 20 ℹ fail 0 exit=0
178+
179+
CLASS: raw-content
180+
✖ absence (e): every content string in the corpus is a token, not capture prose
181+
ℹ pass 19 ℹ fail 1 exit=1
182+
neutered: ℹ pass 20 ℹ fail 0 exit=0
183+
```
184+
185+
Each red names exactly its own §6 test, and each mutation turns exactly that
186+
red green. The extraction orphaned no class.
187+
188+
A first run of this matrix printed `pass 0 / fail 1` in **both** arms — the
189+
isolated tree was missing `proxy/extensions/message-hash.mjs`, which
190+
`tools/replay.mjs` imports, so the file failed to load and the "red" said
191+
nothing about any predicate. Recorded because it is the shape the discipline
192+
warns about: a red that was not attributed is not evidence.
193+
194+
### The hook's bite battery
195+
196+
```
197+
$ python3 git/hooks/pre-push --test
198+
pre-push: all tests passed
199+
```
200+
201+
Eight bites: the three pure-logic ones (activation predicate, zero-sha
202+
handling, range derivation incl. deletion and malformed lines), then, against
203+
real scratch repositories with a bare remote and `core.hooksPath` aimed at a
204+
temp dir containing only this hook —
205+
206+
1. active repo + fixture with a synthetic UUID → **`git push` fails**, stderr
207+
names `capture-uuid` and `--no-verify` and does **not** contain the UUID;
208+
2. active repo + clean fixture → **`git push` succeeds**, ref present on the
209+
remote;
210+
3. foreign remote (no marker) + the same dirty fixture → **push succeeds**,
211+
hook invisible;
212+
4. active repo, neither scanner resolvable (`FALLBACK_SCANNER` patched, same
213+
technique as `pre-commit`'s `installed_version` bite) → `main()` returns 1
214+
and names both paths;
215+
5. chaining: the repo-local `pre-push` runs, its exit code (42) wins, and the
216+
ref lines arrive on its stdin verbatim.
217+
218+
### Doctor registration
219+
220+
No `bootstrap/doctor.py` hunk was needed or written. `check_hook_bite_tests`
221+
already enumerates `DOTFILES/git/hooks` by *content* (`'"--test"' in
222+
p.read_text()`) rather than by suffix — doctor.py:766-780, whose comment says
223+
the content filter exists precisely so extensionless git hooks are not missed.
224+
Verified by executing the check:
225+
226+
```
227+
$ python3 -c "import doctor; r=doctor.Report(); doctor.check_hook_bite_tests(r)"
228+
OK hook bite-test: pre-commit
229+
OK hook bite-test: pre-push
230+
```
231+
232+
---
233+
234+
## 3. The scope decision, and its measurement
235+
236+
The brief pinned range mode to "blobs whose path matches `*.json`/`*.jsonl`".
237+
Applying all five classes at that width was **measured first**:
238+
239+
```
240+
$ node tools/absence-scan.mjs $(git ls-files '*.json' '*.jsonl') # 31 files
241+
absence-scan: 219 finding(s)
242+
```
243+
244+
Roughly 205 of those are hand-authored synthetic test data — English prose in
245+
`text` fields of `test/fixtures/insertion-1405.json`, `read-dedupe/*.json`,
246+
`replay-classes/corpus-*.jsonl`, `toolgc-1536.json`; `ts` fields written by
247+
hand; a 4-character `source.data` placeholder in
248+
`read-dedupe/mixed-array-shape.json`. None is a defect. A guard that fires on
249+
those fires on every push and trains the `--no-verify` reflex that kills it
250+
(`docs/dev-loop.md`: "a check that fires on a non-defect is also broken").
251+
252+
So each class carries a `scope`:
253+
254+
- **`corpus`**`nested-payload`, `live-timestamp`, `raw-content`. These say
255+
what a *sanitized harvest* looks like, and §6 states that scope in the same
256+
breath as the classes ("every committed fixture under
257+
`test/fixtures/harvested`"). Carrying the scope over is part of the
258+
extraction, not a loosening of it.
259+
- **`any`**`b64-run`, `capture-uuid`, `capture-uuid-filename`. These need
260+
no corpus to be true: a 200-char base64 run is a payload and an 8-4-4-4-12
261+
UUID is a live capture identifier wherever they sit. **Measured false-fire
262+
rate outside the corpus: zero.**
263+
264+
Re-measured with the scoping in place:
265+
266+
```
267+
$ node tools/absence-scan.mjs $(git ls-files '*.json' '*.jsonl')
268+
scope: 21 file(s) outside test/fixtures/harvested/ — byte-level classes only
269+
absence-scan: 10 finding(s)
270+
```
271+
272+
All 10 are in one file, and they are real — see finding 1 below. Files scanned
273+
under the reduced class set are counted on a `scope:` line, never silently
274+
passed.
275+
276+
Wider audit (all five classes over any path) remains available by naming the
277+
files explicitly; the scope filter only applies where a path decides it.
278+
279+
---
280+
281+
## 4. Findings surfaced (not fixed — outside the write boundary)
282+
283+
### Finding 1 — `test/fixtures/cc-transcript-shape-snapshot.json` carries live capture data, already public
284+
285+
Introduced by `16ad235` ("feat: JSONL session-content mirror (P1 …) (#221)").
286+
Ten findings:
287+
288+
- 8 × `capture-uuid` — six *distinct* session/prompt/parent UUIDs at
289+
`$.assistant_sample.{uuid,parentUuid,sessionId}`,
290+
`$.user_sample.{uuid,parentUuid,promptId,sessionId,sourceToolAssistantUUID}`;
291+
- 1 × `capture-uuid` at `$.source`, a 130-char string that is a filesystem
292+
path naming a home directory *and* a session UUID;
293+
- 1 × `b64-run`, 448 chars, at
294+
`$.assistant_sample.message.content[0].signature` — an unscrubbed thinking
295+
signature.
296+
297+
The same file also carries raw thinking prose and live wall-clock timestamps
298+
(caught when the corpus classes are applied to it explicitly). It is *not* a
299+
harvested fixture, so `harvest.mjs`'s sanitizer never touched it; it was
300+
hand-committed as a shape snapshot.
301+
302+
**This is already in public history and cannot be scrubbed by editing.** It is
303+
an operator decision, and it is above this tier: (a) accept and allowlist with
304+
a written ruling, (b) re-author the snapshot with synthetic identifiers going
305+
forward, (c) treat it as a rotation-class incident. Nothing was changed here.
306+
307+
Operational consequence to be aware of: because it is already committed, a
308+
*new-branch* push (`EMPTY..<sha>`, which scans everything reachable) from a
309+
repo containing it will be blocked by the new hook until one of the above is
310+
decided. An incremental push is unaffected — the file is not in the range.
311+
312+
### Finding 2 — the `pr/insertion-normalization` branch carries pre-scrub-upgrade fixtures, already pushed
313+
314+
Measured on the worktree `/home/g/dev/vendor/cache-fix-pr1` (branch
315+
`pr/insertion-normalization`, HEAD `b713b2f`), scanner exit 2:
316+
317+
```
318+
$ node tools/absence-scan.mjs --git-range origin/main..HEAD
319+
55 live-timestamp test/fixtures/harvested/pinned-s-633915a8-26-28.json
320+
21 raw-content test/fixtures/harvested/oscillation-s-633915a8-863.json
321+
13 live-timestamp test/fixtures/harvested/oscillation-s-633915a8-863.json
322+
4 b64-run test/fixtures/harvested/oscillation-s-633915a8-863.json
323+
1 capture-uuid test/fixtures/harvested/pinned-s-633915a8-26-28.json
324+
1 capture-uuid-filename test/fixtures/harvested/pinned-s-633915a8-26-28.json
325+
1 capture-uuid-filename test/fixtures/harvested/oscillation-s-633915a8-863.json
326+
```
327+
328+
Both files predate the 2026-07-31 sanitizer work and were harvested under the
329+
old scrub:
330+
331+
- **Unscrubbed thinking signatures**: 1170 and 531 base64 characters at
332+
`$.requests_864[0].msg864.content[0].signature` and `[1].signature`, twice
333+
over.
334+
- **Raw capture prose**: 21 content strings of 289–387 characters at
335+
`$.requests[*].msg863.content[*].text`.
336+
- **A raw session key** at `$.header.key` of the pinned fixture (38 chars,
337+
matching the UUID shape — `sidToken` was not applied).
338+
- **68 live wall-clock instants**`rebaseTimestamps` was not applied.
339+
- **Both filenames carry `s-633915a8`, an 8-hex session prefix** — exactly the
340+
name shape `tools/harvest.mjs` names in its own comment as the reason the
341+
filename convention moved to `s-<sha12>`.
342+
343+
`git ls-tree origin/pr/insertion-normalization` confirms both files are
344+
**already on the remote**. Introduced by `1ca82f0`
345+
("test(insertion-suppression): real-pair check falls back to pinned fixture").
346+
347+
This is the dispatch's own incident class, live and current, on the branch
348+
another session is working on right now. Two consequences:
349+
350+
1. **The new pre-push hook will block that session's next push of this
351+
branch.** That is the guard working, not a false fire — but it will arrive
352+
as a surprise, and the escape is `git push --no-verify`.
353+
2. **Remediation is an operator decision above this tier**: the bytes are
354+
already public, so the choice is between re-harvesting the fixtures under
355+
the current sanitizer (fixes the branch going forward, does not un-publish),
356+
accepting with a ruling, or treating the exposure as an incident. Nothing
357+
was changed here — the files are outside the write boundary and belong to
358+
another agent's branch.
359+
360+
### Finding 3 — the `_sanitization` header claim is still unverified per fixture
361+
362+
The classes verify the *content*; nothing verifies that a fixture's own
363+
`_sanitization` header describes what was actually applied. Both files in
364+
finding 2 would presumably carry a header that is now false, which is the
365+
2026-07-31 gap-1 shape repeating. Not built (out of scope); named.
366+
367+
---
368+
369+
## 5. Residuals and what was NOT verified
370+
371+
- **§§1–5 keep local copies of `TOKEN` / `wellFormed` / `DATA_TOKEN`**, now
372+
duplicated with the tool's. Deliberate: those are the sanitizer's spec at
373+
the site that tests the sanitizer, and both are stated from the documented
374+
token contract rather than from each other. A future tightening must touch
375+
both — noted rather than silently unified.
376+
- **The hook is live the moment the file exists** (`core.hooksPath` points
377+
into the dotfiles working tree). It was exercised only through its own
378+
scratch repositories; no real push through it has happened.
379+
- **No push was performed** from either working copy, and both commits are
380+
unpushed.
381+
- **Not verified:** behaviour under `git push --all` / multiple refs in one
382+
push beyond the unit-level range derivation; sha256 repositories beyond the
383+
`is_zero` unit bite; a repo whose `origin` URL acquires the marker only via
384+
`argv[2]` was covered by unit test, not by a live push; performance of
385+
`EMPTY..<sha>` on a very large tree (this repo: sub-second).
386+
- **`npm test` was not run**`CLAUDE.local.md` warns the full suite can hang
387+
on the production port. Targeted files only, as briefed.

0 commit comments

Comments
 (0)