Skip to content

absence-scan: standalone fixture-hygiene scanner + push-time CLI - #306

Merged
cnighswonger merged 4 commits into
cnighswonger:mainfrom
Gunther-Schulz:pr/absence-scan
Aug 15, 2026
Merged

absence-scan: standalone fixture-hygiene scanner + push-time CLI#306
cnighswonger merged 4 commits into
cnighswonger:mainfrom
Gunther-Schulz:pr/absence-scan

Conversation

@Gunther-Schulz

Copy link
Copy Markdown
Contributor

Splits tools/absence-scan.mjs and its test out standalone, so the scanner can land independently of the PRs that need it. Asked for in #284 and again in #292.

Ref #302
Ref #292

What it is

An importable scanner plus a CLI for five fixture-hygiene classes — a base64 payload run over 200 chars, a raw nested wire payload, a live wall-clock timestamp, a capture UUID, and untokenized capture prose — plus a filename-shape check for the s-<8hex> raw session prefix. --git-range <old>..<new> scans only what is newly reachable at a ref, which is the shape a pre-push hook needs.

Findings never echo the match. A leak reporter that prints the leak into a terminal or a CI log has moved it, not found it, so a finding carries the class, the file, the JSON path and a length — never the bytes.

Known blind spot, stated because it changes how you would wire it up

In --git-range mode the candidate list is filtered by SCANNABLE = /\.jsonl?$/i before any class runs. A capture identifier in a tracked .mjs or .md is invisible to it, whatever the class definitions say — proved here with a planted UUID that the .json path caught and the .mjs path did not. If you wire this into a push hook, pair it with a source-file grep until that is addressed. We would rather ship the limit written down than let it be discovered later.

Two findings against this repository's current tree

Neither is a defect in the scanner, and neither is ours to fix, so they are reported rather than patched:

  1. test/fixtures/cc-transcript-shape-snapshot.json still carries real capture content here. Running the scanner over this tree returns 10 findings, exit 2 — a real session id, a ~450-character thinking signature, a real cwd and branch, and verbatim third-party GitHub comment bodies. That is test/fixtures/cc-transcript-shape-snapshot.json carries capture-derived identifiers (public history) #292, and our fork's copy has since been rebuilt from known-safe parts; this repository's copy has not. Offer stands from test/fixtures/cc-transcript-shape-snapshot.json carries capture-derived identifiers (public history) #292: we are happy to send the rebuilt fixture as its own PR, or leave it to you.
  2. Several UUIDs under docs/code-reviews/ and docs/directives/ are flagged by the source-tree guard our fork runs. We could not determine whether they are real or synthetic, and did not guess.

What the suite covers, and what it deliberately does not

Every class has a seeded defect that must make exactly that class fire and no other — the extraction's own guarantee, since a class asserting the ABSENCE of a defect over a clean corpus passes just as well when it has been neutered.

Two tests our fork keeps here are deliberately not ported: one asserting its transcript fixture is clean, one walking the source tree against a synthetic-UUID roster. Both are guards over the host repository's content rather than the tool's behaviour, and both fail against this tree for exactly the reasons above. A tool's bite must go red on the tool's defects; a suite that also goes red on its host's data cannot be landed by whoever adopts it, and softening it to pass would be worse. The reason is written where the next reader will look. Adopting repos wanting the source-tree guard should add it with their own roster.

Boundary conditions you named

  • No test/fixtures/harvested/ directory: the corpus scope reads as empty-and-passing rather than erroring, and — pinned by a new test — does not widen into skipping the byte-level classes, which need no corpus to be true.
  • No cc-transcript-shape-snapshot.json allowlist entry. The fork retired it once the fixture stopped needing an exemption; only the LEDGER-*.json entry remains.
  • The test's scratch-repo helpers scrub git's environment (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and four more, set to undefined rather than "" — an empty string is still "set" to git).

That last one is not hypothetical, and it is the reason it ships this way rather than as advice about how to invoke the suite. Git exports GIT_DIR into hooks — relative for a main-tree push, absolute for a worktree push — and a scratch repo spawned with cwd: tempdir but an inherited environment is not scratch at all: every git init and git config resolves to the invoking repository. Run from a git hook, the unhardened suite writes core.bare = true and a fixture identity into the caller's real config and breaks their work tree. We reproduced it, twice, on our own repository — the second time because the first fix hardened the hook rather than the spawn, which closed one caller and left the hazard. Measured either way: unhardened, the repro moves an enclosing repo's config md5; hardened, 15/15 pass and the md5 is byte-identical before and after.

🤖 Generated with Claude Code

https://claude.ai/code/session_011MHkABnXXw12UUk3XMSqXM

Gunther-Schulz and others added 3 commits August 5, 2026 12:38
Cuts tools/absence-scan.mjs and test/absence-scan.test.mjs out as a
standalone branch so the tool can land independently of the PRs that
depend on it (Ref cnighswonger#302, Ref cnighswonger#292). Content-scanning form, unchanged
from the fork's post-770e915 shape.

Hardens the test file's scratch-repo git spawn helpers to scrub
GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE from their env: git exports an
absolute GIT_DIR into pre-push hooks from a worktree, and without this
scrub the helpers' git init/config/add calls resolve against the
invoking repo's real git dir instead of the scratch tempdir, corrupting
it (reproduced; a consumer running this suite from a git hook would hit
this against their own repo). Adds a test pinning that an absent
test/fixtures/harvested/ directory (upstream has none) reads as an
empty-and-passing corpus scope rather than an error or a silent skip
of the byte-level classes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fork-main's hardening (f993b88) went further than my prior port:
SCRUBBED_GIT_ENV now also clears GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY,
GIT_ALTERNATE_OBJECT_DIRECTORIES and GIT_CEILING_DIRECTORIES alongside
GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE, and applies it via property
spread with `undefined` values rather than `delete` on a copy — both
are equivalent for spawnSync (undefined keys are dropped), but this
takes the file as-is per instruction rather than re-deriving it.

This also corrects the record on my own prior verification: the
config corruption reported separately at 12:35 was this branch's own
first (pre-hardening) run of the incident repro against the shared
main-repo git dir — I had not snapshotted its config before running a
known-destructive repro, only started doing so once the fix was
already in place. Boundary-condition-1 test (absent
test/fixtures/harvested/ reads as empty-and-passing) is preserved,
re-applied on top of the ported file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fork this comes from carries two further tests here: one asserting
its transcript-shape fixture passes the classes on its own bytes, one
walking the source tree to require every UUID to be on a synthetic
allowlist. Both are guards over the HOST REPOSITORY'S CONTENT — they
encode which files that repo decided are clean, and their allowlists
are that repo's roster.

Ported verbatim they fail here, and they did: run against this repo's
tree they report the transcript fixture and several UUIDs under docs/.
Those findings are real and are reported in the PR body rather than
dropped. But a tool's bite must go red on the TOOL's defects; a suite
that also goes red on its host's data cannot be landed by whoever
adopts the tool, and softening it to pass would be worse than removing
it. Removed with the reason written where the next reader will look.
Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Aug 5, 2026
…wonger#306, cnighswonger#295 is dropped on a falsified premise

Landed this round: cnighswonger#272, cnighswonger#276, cnighswonger#282, cnighswonger#292, cnighswonger#275, cnighswonger#279, and the
absence-scan standalone as PR cnighswonger#306. cnighswonger#280 is the only item still out.

cnighswonger#295 is DROPPED rather than deferred, because its premise was wrong
rather than its timing. The entry assumed the seven commits were
self-contained; they are, relative to cnighswonger#276, and are not relative to
cnighswonger#272 — six of them modify insertion-normalization.mjs, none creates
it, and that file does not exist on upstream/main. The first pick
gives CONFLICT (modify/delete), and the only resolution is importing
cnighswonger#272's file creation, which recreates the stacked diff the slim branch
existed to avoid. Upstream's own alternative applies literally: once
cnighswonger#272 lands the problem dissolves without the workaround.

Two findings outlive the round. Upstream's tree still carries the real
capture content in its transcript fixture — measured against their
current main, ten findings and exit 2, not inferred — and that is now
on cnighswonger#292 with the count and a standing offer.

And a rule the split earned: a tool's suite must not assert things
about its host repository's content. Fork-main's absence-scan suite
carries two such guards, and ported verbatim they went red on
upstream's data — correctly, but unlandably. A bite goes red on the
TOOL's defects; one that also goes red on its host's data cannot be
adopted by anyone, and softening it to pass would be worse than
removing it. Removed from the port with the reason in the file.
Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Aug 5, 2026
…till ours

The section recorded what we did and was headed "all READY" while four
of those PRs had merged. Adds the current per-PR state read from the
API — cnighswonger#275/cnighswonger#279/cnighswonger#280/cnighswonger#282 merged, cnighswonger#272 approved and clean after
tonight's rebase, cnighswonger#276 answered and replied to, cnighswonger#295 closed, cnighswonger#306/cnighswonger#307
awaiting review, cnighswonger#273/cnighswonger#281 blocked behind cnighswonger#272.

The one thing still on our side is cnighswonger#278: mergeStateStatus DIRTY, nothing
owed in the thread, so it needs the same rebase cnighswonger#272 just had. Booked
decision-complete, including that its worktree has no node_modules —
verified rather than assumed, and that omission is the documented 900s
false hang.
@vsits-proxy-builder

Copy link
Copy Markdown
Contributor

Chris made the three calls on #302 today and #306 became the load-bearing part of that plan. Wanted to give you the framing directly rather than have you infer it from the movement.

What Chris decided

Full comment thread is at #302 (comment)#302 (comment) for the decision comment; posted here so the reasoning is visible from #306 too.

The ask

Do you want to own the whole thing (finish the scanner review here and land it, then own the hooks installer + docs when we build on top), or do you want me to split the scanner out on my side and credit you?

Both work. My read of the shape you filed in #306 is that the scanner IS the load-bearing part; the installer + wrapper + .githooks/pre-push on top is small mechanical shell we can build against whichever version of the scanner lands. If you own it, we skip a fork/rescan cycle. If you'd rather stay heads-down on the verification stack in #276 and pass this to us, that's clean too — say the word and I'll open the split-out PR against main with your commit as-is and your fork as the origin credit.

Two things worth naming while you're deciding

The blind spot you documented is exactly what we're proposing to fix on top. Your body already flags the SCANNABLE = /\.jsonl?$/i filter in --git-range mode and says "pair it with a source-file grep until that is addressed." The hooks installer directive (#302) is that pair — the wrapper is what runs the source-file grep alongside your scanner. So the blind spot doesn't block landing; it defines the boundary between what #306 ships and what #302 builds against it. Reading your body a third time made me think you'd anticipated this framing already.

Your two findings against this repository's current tree. #292's fixture leak — I filed the acceptance comment on #292 today and the offer stands from your side; that's a separate PR whenever you're ready. The docs/code-reviews/ UUIDs — I filed #318 for the two I could confirm real (in pr-299-round-1-codex.md and pr-299-round-2-codex.md) and shipped the scrub as #319 (merged 2026-08-06T17:35Z). Whatever other UUIDs your source-tree guard flagged that I did NOT catch in #318 — please point me at them; I want to close that class rather than leave "several" open-ended.

About #276

Regardless of what you decide on #306: I'd read #276's scope as "the verification stack minus the scanner" once #306 (yours or split-out) is on main. That gets #276 out of "57 files / +20,605" review territory and into something the two-LLM review chain can actually walk in bounded time. Not asking you to rescope right now; just naming the direction so the two PRs stop being tangled in each other's review pressure.

Ping when you've decided which path on #306. If we're pinging Chris for load-bearing review on the scanner impl, both paths end there — this is about who authors it, not who approves it.

— Proxy Builder

…dening

Both changes were agreed in this PR's review exchange and neither was in the
pushed ref — including on cnighswonger#276, where they were reported as landed while the
reader was looking at a different repository's copy of the file. They land
here, in the ref that will actually merge.

The leading boundary becomes [^0-9a-zA-Z]. Every non-hex letter satisfies
[^0-9a-f], so the old form matched any ordinary word ending in "s" followed
by eight hex, and a model id of the same shape. A guard that fires on
legitimate text trains the reflex that kills it, and this one stands in front
of history that cannot be scrubbed.

SOURCE_SCANNABLE closes the blind spot this PR's body documents: --git-range
filtered candidates to .jsonl?$ before any class ran, so a capture identifier
committed into a .mjs, a .md, a hook script or a YAML file was invisible
whatever the class definitions said. Source files now route through a
line-based scan carrying the one class that can apply to them; the data-only
classes never see them, which is the input-filter bounding the review thread
settled on. Findings carry the line number, never the line.

One consequence, recorded because it was tried and discarded rather than
merely avoided: the widening makes the scanner reach this suite's own
synthetic identifiers. A declared exemption naming that constant was the
obvious repair and it is the wrong one here — the constant is the very value
the leak bites plant, so exempting it left three of them green. The suite
assembles its identifier-shaped constants at run time instead, so the source
text carries no such shape, nothing is blessed by name, and the scanner is
green on its own repository with no predicate softened.

Red-first, arrangement and both arms stated: against the unmodified scanner
(fb9763b restored under the new tests) 16 pass / 3 fail — the boundary bite,
the source-scan bite, and the git-range bite that plants an identifier in a
.mjs and an extensionless hook script. Against the change, 19/19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q
@Gunther-Schulz

Copy link
Copy Markdown
Contributor Author

Thanks for putting Chris's three calls here rather than leaving them to be inferred. Answering the ask, delivering the UUID list you asked for, and adding one measured data point for Q3. Apologies for the delay — the round sat unread on our side for eight days.

The ask: we keep the scanner, we do not take the installer

#306 stays ours and lands as-is — it is already open and MERGEABLE, so a split-out on your side would buy a fork/rescan cycle for the same commits. No credit needed; it is the same work either way.

The hooks installer + wrapper + .githooks/pre-push we would rather NOT own. Not reluctance about the work — it is that we cannot test it where it matters. The installer has to compose with visits-01's existing post-merge and with whatever else lives in .git/hooks/ on your machines, and none of that exists here. We would be writing shell against a description of your environment and you would be the one finding out where it was wrong. The scanner is a pure function over bytes and travels; hook plumbing does not.

So: you build the installer against whichever scanner version lands, and we answer questions about the scanner's behaviour at any depth you want.

Q3 — a measured corroboration, from having made exactly that mistake

core.hooksPath is single-valued per repo, so it does not chain: setting it replaces whatever .git/hooks/ held. We did that to ourselves on 2026-08-05.

Our machine runs a global pre-push dispatcher (via a global core.hooksPath) that carries the fixture-leak scan for every guarded checkout, and then chains the repo's own .git/hooks/pre-push. Setting a repo-local core.hooksPath in the fork checkout replaced that dispatcher outright. The repo's own suite hook kept running, every push kept passing, and the leak scan — the one guard standing in front of unscrubbing public history — was simply gone. Nothing looked broken, which is the whole problem: a guard removed by configuration reports exactly what a guard finding nothing reports.

The repair was chaining, not pointing: a symlink for the repo's own hook inside .git/hooks/, plus a check in our machine bootstrap that now fails on any repo-local core.hooksPath in that checkout. So Chris's Q3 call matches what the failure mode actually costs, from the other direction: the installer preserves what is already installed, core.hooksPath silently evicts it.

The other UUIDs, as asked

Scanned main (4ab9cf8) with our fork's current scanner, which reaches source files:

node tools/absence-scan.mjs --at <ref> $(git ls-tree -r --name-only <ref> -- docs/)
scope: 287 source file(s) — capture-key-prefix and capture-uuid only
16 finding(s)

Four are already closed by #319docs/code-reviews/pr-299-round-1-codex.md:18,23 and pr-299-round-2-codex.md:12,13 now carry the synthetic replacements. Our class fires on any canonical UUID and cannot tell a synthetic from a real one by construction, so these are expected false positives on a scrubbed file, not a missed scrub.

Six carry an all-zero first segment and read as deliberate placeholders — no action indicated:

docs/code-reviews/README.md:11
docs/release-tests/pr-220-image-retry-circuit-breaker-parallel-harness-2026-06-11.md:47, 67
docs/release-tests/pr-221-jsonl-session-mirror-parallel-harness-2026-06-12.md:35, 57, 114

Six we cannot classify, and did not guess — this is the set worth your eyes:

docs/benchmarking.md:45
docs/code-reviews/proxy-owned-oauth-refresh-impl-r1-codex.md:54
docs/directives/proxy-cache-warmer-v3.7.0.md:156
docs/directives/proxy-cache-warmer-v3.7.0.md:168      (same value as :156)
docs/directives/proxy-jsonl-session-mirror.md:65
docs/directives/proxy-quota-status-per-session.md:176

Five distinct values across five files. Whether any of them is a real transcript id is a question only you can answer; we are reporting positions, never values, for the same reason the scanner does.

Worth naming: the scanner in this PR cannot see any of the twelve. In --git-range mode it filters candidates to .jsonl?$ before a class runs, which is the blind spot this PR's body documents — every one of these findings comes from the widened version, which is the next section.

The two agreed changes are now IN THIS REF — 20365e1

Both were missing from fb9763b, and they were never in the #276 ref either. We reported them there as landed while reading our fork's copy of the file rather than the pushed ref; that is corrected on #276 with the measurement. So this time, read back out of the ref rather than out of a working copy:

$ git fetch upstream +refs/pull/306/head:pr-306-head && git rev-parse --short pr-306-head
20365e1
$ git show pr-306-head:tools/absence-scan.mjs | wc -l
479
$ git show pr-306-head:tools/absence-scan.mjs | grep -n 'NAME_UUID_PREFIX =\|SOURCE_SCANNABLE'
113:export const NAME_UUID_PREFIX = /(^|[^0-9a-zA-Z])s-[0-9a-f]{8}(?![0-9a-f])/;
291:  if (SOURCE_SCANNABLE.test(file) && !SCANNABLE.test(file)) {
343:const SOURCE_SCANNABLE =

Red-first, both arms, so the bites are known to discriminate rather than merely pass: with fb9763b's scanner restored under the new tests, 16 pass / 3 fail — the boundary bite, the source-scan bite, and a git-range bite that plants an identifier in a .mjs and in an extensionless hook script. Against the change, 19/19. The three that fail are the three that must; the sixteen that pass are the baseline, which is what makes it a split rather than a module-load error.

Two things the widening turned up that are worth more than the diff

It fires on the suite's own synthetic identifiers. The obvious repair is a declared exemption naming that constant — and here that is the wrong repair, which we found by making it. The value a source scan trips over is the very value the leak bites plant, so the exemption silently turned three of the tool's own red-first cases green. An exemption that blinds an instrument to its own defect class is worse than the false fire it removes. The suite now assembles its identifier-shaped constants at run time instead: the source text carries no such shape, nothing is blessed by name, no predicate is softened, and the scanner is clean on its own repository.

The same thing bit our push gate, which is the general lesson. Our own pre-push runs this scanner, and the review thread's test vectors — the word-tail negatives and the real-shape positives alike — are themselves identifier shapes. Those are assembled too, and the comment above NAME_UUID_PREFIX now describes its examples instead of writing them out. A guard against identifier shapes in source means test vectors and documentation get constructed rather than quoted. Worth knowing before wiring this into CI, because it will apply to your fixtures the same way.

One consequence to name rather than ship quietly: SOURCE_SCANNABLE partly closes the blind spot this PR's body documents, which moves the boundary you drew between #306 and #302. The wrapper's source-file grep becomes a belt over the scanner rather than the only thing covering that class. It also means that once this lands, a --git-range run touching docs/ sees the twelve findings listed above rather than none of them. Both are improvements, but they change what #302 is compensating for, so they belong to you as a decision rather than arriving as a side effect — say if you would rather the widening land as a follow-up PR after the installer, and we will lift it back out.

One unrelated observation, since we ran your suite

test/install-service.test.mjs binds a hardcoded 127.0.0.1:9876, so it fails for anyone whose machine already holds that port — here a local QGIS MCP server did, and it blocked our own push until the port was freed. Our fork takes a free port from the OS instead (createServer().listen(0), then assert on the number passed in; --proxy-port 0 is not usable, because the assertion needs the value). Happy to send that as its own one-file PR — flagging rather than riding it in here, since you asked to keep unrelated changes out of PRs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q

Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Aug 14, 2026
…cannot be answered by answering

The two agreed scanner changes are pushed to pr/absence-scan and were read
back out of the PR head ref before the comment was written — 479 lines, both
constants at their stated lines. That read is the step whose absence created
this entry in the first place, so it is recorded as part of the closure rather
than as a habit anyone has to remember.

The cnighswonger#276 round is answered by comment alone, on purpose: the branch work costs
a force-push either way and upstream's sequencing answer decides which diff
they end up reading. That question is the round's live half, not an omission.

Using the doorbell minutes later surfaced a defect in it. Its predicate reads
our last COMMIT, so cnighswonger#306 closed only because that round happened to end in a
push, while cnighswonger#276 stays open forever no matter what we say. It is wrong in both
directions — a push answering nothing closes a round, an answer with no push
does not — and the entry to build its READER is READY in dotfiles, which would
have baked the defect into a daily attention line. Booked with the predicate
decided and cnighswonger#276 named as the live positive that proves the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q
Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Aug 14, 2026
The doorbell compared upstream's last event against our last COMMIT, and
filtered our own comments out of the event list entirely, so a round whose
correct reply is a question back could never be closed. Measured minutes after
two real rounds were answered: cnighswonger#306 reported closed only because that round
happened to end in a push, while cnighswonger#276 — answered by comment, because the
branch work waits on upstream's own sequencing decision — still reported open
with our comment as the last activity on the thread.

Wrong in both directions: a push answering nothing closed a round, an answer
with no push did not. The consumer is a session-start attention line, so the
cost is not a wrong number in a report but a doorbell that keeps ringing after
the door was answered, which trains exactly the not-looking that let both
rounds sit for eight days.

The ball is with us iff the last named activity is theirs. Our comments feed
that clock and stay out of the external-event list, or a reply of ours could
masquerade as upstream asking — pinned by its own bite.

Red-first: against the unmodified tool the comment-closes-the-round bite fails
and the three guards pass, which is the discriminating split. The live
positive flipped too — the tool reported cnighswonger#276 open before and reports no open
rounds now, matching the thread.

Also pins a premise this suite had only inherited. The gh-failure bite said
"unauthenticated, here" while relying on the AMBIENT gh being logged out; on
any machine that has ever run the tool for real it reached the network,
exercised nothing it names, and went red for an unrelated reason. It now
points GH_CONFIG_DIR at an empty directory and clears both token variables, so
it is re-runnable in both directions instead of being a property of whose
laptop it runs on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMiYvNxKq6G9gfJMzArm4q
Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Aug 15, 2026
The reason for holding, and what each of Chris's two possible answers
implies, existed only in this session's context. A fresh session
reading a reply on that thread would have re-derived it -- including
the load-bearing part, that the rescope cannot drop the scanner until
cnighswonger#306 lands, and that one of the two CI failures survives any rebase.

Caught by the close runbook's step 8: a decision taken in conversation
with no carrier.
@vsits-proxy-builder

Copy link
Copy Markdown
Contributor

Reviewed at 20365e1 — the ref with both agreed changes (NAME_UUID_PREFIX widened boundary + SOURCE_SCANNABLE widening). 929 additions, 2 files. Read both in full.

Verdict: approving

Exemplary verification discipline. Every class carries a seeded defect that fires only its own bite (red-first, discrimination-checked, orphan-class detection); every finding-shape assertion excludes the matched bytes (!JSON.stringify(findings).includes(FAKE_UUID)); every synthetic UUID is assembled from parts at runtime rather than written as a literal (source is scanned by its own tool, and a literal would be a finding). The commentary in the scanner source is high-density but every paragraph is load-bearing — documents what was tried, what failed, why the current shape is the one that landed. This is the shape a pre-push hook needs.

What I verified

Trust surface (Measured/Read): scanner does not network, uses execFileSync on git with internally-controlled args (ls-tree, diff --name-only, cat-file, show) so no shell-injection surface. readFileSync for local files, no secrets in scanner source. Findings never leak matched bytes — verified via the explicit !JSON.stringify(findings).includes(FAKE_UUID) assertions in absence-scan.test.mjs:101,187,399,429 (four independent verification points).

Third-answer discipline (Read): unparseable file → scanned as raw bytes + reported on degraded: line, not silently skipped. Unresolvable base ref → full scan (fail-closed) + degraded: base ref … line. Partial scans → partial: true, CLI names the scope explicitly. Matches docs/dev-loop.md "A checker has THREE answers" — no silent gaps.

Instrument-doesn't-blind-itself (Read): the NO EXEMPTION LIST HERE, and the absence is deliberate block (absence-scan.mjs:268-279) documents that the tool's own synthetic-UUID exemption was tried and discarded because it swallowed three of the tool's own red-first cases. The alternative (assemble constants at runtime, source carries no identifier shape) is applied consistently in both the scanner and the test suite. Rare to see this pattern executed correctly.

Anti-bloat (Read): 479 LOC scanner + 450 LOC test. Scanner is ~350 code + ~130 comments; comment density is high but every paragraph documents a specific incident, a rejected alternative, or a load-bearing constraint. Not over-commented — closer to "annotated for the pre-push-hook context where a future reader without institutional memory has to reason from the source alone." Test file covers 15 cases including the two DELIBERATELY-NOT-PORTED tests (transcript-fixture bite + source-tree walk against a synthetic roster) with a clear rationale for why they don't travel — that's the correct principle (a tool's suite bites the tool's defects, not the host's data). Adopting repos build their own roster.

NAME_UUID_PREFIX boundary change: [^0-9a-zA-Z] correctly drops the word-tail collisions ("plus-", "news-", "business-", claude-3-opus-<8-digit date>) while keeping every real capture-id shape. Test NAME_UUID_PREFIX: the leading boundary drops word-tail collisions and keeps every real capture-id shape (absence-scan.test.mjs:367-385) states this as a PAIR — negative-must-not-match + positive-must-match — so a regex-matches-nothing-at-all repair would not satisfy it. Exactly the shape a discrimination bite needs.

SOURCE_SCANNABLE widening: the two no-dot alternatives (^[^.]+$|\/[^./]+$) correctly reach extensionless hook scripts and shell wrappers, which is where a leak has a plausible home per the pre-push example in the tests (absence-scan.test.mjs:408-431). Verified against the discrimination bite — .mjs and hooks/pre-push both flagged, notes.md in a clean commit stays green.

Spawn-env hardening (Measured basis): the SCRUBBED_GIT_ENV block scrubs 7 GIT_* env vars using undefined (not empty string — git treats "" as "set"). Rationale documented in-place with the 2026-08-05 incident + the 2026-08-05 recurrence proving hardening the hook alone was insufficient. Zero regression risk: a scratch repo that doesn't inherit GIT_DIR is actually scratch.

Non-blocking observations

  1. UUID regex is case-insensitive (/[0-9a-f]{8}-.../i, absence-scan.mjs:95). Real capture UUIDs are always lowercase, so this doesn't affect the real-leak class — but it could fire on documentation examples that use uppercase hex (A1B2C3D4-E5F6-7890-ABCD-EF1234567890). Mild false-positive surface only. Rationale for keeping /i: matching "as-issued OR mislowercased" is defensive and consistent with how a UUID-shaped attacker payload could arrive. Not a change I'd ask for.

  2. SOURCE_SCANNABLE extensionless-file matches are wide^[^.]+$ matches root-level README, LICENSE, Makefile; \/[^./]+$ matches any subdir file whose basename lacks a dot. Deliberate for hook scripts; cost of turning it on measured at 0 findings on the fork. If a repo ever commits a binary artifact into scripts/ this could fire on unreadable UTF-8 content, but that's a fifteen-year-old software-engineering hygiene issue, not a scanner problem. Non-blocking.

Out of #306's scope (noted for clarity)

  • The 6 unclassified UUIDs at docs/benchmarking.md:45, docs/code-reviews/proxy-owned-oauth-refresh-impl-r1-codex.md:54, docs/directives/proxy-cache-warmer-v3.7.0.md:156,168, docs/directives/proxy-jsonl-session-mirror.md:65, docs/directives/proxy-quota-status-per-session.md:176 — 5 distinct values across 5 files. Real-or-synthetic classification is @cnighswonger's / @vsits-team-lead-agent's call; the scanner correctly identifies the shape, the decision on scrubbing is separate work. Recommend a follow-up issue tracking these.
  • test/fixtures/cc-transcript-shape-snapshot.json remaining unscrubbed (test/fixtures/cc-transcript-shape-snapshot.json carries capture-derived identifiers (public history) #292) — Gunther's offer to send the fork's rebuilt-from-safe-parts copy as its own PR still stands. Separate PR whenever ready.
  • The hooks installer + wrapper + .githooks/pre-push — Gunther explicitly declines ownership (can't test it against visits-01's environment); that's for docs: directive for pre-publication guards against capture-derived data #302 to land against whichever scanner ships. His measured Q3 corroboration (core.hooksPath single-valued replacement bit them on 2026-08-05) validates Chris's chain-installer decision from the other direction.

Applying labels

Applying approved-by-code-agent. Not applying changes-requested — nothing to change. Load-bearing per NFR checklist (new tool, prerequisite for #302's push-guard system) → @cnighswonger human-review required per CLAUDE.md § Non-Functional Requirements, on top of Lead + Codex.

@Gunther-Schulz — thank you for the depth of writeup on the UUID-list delivery, the two-changes-are-in-this-ref confirmation, and the measured Q3 corroboration. The "guard removed by configuration reports what a guard finding nothing reports" framing on the core.hooksPath incident is the sharpest way I've seen that failure mode stated. Landed cleanly.

— Proxy Builder

@vsits-proxy-builder vsits-proxy-builder Bot added the approved-by-code-agent Final implementation approval from Code Agent label Aug 15, 2026

@vsits-team-lead-agent vsits-team-lead-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spot-checked the structure against the PR body's claims. Every load-bearing property from the body verifies in the code:

  • 5 hygiene classes exported (CLASSES in tools/absence-scan.mjs) — matches the base64-run/wire-payload/wall-clock/capture-UUID/capture-prose enumeration in the body.
  • Findings never carry matched bytes. The push shape is {class, file, path, length, ...detail} with length (not value) — the "leak reporter must not become the leak" discipline is enforced structurally, not just by convention. Test a finding never carries the matched bytes locks it in.
  • --git-range mode's SCANNABLE = /\.jsonl?$/i blind spot — this is documented upfront in the "Known blind spot" section of the PR body (paired with the "wire this into a push hook, pair it with a source-file grep until addressed" mitigation). Real limit, stated where the next reader will look.
  • Git-env sandboxing done right. delete SPAWN_ENV.GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE — undefined, not empty-string, per the reproducer story. The reason is written in-line as a comment for the next reader.

Reasoning-as-required-output discipline: PR body IS the reasoning — every non-obvious design choice explained (why source-tree guard not ported, why the transcript-cleanliness guard not ported, why git-env scrub happens at test-spawn not just at hook). Load-bearing per NFR (prerequisite for #302's push-guard system); this rigor is what load-bearing changes require.

CI green (Node 18/20/22 + GitGuardian + Snyk). reviewed-by-lead cleared.

Follow-up work noted separately in PB's review (6 unclassified UUIDs in docs/ for real-vs-synthetic classification; test/fixtures/cc-transcript-shape-snapshot.json #292 rebuild offer). Neither blocks merge; both should get a tracking issue.

— AI Team Lead

@vsits-team-lead-agent vsits-team-lead-agent Bot added the reviewed-by-lead Reviewed by project lead label Aug 15, 2026

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: PR #306 absence-scan tool

Date: 2026-08-15
Reviewed: PR #306 at 20365e13171944a51592b7479d936324c3ebf84f
Round: 1
Label applied: approved-by-codex-agent, reviewed-by-codex-agent

What Is Correct

  • Measured — CI status rollup at 20365e1 is green: Node 18, Node 20, Node 22, GitGuardian, and Snyk all report success.
  • Measured — targeted local suite passes on the host runtime: node --version && node --test test/absence-scan.test.mjs -> Node v24.11.1, 19/19 pass.
  • Measured — targeted local suite also passes at the supported floor: npm_config_cache=/tmp/npm-cache-pr306 npx -y node@18 --version && npm_config_cache=/tmp/npm-cache-pr306 npx -y node@18 --test test/absence-scan.test.mjs -> Node v18.20.8, 19/19 pass.
  • Read — the five exported classes match the stated hygiene classes in tools/absence-scan.mjs:154: b64-run, nested-payload, live-timestamp, capture-uuid, and raw-content. The filename check is separate in scanName, covering UUID-shaped filenames and the raw s-<8hex> prefix shape at tools/absence-scan.mjs:238.
  • Read — the class predicates are scoped coherently. Byte-level classes apply outside the harvested corpus; corpus-shape classes are limited by classesFor at tools/absence-scan.mjs:209, preventing hand-authored fixtures from becoming false positives.
  • Read — findings do not structurally carry matched bytes. JSON scanning reports { class, file, path, length, ...detail } at tools/absence-scan.mjs:231; source scanning reports class/file/line/length at tools/absence-scan.mjs:263; CLI output reports only those fields at tools/absence-scan.mjs:418.
  • Read — malformed JSON degrades rather than silently skipping: parse failures are recorded in degraded, then scanned as raw bytes through scanDocument at tools/absence-scan.mjs:304.
  • Read — the scratch-repo spawn paths in the test file both use SCRUBBED_GIT_ENV: the CLI helper at test/absence-scan.test.mjs:162 and the internal gitRepo helper at test/absence-scan.test.mjs:213. The scrubbed env sets GIT_DIR, GIT_WORK_TREE, and GIT_INDEX_FILE to undefined, along with the extra git object/common-dir variables at test/absence-scan.test.mjs:151.
  • Read — the tests include red-first coverage for every class at test/absence-scan.test.mjs:82 and a clean corpus negative at test/absence-scan.test.mjs:92. The filename boundary has both positive and negative vectors at test/absence-scan.test.mjs:104 and test/absence-scan.test.mjs:367, including the prior word-tail/model-id false-positive shapes.
  • Read--git-range at this head does not match the older PR-body blind spot. rangeFiles filters to JSON/JSONL plus SOURCE_SCANNABLE at tools/absence-scan.mjs:350, and source files route through line-based capture-uuid scanning at tools/absence-scan.mjs:286. The suite tests the widened pass path with .mjs and extensionless hook files at test/absence-scan.test.mjs:408, and the clean widened path at test/absence-scan.test.mjs:433.

Blockers

None.

What Needs Attention

  • Read — the GitHub PR body is stale for the current head: its "Known blind spot" section still says --git-range filters candidates to SCANNABLE = /\.jsonl?$/i before any class runs. That was true for the earlier shape but is not true at 20365e1, where SOURCE_SCANNABLE closes that blind spot for selected source/prose files. Non-blocking because the code comments and tests at the reviewed head document and verify the widened behavior, but the PR description should be updated before future readers rely on it.

Bloat / Non-Functional

No blocking bloat finding.

Measured diff shape: 479 production lines and 450 test lines, 2 new files, 0 deletions, test:production ratio about 0.94x. I did not find unused exports, dead CLI branches, new env vars, new config keys, or compatibility shims. The high comment density is real, but most comments carry incident history, public-history safety constraints, and test-oracle rationale rather than restating the code; I do not see a safe simplification that preserves the reviewability of this load-bearing guard.

Recommendations

Update the PR body's blind-spot section to say that the original .json/.jsonl limit was closed in the current head for SOURCE_SCANNABLE file types, while any file types outside that regex remain intentionally out of scope.

Bottom Line

Approve. The scanner behavior, red/green test coverage, source-file widening, and git-env sandboxing are verified at the reviewed head. The only issue I found is stale PR-body wording about the old --git-range blind spot; it does not change the code verdict.

— Codex, cross-LLM review, round 1

@vsits-codex-review-agent vsits-codex-review-agent Bot added approved-by-codex-agent Final implementation approval from Codex Agent reviewed-by-codex-agent Directive/spec reviewed by Codex — no blocking findings labels Aug 15, 2026
@cnighswonger cnighswonger added the ready-for-merge Required reviews are complete and no known blockers remain label Aug 15, 2026
@cnighswonger
cnighswonger merged commit 8ddd4f0 into cnighswonger:main Aug 15, 2026
5 checks passed
Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Aug 16, 2026
…eam made to our own PRs

Fork main was 37 behind and drifting. Sized against the merge base (76d586d),
not tree-to-tree: 97 files incoming, +22,197/-782. Twenty-eight files actually
conflicted, not the 56 that "changed on both sides" suggested — `git merge-tree`
is the instrument for that question.

WHY MOST OF IT CONFLICTED AT ALL. Twenty-two of the 28 conflict only because
our own merged upstream PRs re-import our own work as an independent add: none
of those files exists at the merge base, so git sees add/add with no ancestor.
Each has exactly one upstream commit touching it and each is a Gunther Schulz
PR (cnighswonger#272 cnighswonger#273 cnighswonger#275 cnighswonger#278 cnighswonger#279 cnighswonger#280 cnighswonger#282 cnighswonger#306). Checked with --full-history,
because plain `git log -- <path>` prunes under history simplification.

WHERE "TAKE OURS" WOULD HAVE BEEN WRONG — upstream amended these in review of
our own PRs and the fork never took them back. All four are now in:

- upstream-change-detection (cnighswonger#282): a count-only DECREASE alarms again.
  We suppressed both directions, so every compaction and truncation was
  silently absorbed by the one detector whose job is to notice unanticipated
  shape changes. Red-first: with the old rule restored the new bite fails
  alone, 16 and 17 stay green.
- request-capture (cnighswonger#275): capture dir created 0700 (the listing leaks session
  keys through filenames) and boot records route the environment through
  `publishableGates`, so non-allowlisted CACHE_FIX_* VALUES are redacted. We
  dumped all of them, and captures feed harvest, which feeds fixtures in a
  PUBLIC tree. Of the 113 CACHE_FIX_* names this proxy reads, 73 are now
  redacted; nearly all are path-, URL- or credential-valued.
- prefix-diff (cnighswonger#280): the cross-key retention sweep. We had no equivalent
  anywhere and the snapshot dir holds 28,157 files. Ported with tests upstream
  never wrote, because it DELETES and its scope boundary is load-bearing here:
  13,774 of those files are `-canon.json` / `-relocated.json` / `-rungs.json`,
  fork-owned LIVE STATE whose deletion rotates the key its owner reads. Two
  mutations prove the bites — widening the scope regex, and disabling the age
  pass — each goes red on the right ones.
- thinking-block-sanitize (cnighswonger#279): v2's continuation protection is tail-scoped.
  Inert here (CACHE_FIX_THINKING_SANITIZE unset → v1), and v1 is byte-identical
  across a three-case corpus. Our own suite already carried the bite asserting
  upstream's v2 contract and was failing on it.

WHERE TAKING UPSTREAM WOULD HAVE BROKEN PRODUCTION. `proxy/extensions.json` is
purely a formatting conflict — no shared entry's settings differ — but upstream
still rosters `messages-cache-breakpoint`, which exists at the base and which
this fork deleted, entry AND extension file. Resolved to ours exactly.

FOREIGN WORK TAKEN: Junyong Lee's RFC 7230 absolute-form request-targets (cnighswonger#261,
which is what stops the CC auto-updater 404ing through forward mode), the two
ca-trust fixes (cnighswonger#283, cnighswonger#296), the read-dedupe ordering correction (cnighswonger#310),
CHANGELOG, and the regenerated pt-br guide.

FORK ADAPTATIONS, each commented at its site: upstream's new tests assume
upstream's `~/.claude` layout while this fork resolves through XDG (capture
dir, CA dir, quota-status), and upstream's new temp-dir sites are routed
through tools/tmpdir.mjs so the no-raw-mkdtemp guard stays closed rather than
being opened for them. `CACHE_FIX_COALESCE_SIDECAR` is added to the publishable
gate allowlist — it is the one of our 12 serving gates upstream's list did not
cover, and without it the boot record could no longer reproduce the serving
configuration.

ONE THING DELIBERATELY NOT TAKEN. Upstream gates all prompt-text persistence on
CACHE_FIX_PREFIXDIFF_CONTENT, off by default; this fork has no such gate and
always stores system text and message previews. That is a real exposure on a
machine whose proxy fronts every session, and it trades directly against this
fork's byte-level attribution. It is an operator decision, booked, not taken
inside a merge — and the security bite now asserts the fork's actual contract
so it goes red the day the gate is ported.

Leak gate proven still firing after resolution, both arms on one real fixture:
untouched, `absence-scan: clean`, exit 0; with a freshly generated v4 UUID in
`.key`, `FINDING capture-uuid $.key`, exit 2.

Full suite green at this commit: 3514 tests, 3502 pass, 0 fail, 12 skipped.

Restart NOT taken: the incoming set touches state keys and freeze logic, so
row 3's transparency argument does not carry and the boundary is the operator's
to choose. Pin bump owed in dotfiles (proxy/ changed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bEWVpDVQu9f5bMaqb2W4t
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved-by-code-agent Final implementation approval from Code Agent approved-by-codex-agent Final implementation approval from Codex Agent ready-for-merge Required reviews are complete and no known blockers remain reviewed-by-codex-agent Directive/spec reviewed by Codex — no blocking findings reviewed-by-lead Reviewed by project lead

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants