Skip to content

fix(release): releases were disabled by the mirror-era guard; plus the leak-gate bootstrap (F.1, C.3)#7

Merged
wshallwshall merged 3 commits into
mainfrom
leak-gate-bootstrap
Jul 27, 2026
Merged

fix(release): releases were disabled by the mirror-era guard; plus the leak-gate bootstrap (F.1, C.3)#7
wshallwshall merged 3 commits into
mainfrom
leak-gate-bootstrap

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Two cutover-completion items. Read the first one — it means no release could ship.


1. Releases were disabled entirely (runbook F.1)

Both jobs in release.yml were gated:

if: github.repository != 'MEFORORG/MessageFoundry'

Correct when this repo was the published mirror — the mirror must never release, and publish.ps1 rewrote the private slug to the public one across *.yml, so != public-slug was the only rewrite-proof form.

Both premises died at the cutover. publish.ps1 is retired, and MEFORORG is now the source. Left inverted, the guard skips the release on the only repo that can publish — PyPI Trusted Publishing is bound to MEFORORG/MessageFoundry + release.yml (see the release.yml header, and F.1's owner prereq). The job guard and the OIDC binding pointed in opposite directions: a pushed v* tag would have silently skipped and shipped nothing.

Nobody noticed because no tag has been cut since the cutover. F.1 — "validate the release under the new OIDC binding" — is precisely the step that would have caught it.

Every other slug guard (ci.yml, codeql.yml, quality-advisory.yml, scorecard.yml) was already flipped to == at the cutover. release.yml was missed. == is also the safe form now: a fork, or the retired vault, still cannot release.

The test was pinning the bug

tests/test_release_pipeline.py asserted the inverted guard appeared at least twice, with a comment explaining the publish.ps1 rewrite. It was right for the old topology and outlived it — so the broken state looked deliberate and was enforced. Rewritten to assert the post-cutover invariant, reject the old form explicitly, and state plainly that it used to assert the opposite. Mutation-verified: restoring the inversion fails it.

F.1 is still open. Cutting vX.Y.Z-rc1 publishes to production PyPI — an owner action, not something to slip into a PR.


2. The leak-gate bootstrap (runbook C.3)

forbidden-content passes --require-tokens and fails closed — correct, since without it the gate loads zero detectors and passes every commit green. The flag stays. But the token list is git-ignored, so it doesn't travel with a clone or git worktree add, and the setup step was documented only inside a file you had to already know to open. CONTRIBUTING.md mentioned neither pre-commit nor the token file.

Outside contributors were permanently blocked — they can never have the real list, making the hook stricter than both CI (which branches to structural-only for fork PRs) and the scanner's own default. The supported answer is to copy the synthetic template.

Which creates the hazard this actually closes

The synthetic example populates every section, so it clears the count floor while matching nothing real. A maintainer who copied it instead of installing the real list gets a green gate that is blind — and no count could tell. So the scanner now recognises its own template and labels the run:

loaded names=7, estate=13, estate_file_scanned=12, site_prefixes=1
loaded names=5, estate=3,  ...  [SYNTHETIC EXAMPLE TOKENS — blind to real customer tokens; CI is authoritative]
loaded names=0, estate=0,  ...  [STRUCTURAL-ONLY: no token source configured]

Three states that exit 0 collapses into one. Only the first is evidence. Compared on parsed content, not bytes, so adding a comment can't silently reclassify a synthetic set as real — the direction that fails open.

File Change
scripts/dev/setup-leak-gate.ps1 new. -From <path> (maintainers) / -Synthetic (contributors). Always ends by running the scanner and printing per-section counts, exiting non-zero if nothing loaded. Verifies the destination is git-ignored, deleting it rather than risk committing the token list.
CONTRIBUTING.md A Leak gate section: why it fails closed, both paths, how to read the three modes.
scripts/worktree/new.ps1 Carries the token file into a new worktree, or says plainly that commits will fail closed.
scan-tokens.local.txt.example States it's a supported contributor setup, that it's labelled on every run, and that its counts are below CI's floor — so passing locally doesn't predict CI.

Four tests, mutation-verified; the negative controls matter because "no source" and "the example" are different failures with different fixes.

C.3's ordering hazard is separately resolved and I've marked it done in the runbook: install-git-hooks.ps1 no longer writes .git/hooks/pre-commit at all (PR #4 moved the ledger gate into .pre-commit-config.yaml), so it can't collide with pre-commit install and order no longer matters.


Not fixed here, deliberately

The pre-commit ruff/bandit hooks scan directories CI does not — scripts/ (1 ruff finding), harness/ (74), tee/ (12), plus pre-existing bandit findings. Touching any file there blocks your commit on errors you didn't cause, invisibly to CI. I started on the one-line UP017 in vuln_metrics.py and reverted it: editing that file pulled in four unrelated bandit findings to annotate. That needs one decision — clean the dirs and add them to CI's scope, or narrow the hooks to match CI — not a drive-by under a release fix.

🤖 Generated with Claude Code

…synthetic token set announce itself

The forbidden-content hook passes --require-tokens and fails closed, which is correct: without it the
gate loads zero detectors in a fresh checkout and passes every commit green. But the token list is
git-ignored, so it does NOT travel with a clone or a `git worktree add`, and the setup step was
documented only inside a file you had to already know to open. Every new worktree therefore hit a hard
commit block with no documented way out -- reported from a parallel session that could not commit a
one-line docs edit. The enforcement was never the defect; the missing bootstrap was.

CONTRIBUTING.md said nothing about pre-commit or the token file at all. It does now, including the
part that matters: an OUTSIDE CONTRIBUTOR cannot ever have the real list, so the hook as shipped
blocked them permanently. That was stricter than both CI (which branches to structural-only for fork
PRs) and the scanner's own documented default. The supported answer is to copy the synthetic template.

Which creates the real hazard this change exists to close. The synthetic example POPULATES every
section, so it clears the count floor while matching nothing real -- a maintainer who copied it
instead of installing the real list would get a green gate that is blind, and no count could tell.
The scanner now compares the loaded set against the committed example (on PARSED content, not bytes,
so a reformatted copy still reads synthetic) and labels the run:

    loaded names=7, estate=13, estate_file_scanned=12, site_prefixes=1
    loaded names=5, estate=3, ...  [SYNTHETIC EXAMPLE TOKENS - blind to real customer tokens; CI is authoritative]
    loaded names=0, estate=0, ...  [STRUCTURAL-ONLY: no token source configured]

Three states that exit 0 collapses into one. Only the first is evidence.

  * scripts/dev/setup-leak-gate.ps1 -- -From <path> (maintainers) or -Synthetic (contributors). It
    always finishes by RUNNING the scanner and printing per-section counts, and exits non-zero if
    nothing loaded: a green gate is evidence only once you have confirmed it can see. It also verifies
    the destination is git-ignored and deletes the file rather than risk committing the token list.
  * scripts/worktree/new.ps1 -- carries the token file into a new worktree, or says plainly that
    commits will fail closed. The symptom otherwise never mentions worktrees.
  * scan-tokens.local.txt.example -- states that using it as-is is a supported contributor setup, that
    it is labelled on every run, and that its counts are BELOW CI's per-section floor, so passing
    locally with it does not predict CI.

Four tests, mutation-verified: forcing the predicate False fails exactly the two positive cases while
the negative controls (a real-shaped set, and no source at all) still hold -- "absent" and "synthetic"
are different failures with different fixes and must not be conflated.

Not changed, deliberately: --require-tokens stays. pre-commit has no per-hook env, so the flag is the
only mechanism, and relaxing it restores the blind-green failure it was added to stop.
…ra guard disabled releases entirely

Both jobs in release.yml were gated `if: github.repository != 'MEFORORG/MessageFoundry'`. That was
right when this repo was the published MIRROR: the mirror had to never release, and publish.ps1
rewrote the private slug to the public one across *.yml, so `!= public-slug` was the only rewrite-proof
form.

Both premises died at the cutover. publish.ps1 is retired (nothing rewrites anything), and MEFORORG is
now the SOURCE repo. Left inverted, the guard skips the release on the ONLY repo that can publish --
PyPI Trusted Publishing is bound to MEFORORG/MessageFoundry + release.yml (release.yml header, and the
runbook's F.1 prereq). The job guard and the OIDC binding pointed in opposite directions, so a pushed
`v*` tag would have silently SKIPPED and shipped nothing. Nobody had noticed because no tag has been
cut since the cutover -- F.1 ("validate the release under the new OIDC binding") is exactly the step
that would have found it, and it is still open.

Every other slug guard in .github/workflows -- ci.yml, codeql.yml, quality-advisory.yml, scorecard.yml
-- was already flipped to `== 'MEFORORG/MessageFoundry'` at the cutover. release.yml was missed. `==`
is also the safe form now: a fork, or the retired private vault, still cannot release.

THE TEST WAS PINNING THE BUG. tests/test_release_pipeline.py asserted the inverted guard appeared at
least twice, with a comment explaining the publish.ps1 rewrite. It was correct for the old topology and
survived it, so the broken state looked deliberate and enforced. Rewritten to assert the post-cutover
invariant, to reject the old form explicitly, and to say plainly that it used to assert the opposite --
a test can outlive the premise it encodes, and this one did. Mutation-verified: restoring the inversion
fails it.

NOT fixed here, deliberately: the pre-commit ruff/bandit hooks scan directories CI does not
(`scripts/` 1 ruff finding, `harness/` 74, `tee/` 12; plus pre-existing bandit findings in scripts/).
So touching any file there blocks your commit on errors you did not cause, invisibly to CI. I started
on the one-line UP017 in scripts/security/vuln_metrics.py and reverted it: editing that file pulled in
four unrelated bandit findings to annotate, which is scope creep on a release fix and would bury it.
It needs one decision -- clean the dirs and add them to CI's ruff/bandit scope, or narrow the hooks to
match CI -- not a drive-by.
@wshallwshall wshallwshall changed the title fix(security): document the leak-gate bootstrap; make a synthetic token set announce itself fix(release): releases were disabled by the mirror-era guard; plus the leak-gate bootstrap (F.1, C.3) Jul 27, 2026
@wshallwshall
wshallwshall enabled auto-merge (squash) July 27, 2026 14:35
wshallwshall added a commit that referenced this pull request Jul 27, 2026
…code before matching (#8)

tests/test_store_aad_binding.py asserted `"DOE" not in on_disk` against an mfenc cell. The cell's
payload is base64 of a random nonce + AES-GCM ciphertext, so matching a 3-character needle against
that TEXT is a coin flip: measured ~0.12% of runs at this payload size, about 1 in 850. It red-X'd
PR #7, whose diff touched release.yml and the leak-gate docs and nothing within reach of the store.

The flake is the smaller half. The same check is also BLIND to the thing it exists to catch. Handed a
cell that base64-encodes the plaintext verbatim, `"DOE" not in on_disk` evaluates TRUE -- "no
plaintext here" -- because base64 of "DOE^JANE" contains no literal "DOE". So it fired at random on
safe data and would have passed a real PHI-at-rest leak. Demonstrated both directions in the commit's
test run.

And the obvious fix is a trap. Reaching for a longer, more distinctive needle -- "DOE^JANE" -- makes
it VACUOUS: '^' is not in the base64 alphabet, so the assertion could never match, would pass
unconditionally, and would look stronger than what it replaced.

So: decode first, then match the FULL plaintext against the at-rest BYTES. A false positive now needs
a collision across the whole message rather than three characters, and a cell carrying its plaintext
fails loudly.

`test_the_plaintext_probe_can_actually_fail` pins the non-vacuity directly: it feeds the probe a
plaintext-bearing cell and asserts the probe says so. Without it the round-trip test would pass no
matter what the store wrote at rest -- which is exactly the state this repo keeps rediscovering.

NOT changed, because not every short needle is wrong:
  * tests/test_support_bundle.py:81 (`"2575" not in blob`) matches PLAINTEXT json.dumps output. It is a
    deterministic redaction check and a short needle is exactly right there.
  * tests/test_uploads.py:52 (`"MRN123" not in blob`) is the same ciphertext shape as this one, but a
    6-character needle is ~400,000x safer (~3e-7 %). Left alone rather than churn it; the pattern to
    avoid is a SHORT needle against ciphertext, not the length itself.
@wshallwshall
wshallwshall merged commit 5ecaf0c into main Jul 27, 2026
32 checks passed
wshallwshall added a commit that referenced this pull request Jul 27, 2026
…olation (#10)

Publishing docs/BACKLOG.md (#6) broke the ledger gate on every branch cut before that merge, including
PR #7 -- whose diff touched release.yml and leak-gate docs and nothing near a ledger:

    OSError: git show HEAD:docs/BACKLOG.md failed (128): fatal: path 'docs/BACKLOG.md' does not exist
    in 'HEAD'

CI's change set is `diff base HEAD`. Once origin/main gained the file, a branch that predates it lists
docs/BACKLOG.md as CHANGED -- as a deletion relative to base -- despite never touching it. check_backlog
then read HEAD for a copy that was never there.

`base_has` (added when the file was imported) covers the opposite end: absent on BASE, being added.
This is the mirror case -- present on base, absent on HEAD -- and it is the one that fires repeatedly,
because it hits every open branch at once rather than the single commit that publishes the file. Both
probes now share `_obj_exists`, which asks `git cat-file -e` directly rather than inferring absence
from an exception: `git()` must keep raising on real failures, since an error swallowed as "empty
ledger" reads as "no numbers taken" -- the false-clean this gate exists to prevent. "The path is not on
that ref" is the one non-failure, so it gets its own probe instead of a broad `except`.

`head_has` mirrors `head_text`'s ref exactly (HEAD in CI, the INDEX locally), so the guard cannot
disagree with the read it guards.

Mutation-verified: neutralise the guard and the new test fails with the same exit-128 shape seen in CI.

Worth noting for whoever meets this next: the failure looked like an unrelated flake on somebody else's
PR, and the first re-run "fixed" nothing -- the run before it had failed for a genuinely different,
genuinely random reason (a 3-char needle colliding with base64 ciphertext, fixed separately). Two
different faults on consecutive attempts of the same leg is exactly the shape that gets misread as one
flaky test.
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.

1 participant