|
| 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