From 4180a1b524444aee7dc0a21bc22b9a94927f61f9 Mon Sep 17 00:00:00 2001 From: kraxo Date: Fri, 7 Aug 2026 20:21:56 +0200 Subject: [PATCH 01/28] ci: extend the release-integrity gate to prose places and external surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate already agreed pyproject/__init__/CITATION. It did not check the two places that state the CURRENT version in prose (RELEASE.md, readiness_pack/ PROGRESS.md), nor PyPI, nor the project page — and PR 575 upstream carried a stale 3.6.2 for weeks without anything noticing. Extended in place rather than as a second script: a second measuring station for the same quantity is the next drift. Historical statements (since vX, as of vX, old changelog headings) are excluded by design and covered by a test — bumping them would turn a fact into a lie. External surfaces have three states. Unreachable is NICHT MESSBAR: it does not fail the run, and it never counts as green; the summary says what was actually verified. --require-external turns it into a failure for the release checklist. --- scripts/check_version_and_changelog.py | 156 ++++++++++++++++++++++++- tests/test_release_integrity_gate.py | 155 +++++++++++++++++++++++- 2 files changed, 304 insertions(+), 7 deletions(-) diff --git a/scripts/check_version_and_changelog.py b/scripts/check_version_and_changelog.py index b445e874..9147d00a 100644 --- a/scripts/check_version_and_changelog.py +++ b/scripts/check_version_and_changelog.py @@ -2,7 +2,7 @@ """check_version_and_changelog.py — release-integrity gate for proofbundle. Closes the "merged but never released / version drift" class (the M2 security fix and the 811-vs-817 -typo both sat unreleased on main because nothing enforced this). Three checks: +typo both sat unreleased on main because nothing enforced this). Five checks: 1. VERSION SINGLE-SOURCING: pyproject.toml, src/proofbundle/__init__.py and CITATION.cff MUST agree. 2. CHANGELOG DOCUMENTS THE VERSION: the current version has a `## []` section in CHANGELOG.md. @@ -10,21 +10,59 @@ version was NOT bumped past that tag, CHANGELOG.md MUST carry an `## [Unreleased]` section — otherwise work is sitting on main undelivered with no changelog trace. Git-gated: skipped (with a note) when git history / tags are unavailable (e.g. a shallow checkout without tags), never a false failure. + 4. TRACKED PROSE PLACES: every place that states the *current* version in prose (see _TRACKED_PLACES) + MUST state the source version. A place whose anchor phrase has vanished is a failure too, not a + silent pass — a gate that stops finding its anchor stops gating. + 5. EXTERNAL SURFACES (opt-in, --external): PyPI and the project page must state the same version. -Exit 0 = OK, 1 = violation. stdlib only, offline, no third-party deps. +WHAT THIS DOES NOT TOUCH, deliberately: historical statements. "since v3.7.0", "as of v3.7.0" and old +CHANGELOG headings record *when* something became true. Bumping them would turn a fact into a lie, so +they are not in _TRACKED_PLACES and must never be added to it. -Usage: python3 scripts/check_version_and_changelog.py [--repo ] +THREE STATES, not two. An external surface that cannot be reached is `NICHT MESSBAR` — it is neither a +pass nor a failure. Without --require-external it does not fail the run, and it never counts as green: +the summary line says what was actually verified. --require-external turns not-measurable into a +failure and belongs in the release checklist, where "we could not look" must block. + +Exit 0 = OK, 1 = violation. stdlib only; checks 1-4 are offline, check 5 needs the network and only +runs when asked. + +Usage: python3 scripts/check_version_and_changelog.py [--repo ] [--external] [--require-external] """ from __future__ import annotations import argparse +import json import re import subprocess +import urllib.error +import urllib.request from pathlib import Path # Commit-subject prefixes that do NOT require a changelog entry (docs/tooling/meta). _TRIVIAL_PREFIX = re.compile(r"^(chore|ci|docs|test|style|build|refactor|merge)\b", re.IGNORECASE) +_SEMVER = r"([0-9]+\.[0-9]+\.[0-9]+)" + +# Check 4 — prose that states the CURRENT version and must therefore track the source. +# Each entry: (path, anchor regex with one capture group, human description of the anchor). +# Only add a place here if it means "this is the current release". Never add a "since"/"as of" +# statement: those are history, and a gate that bumps history manufactures false claims. +_TRACKED_PLACES = [ + ("RELEASE.md", re.compile(r"current:\s*v?" + _SEMVER), "the `(current: X.Y.Z)` note"), + ("docs/readiness_pack/PROGRESS.md", + re.compile(r"current release:\s*v?" + _SEMVER), "the `(current release: X.Y.Z)` note"), +] + +_PYPI_JSON = "https://pypi.org/pypi/proofbundle/json" +_PROJECT_PAGE = "https://b7n0de.com/proofbundle/" +# The page states the published version as `PyPI latest X.Y.Z` (and `PyPI-latest` in the +# German string table). Every occurrence must agree: one translated string left behind is exactly the +# drift this checks for. +_PAGE_VERSION = re.compile(r"PyPI[- ]latest\s*\s*" + _SEMVER + r"\s*", re.IGNORECASE) + +NICHT_MESSBAR = "NICHT MESSBAR" + def _read(p: Path) -> str: return p.read_text(encoding="utf-8") if p.is_file() else "" @@ -100,20 +138,128 @@ def check(repo: Path) -> list[str]: f"{len(nontrivial)} non-trivial commit(s) since tag {last_tag_raw} but the version was not bumped " f"and CHANGELOG.md has no `## [Unreleased]` section — undelivered work with no changelog trace " f"(e.g. {nontrivial[:3]})") + + # 4. Tracked prose places state the current version + if version: + problems.extend(check_tracked_places(repo, version)) return problems +def check_tracked_places(repo: Path, version: str) -> list[str]: + """Every declared "this is the current release" statement must name `version`. + + A missing file or a vanished anchor phrase is a failure, not a pass: if the sentence was + reworded, nobody is checking that place any more and the gate would go quietly blind. + """ + problems: list[str] = [] + for rel, pattern, beschreibung in _TRACKED_PLACES: + path = repo / rel + if not path.is_file(): + problems.append(f"{rel}: tracked version place is missing (expected {beschreibung})") + continue + found = pattern.findall(_read(path)) + if not found: + problems.append( + f"{rel}: {beschreibung} was not found — the anchor moved or was reworded, so this " + f"place is no longer being checked. Fix the file or update _TRACKED_PLACES.") + continue + wrong = sorted({v for v in found if v != version}) + if wrong: + problems.append(f"{rel}: {beschreibung} states {wrong} but the source version is {version}") + return problems + + +def _fetch(url: str, timeout: float) -> str | None: + """Fetch a URL as text. None on ANY failure — unreachable is a state, not an exception.""" + try: + req = urllib.request.Request(url, headers={"User-Agent": "proofbundle-version-gate"}) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (fixed https URLs) + return r.read().decode("utf-8", "replace") + except (urllib.error.URLError, OSError, ValueError, TimeoutError): + return None + + +def check_external(version: str, timeout: float = 15.0) -> list[tuple[str, str, str]]: + """Compare the source version against PyPI and the project page. + + Returns (surface, state, detail) with state in {"OK", "ABWEICHUNG", NICHT_MESSBAR}. + Never raises: a network that is down must not decide a release question by accident. + """ + ergebnisse: list[tuple[str, str, str]] = [] + + roh = _fetch(_PYPI_JSON, timeout) + if roh is None: + ergebnisse.append(("PyPI", NICHT_MESSBAR, f"{_PYPI_JSON} not reachable")) + else: + try: + veroeffentlicht = json.loads(roh)["info"]["version"] + except (ValueError, KeyError, TypeError): + ergebnisse.append(("PyPI", NICHT_MESSBAR, "response was not the expected JSON shape")) + else: + ergebnisse.append(("PyPI", "OK" if veroeffentlicht == version else "ABWEICHUNG", + f"PyPI states {veroeffentlicht}, source states {version}")) + + seite = _fetch(_PROJECT_PAGE, timeout) + if seite is None: + ergebnisse.append(("project page", NICHT_MESSBAR, f"{_PROJECT_PAGE} not reachable")) + else: + genannt = sorted(set(_PAGE_VERSION.findall(seite))) + if not genannt: + # Also NICHT MESSBAR, not a pass: an empty body (a redirect that was not followed, or a + # reworded page) must never read as agreement. + ergebnisse.append(("project page", NICHT_MESSBAR, + "no `PyPI latest X.Y.Z` statement found on the page")) + elif genannt == [version]: + ergebnisse.append(("project page", "OK", f"page states {version}")) + else: + ergebnisse.append(("project page", "ABWEICHUNG", + f"page states {genannt}, source states {version}")) + return ergebnisse + + def main() -> int: ap = argparse.ArgumentParser(description="proofbundle release-integrity gate") ap.add_argument("--repo", default=".", help="repo root (default: cwd)") + ap.add_argument("--external", action="store_true", + help="also compare against PyPI and the project page (needs the network)") + ap.add_argument("--require-external", action="store_true", + help="with --external: treat NICHT MESSBAR as a failure (for the release checklist)") + ap.add_argument("--timeout", type=float, default=15.0, help="per-request timeout for --external") a = ap.parse_args() - problems = check(Path(a.repo).resolve()) + repo = Path(a.repo).resolve() + problems = check(repo) + + aussen: list[tuple[str, str, str]] = [] + if a.external or a.require_external: + version = _pyproject_version(repo) or _init_version(repo) or _citation_version(repo) + if not version: + problems.append("cannot check external surfaces: no source version found") + else: + aussen = check_external(version, a.timeout) + print("external surfaces:") + for name, state, detail in aussen: + print(f" - {name}: {state} ({detail})") + for name, state, detail in aussen: + if state == "ABWEICHUNG": + problems.append(f"{name} disagrees with the source version: {detail}") + elif state == NICHT_MESSBAR and a.require_external: + problems.append(f"{name} is {NICHT_MESSBAR} and --require-external was given: {detail}") + if problems: print("check_version_and_changelog: FAIL") for p in problems: print(f" - {p}") return 1 - print("check_version_and_changelog: OK — version single-sourced, changelog current, no undelivered drift") + + geprueft = "version single-sourced, tracked places current, changelog current, no undelivered drift" + if not aussen: + print(f"check_version_and_changelog: OK — {geprueft}. External surfaces NOT checked.") + elif any(s == NICHT_MESSBAR for _, s, _ in aussen): + offen = ", ".join(n for n, s, _ in aussen if s == NICHT_MESSBAR) + print(f"check_version_and_changelog: OK — {geprueft}. " + f"NOT verified ({NICHT_MESSBAR}): {offen}.") + else: + print(f"check_version_and_changelog: OK — {geprueft}, external surfaces agree.") return 0 diff --git a/tests/test_release_integrity_gate.py b/tests/test_release_integrity_gate.py index fb12d2ae..44fb0ff8 100644 --- a/tests/test_release_integrity_gate.py +++ b/tests/test_release_integrity_gate.py @@ -1,12 +1,18 @@ """Tests for scripts/check_version_and_changelog.py — the release-integrity gate. Bidirectional: a consistent release state passes; each drift class (version disagreement, missing -changelog section, post-tag undelivered work) fails. The post-tag-drift case uses a real throwaway git -repo so the M2-style "merged but never released" bug is caught by a durable test, not just live. +changelog section, post-tag undelivered work, a stale prose place, a vanished anchor, an external +surface that disagrees) fails. The post-tag-drift case uses a real throwaway git repo so the M2-style +"merged but never released" bug is caught by a durable test, not just live. + +The external-surface tests never touch the network: `_fetch` is replaced, so "unreachable" is a state +the test can produce on purpose. That matters, because the interesting case is precisely the one that +cannot be provoked on a healthy machine. """ from __future__ import annotations import importlib.util +import json import subprocess from pathlib import Path @@ -23,6 +29,18 @@ def _write_repo(t: Path, version: str, changelog_headings: list[str]) -> None: (t / "CITATION.cff").write_text(f"cff-version: 1.2.0\nversion: {version}\n", encoding="utf-8") body = "# Changelog\n\n" + "".join(f"## [{h}] - 2026-07-12\n\n- something\n\n" for h in changelog_headings) (t / "CHANGELOG.md").write_text(body, encoding="utf-8") + _write_tracked_places(t, version) + + +def _write_tracked_places(t: Path, version: str) -> None: + """The prose places that state the CURRENT version, in the shape the gate anchors on.""" + (t / "RELEASE.md").write_text( + f"# Release\n\nthe stable default has moved on to the 3.x line (current: {version}) and so on.\n", + encoding="utf-8") + (t / "docs" / "readiness_pack").mkdir(parents=True, exist_ok=True) + (t / "docs" / "readiness_pack" / "PROGRESS.md").write_text( + f"# Progress\n\nThe denominator is the distance from 3.3.0 (current release: {version}) to stable.\n", + encoding="utf-8") def test_consistent_release_passes(tmp_path): @@ -84,5 +102,138 @@ def g(*a): assert chk.check(t) == [] +# -------------------------------------------------------------------------------------------- +# Check 4: the prose places that state the CURRENT version +# -------------------------------------------------------------------------------------------- + +def test_stale_prose_place_fails(tmp_path): + # The source was bumped and RELEASE.md was not followed through — the drift this gate exists for. + _write_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "RELEASE.md").write_text( + "# Release\n\nthe stable default has moved on (current: 3.0.0) and so on.\n", encoding="utf-8") + probs = chk.check(tmp_path) + assert any("RELEASE.md" in p and "3.0.0" in p and "3.0.1" in p for p in probs), probs + + +def test_stale_second_prose_place_fails(tmp_path): + # Two places, and only one of them left behind: the gate must name the one that is wrong. + _write_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "docs" / "readiness_pack" / "PROGRESS.md").write_text( + "# Progress\n\nfrom 3.3.0 (current release: 3.0.0) to stable.\n", encoding="utf-8") + probs = chk.check(tmp_path) + assert any("PROGRESS.md" in p for p in probs), probs + assert not any("RELEASE.md" in p for p in probs), probs + + +def test_vanished_anchor_fails_instead_of_passing_quietly(tmp_path): + # The sentence was reworded, so nothing matches any more. A gate that stops finding its anchor + # must say so — silence here would look exactly like agreement. + _write_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "RELEASE.md").write_text("# Release\n\nno version statement here at all.\n", encoding="utf-8") + probs = chk.check(tmp_path) + assert any("RELEASE.md" in p and "not found" in p for p in probs), probs + + +def test_missing_tracked_file_fails(tmp_path): + _write_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "docs" / "readiness_pack" / "PROGRESS.md").unlink() + probs = chk.check(tmp_path) + assert any("PROGRESS.md" in p and "missing" in p for p in probs), probs + + +def test_citation_disagreement_fails(tmp_path): + # Named explicitly because CITATION.cff is the place a human forgets: it is not code and not prose. + _write_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "CITATION.cff").write_text("cff-version: 1.2.0\nversion: 3.0.0\n", encoding="utf-8") + probs = chk.check(tmp_path) + assert any("disagreement" in p for p in probs), probs + + +def test_historical_statements_are_not_touched(tmp_path): + # "since v3.7.0" records WHEN something became true. Bumping it would manufacture a false claim, + # so a file full of historical mentions must not produce a single finding. + _write_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "INTEGRATIONS.md").write_text( + "sample-count provenance since v3.7.0; corpus 56/56 as of v3.2.0.\n", encoding="utf-8") + assert chk.check(tmp_path) == [] + + +# -------------------------------------------------------------------------------------------- +# Check 5: the external surfaces, with three states +# -------------------------------------------------------------------------------------------- + +def _fake_fetch(mapping): + """Replace chk._fetch with a lookup. None means 'not reachable'.""" + def fetch(url, timeout): # noqa: ARG001 + return mapping.get(url) + return fetch + + +def _page(version): + return f'
Version
PyPI latest {version}, classifier 4
' + + +def test_external_agreement_is_ok(monkeypatch): + monkeypatch.setattr(chk, "_fetch", _fake_fetch({ + chk._PYPI_JSON: json.dumps({"info": {"version": "3.7.0"}}), + chk._PROJECT_PAGE: _page("3.7.0"), + })) + assert [(n, s) for n, s, _ in chk.check_external("3.7.0")] == [("PyPI", "OK"), ("project page", "OK")] + + +def test_external_pypi_disagreement_is_a_finding(monkeypatch): + monkeypatch.setattr(chk, "_fetch", _fake_fetch({ + chk._PYPI_JSON: json.dumps({"info": {"version": "3.6.2"}}), + chk._PROJECT_PAGE: _page("3.7.0"), + })) + zustaende = dict((n, s) for n, s, _ in chk.check_external("3.7.0")) + assert zustaende["PyPI"] == "ABWEICHUNG" + + +def test_external_page_disagreement_is_a_finding(monkeypatch): + monkeypatch.setattr(chk, "_fetch", _fake_fetch({ + chk._PYPI_JSON: json.dumps({"info": {"version": "3.7.0"}}), + chk._PROJECT_PAGE: _page("3.6.2"), + })) + zustaende = dict((n, s) for n, s, _ in chk.check_external("3.7.0")) + assert zustaende["project page"] == "ABWEICHUNG" + + +def test_page_with_two_language_variants_out_of_step_is_a_finding(monkeypatch): + # The realistic failure: the English string table is bumped and the German one is not. + monkeypatch.setattr(chk, "_fetch", _fake_fetch({ + chk._PYPI_JSON: json.dumps({"info": {"version": "3.7.0"}}), + chk._PROJECT_PAGE: _page("3.7.0") + " ... " + 'PyPI-latest 3.6.2', + })) + zustaende = dict((n, s) for n, s, _ in chk.check_external("3.7.0")) + assert zustaende["project page"] == "ABWEICHUNG" + + +def test_unreachable_external_is_not_measurable_and_never_green(monkeypatch): + monkeypatch.setattr(chk, "_fetch", _fake_fetch({})) # nothing reachable + zustaende = [s for _, s, _ in chk.check_external("3.7.0")] + assert zustaende == [chk.NICHT_MESSBAR, chk.NICHT_MESSBAR] + assert "OK" not in zustaende + + +def test_empty_page_body_is_not_measurable_not_agreement(monkeypatch): + # An unfollowed redirect yields an empty body. That must never read as "the page agrees". + monkeypatch.setattr(chk, "_fetch", _fake_fetch({ + chk._PYPI_JSON: json.dumps({"info": {"version": "3.7.0"}}), + chk._PROJECT_PAGE: "", + })) + zustaende = dict((n, s) for n, s, _ in chk.check_external("3.7.0")) + assert zustaende["project page"] == chk.NICHT_MESSBAR + + +def test_malformed_pypi_response_is_not_measurable(monkeypatch): + monkeypatch.setattr(chk, "_fetch", _fake_fetch({ + chk._PYPI_JSON: "not json", + chk._PROJECT_PAGE: _page("3.7.0"), + })) + zustaende = dict((n, s) for n, s, _ in chk.check_external("3.7.0")) + assert zustaende["PyPI"] == chk.NICHT_MESSBAR + + if __name__ == "__main__": raise SystemExit(__import__("pytest").main([__file__, "-q"])) From b87be3dc69d4d0151de996b3fcb769d66610f396 Mon Sep 17 00:00:00 2001 From: kraxo Date: Fri, 7 Aug 2026 20:25:26 +0200 Subject: [PATCH 02/28] docs: add the release gate and the 3.7.1 scope list The audit recommended a stability window in weeks. The rule here is the opposite shape: no calendar, no cadence. A release happens when it can answer a checkable list, so what gets slowed down is vagueness rather than speed. The scope list answers the stash question by measurement instead of assumption. stash@{0} applies cleanly to main and is out precisely because of that: it adds a targetSubjectDigest key to the per-edge result, and that result is signed into a relation statement. New output at a public interface is a MINOR, not a PATCH. --- RELEASE.md | 32 +++++++++++++++++ docs/release_scope/3.7.1.md | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 docs/release_scope/3.7.1.md diff --git a/RELEASE.md b/RELEASE.md index e37fa7b7..7fc8f43c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,6 +5,38 @@ non-negotiable invariant: **the artifact published to PyPI is the exact artifact attested** — the release workflow builds once, attests those bytes, and gates the PyPI upload on a sha256 match. This checklist covers the human steps around that. +## Release gate (answer this before asking for the Owner-GO) + +**No calendar, no cadence, no waiting period.** A release happens when it can answer the questions +below, not because a date arrived. What is slowed down is vagueness, not speed — whoever can answer +this list today releases today. + +Every line is checkable by someone else. "I think so" is not an answer. + +- [ ] **A written scope list exists** for this version: what is in, and what is explicitly out, with + one reason per line. For a PATCH the reason must survive the SemVer question: no semantic + change, no new obligation, no changed behaviour at a public interface. In doubt, leave it out + and say so in the list. Scope lists live in `docs/release_scope/.md`. +- [ ] **`scripts/check_version_and_changelog.py --external --require-external` is green.** Source + (`pyproject.toml`), the two derived files, the tracked prose places, PyPI and the project page + all state the same version. `--require-external` is deliberate here: at release time, + "we could not reach it" must block, because that is exactly when the number matters. +- [ ] **No security-relevant work exists only locally**, in a stash or under `/tmp`. Measure it, do + not remember it: `git stash list` plus `git branch -r --contains ` per entry. + `git rev-list --all --not --remotes` alone is NOT sufficient — it only sees `refs/stash`, i.e. + `stash@{0}`; older stash entries live in that ref's reflog and stay invisible to it. +- [ ] **The CHANGELOG entry says explicitly whether semantics change.** For a PATCH the expected + sentence is that they do not. +- [ ] **An adversarial deep-gate run holds a valid verdict on exactly this digest.** A verdict for an + earlier digest is not a verdict for this one (`scripts/pre_tag_audit_gate.py --strict` blocks + the build without the record). +- [ ] **The external surfaces that must follow are named**, each with who pulls it: at minimum PyPI, + the README badge, the project page (version *and* the "checked on" line), and the description + of any open upstream pull request that states the version. + +The Owner-GO is asked for after this list is answered, not before. The release itself is a one-way +door; the list is what makes it a decision instead of a habit. + ## Release ordering (the tag comes last) The order below is the convention, not a suggestion. A release is a fact about `main` (or a diff --git a/docs/release_scope/3.7.1.md b/docs/release_scope/3.7.1.md new file mode 100644 index 00000000..d5cae7cd --- /dev/null +++ b/docs/release_scope/3.7.1.md @@ -0,0 +1,72 @@ +# Release scope — 3.7.1 (PATCH) + +Written before the release, per the release gate in [RELEASE.md](../../RELEASE.md). It says what is +in, what is explicitly out, and why — one reason per line, each survivable against the SemVer +question a PATCH has to answer: **no semantic change, no new obligation, no changed behaviour at a +public interface.** + +No date and no cadence. 3.7.1 goes out when it can answer the release gate, not when a window opens. + +## In + +| Item | Why it is patch-safe | +|---|---| +| Doc alignment for the harness digest and the absence rule (`docs/upstream/eval-result.md`, `docs/IN_TOTO_PROFILE.md`) | Documentation only. It removes a contradiction with our own upstream submission, where the predicate already carries the optional harness `DigestSet` and the absence rule. No code path changes. | +| The CI work from PR #134 (`pyproject.toml`: pin the ruff *rule set*, cap `<0.17`, `mypy<3`) | Build tooling only, not shipped behaviour. Measured on `main` today: ruff 0.16's expanded default set produces **87 findings** in this tree — that is the drift PR #134 pins away. | +| The version-consistency gate (`scripts/check_version_and_changelog.py` extension + tests) | A check, not a shipped code path. Nothing in `src/` changes. Its first job is to verify its own release. | +| The release gate section in `RELEASE.md` and this scope file | Process documentation. | + +## Out, and why + +| Item | Why not in 3.7.1 | +|---|---| +| `ResourceDescriptor` for harness/suite, the profile matrix, the exact score as its own profile | All three touch field semantics. That is at least a MINOR, and each deserves its own upstream discussion after in-toto PR 575 lands. | +| A second independent validator implementation | New surface, not a fix. | +| The conformance corpus as its own repository | Repository-level change with its own release and licence questions. | +| K2 and K3 acceptance criteria | Architecture work with executable criteria still to be written; nothing to ship yet. | +| `stash@{0}` — `targetSubjectDigest` in the per-edge result entry | **Measured, not assumed. It is out.** See below. | +| `stash@{1}` — `if False:` in `evalclaim.py` | A planted defect from an adversarial probe. It disables the check that `samples.n` matches the claim's n. Never ships, and deliberately has no safety branch: a branch whose only content is the removal of a check is worse than no branch. | + +## The stash question, answered by measurement + +The audit asked it because one stash touches `src/proofbundle/relation.py`, and the honest answer +decides whether it may ride along in a PATCH. Measured on 2026-08-07 against `main` at `cf39e9f`: + +**`stash@{0}` applies cleanly (`git apply --check`, rc 0) — and that is exactly what makes it a +problem.** It adds a `targetSubjectDigest` key to the entry dict built in +`verify_relationship_edges`. `main` already validates and cross-checks `targetSubjectDigest` +elsewhere (declaration validation, resolution against the attached target, the fail-closed codes from +`PB-2026-0717-01`), but it does **not** surface the field in the per-edge result. So this is not a +fix for something broken; it is **new output**. + +And that output is not internal: `relation_statement.py:260` puts the result of +`verify_relationship_edges` into `r["lineage"]` of a relation statement — a structure that gets +signed. A new key changes emitted bytes. **New behaviour at a public interface is a MINOR, not a +PATCH.** It stays out of 3.7.1, preserved on +`safety/stash-relation-targetsubjectdigest-20260807`, and belongs in the same discussion as the rest +of the relation/v0.1 surface. + +**`stash@{2}` is test-only and does apply.** It adds `test_no_secret_value_ever_reaches_output` to +`tests/test_fork_pr_secret_isolation.py`: a reachability proof that the fork-PR guard reports secret +*names* (public YAML identifiers) but can never emit a secret *value* — the triage for a CodeQL +clear-text-logging finding. Measured: applies to `main`, suite goes 34 → 35 tests, all green. No +`src/` change, no semantics. **It is patch-safe and may ride along**; it is not required for the +release. Preserved on `safety/stash-forkpr-secret-value-test-20260807`. + +## External surfaces to pull after the release + +| Surface | What has to follow | Who | +|---|---|---| +| PyPI | follows automatically from the release workflow (build once, attest, digest-gated upload) | release workflow | +| README badge | live shields.io badges — nothing to edit. Measured: README hard-codes **no** version string, so it cannot go stale | nobody, by design | +| `b7n0de.com/proofbundle` | the version number **and** the "checked on" line; both string tables (EN and DE) — the gate compares every `PyPI latest X.Y.Z` occurrence, so one translated string left behind is a finding | Owner (the page is an outward surface and needs its own GO) | +| Description of in-toto PR #575 | stated 3.6.2; corrected to 3.7.0 on 2026-08-07 under `QITEM-PB-AUDIT-P0-01`. Must be pulled again on every release while the PR is open | whoever releases | +| `CITATION.cff`, `RELEASE.md`, `docs/readiness_pack/PROGRESS.md` | in-repo, enforced by the gate — listed here so the list is complete, not because they need a human | the gate | + +## What the gate does not touch, deliberately + +Historical statements stay as they are: `since v3.7.0` in `INTEGRATIONS.md`, `as of v3.7.0` in +`CROSS_IMPLEMENTATION_REPORT.md` and the two `docs/readiness_pack/` files, every older `CHANGELOG.md` +heading, and everything under `audit_artifacts/370/`. They record *when* something became true. +Bumping them would turn a fact into a false claim, which is a worse failure than the drift the gate +exists to catch. From c5d420d0329afa06294c61168b0f3a64cd26aa73 Mon Sep 17 00:00:00 2001 From: kraxo Date: Fri, 7 Aug 2026 20:39:15 +0200 Subject: [PATCH 03/28] docs: correct the ruff measurement in the 3.7.1 scope list The line claimed 87 findings on this tree from ruff 0.16's expanded default set. Both halves were wrong, and measuring properly showed why: the local ruff is 0.15.10, and all 87 findings sit in an untracked scratchpad/ directory that 'ruff check .' happens to walk. Over the 258 tracked .py files the tree is clean. I had read the output of a command as an answer about the repository, when it was an answer about the working directory. The real numbers were already in pyproject.toml, measured by the change itself. --- docs/release_scope/3.7.1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release_scope/3.7.1.md b/docs/release_scope/3.7.1.md index d5cae7cd..b9f208d8 100644 --- a/docs/release_scope/3.7.1.md +++ b/docs/release_scope/3.7.1.md @@ -12,7 +12,7 @@ No date and no cadence. 3.7.1 goes out when it can answer the release gate, not | Item | Why it is patch-safe | |---|---| | Doc alignment for the harness digest and the absence rule (`docs/upstream/eval-result.md`, `docs/IN_TOTO_PROFILE.md`) | Documentation only. It removes a contradiction with our own upstream submission, where the predicate already carries the optional harness `DigestSet` and the absence rule. No code path changes. | -| The CI work from PR #134 (`pyproject.toml`: pin the ruff *rule set*, cap `<0.17`, `mypy<3`) | Build tooling only, not shipped behaviour. Measured on `main` today: ruff 0.16's expanded default set produces **87 findings** in this tree — that is the drift PR #134 pins away. | +| The CI work from PR #134 (`pyproject.toml`: pin the ruff *rule set*, cap `<0.17`, `mypy<3`) | Build tooling only, not shipped behaviour. Merged 2026-08-07 as `6a3011f`, 22/22 checks green. The measurement that justifies it is recorded in `pyproject.toml` itself: on the identical tree, ruff 0.15.x applies 59 default rules and exits 0 over all 258 tracked `.py`, ruff 0.16.x applies 413 and reports 1168 findings. | | The version-consistency gate (`scripts/check_version_and_changelog.py` extension + tests) | A check, not a shipped code path. Nothing in `src/` changes. Its first job is to verify its own release. | | The release gate section in `RELEASE.md` and this scope file | Process documentation. | From 871453cc0b8e4ed635e203c7f33fed3aafe9dec2 Mon Sep 17 00:00:00 2001 From: kraxo Date: Fri, 7 Aug 2026 20:54:53 +0200 Subject: [PATCH 04/28] docs: align the in-toto profile docs with what was actually submitted Cut-list item 1 for 3.7.1. Measured drift was larger than the list assumed: not only the harness digest and the absence rule were missing, but also today's three corrections. Both docs still carried anchors[] as a predicate field, which PR 575 never had. docs/upstream/eval-result.md is now a mirror of the submitted file and says so: when the two differ, the PR is the source of truth and this copy is the one that is wrong. It also stopped claiming the PR is unopened. IN_TOTO_PROFILE.md drops the anchors row and says plainly that earlier revisions were wrong there, rather than quietly deleting it. Documentation only, no src/ change. --- docs/IN_TOTO_PROFILE.md | 27 +++++++--- docs/upstream/eval-result.md | 100 ++++++++++++++++++++--------------- 2 files changed, 78 insertions(+), 49 deletions(-) diff --git a/docs/IN_TOTO_PROFILE.md b/docs/IN_TOTO_PROFILE.md index 442b61ac..3bc5b8a2 100644 --- a/docs/IN_TOTO_PROFILE.md +++ b/docs/IN_TOTO_PROFILE.md @@ -1,9 +1,14 @@ # in-toto profile: the `eval-result` predicate and the SVR export -Status: **PROPOSED**, under discussion at [in-toto/attestation#565](https://github.com/in-toto/attestation/issues/565). +Status: **PROPOSED**. Discussed at [in-toto/attestation#565](https://github.com/in-toto/attestation/issues/565) +and submitted as [PR #575](https://github.com/in-toto/attestation/pull/575), which is **open, not merged**. Not standardized. The `predicateType` lives in a vendor namespace until (and unless) it is registered upstream. Nothing here changes the native receipt or what it proves — see [NON_CLAIMS.md](NON_CLAIMS.md). +The field table below mirrors the submitted spec. When the two differ, the PR is the source of truth +and this page is the one that is wrong; a byte-for-byte copy of the submitted file lives in +[docs/upstream/eval-result.md](upstream/eval-result.md). + This page answers, for a first-time reader, three questions in a few minutes: 1. **What is the predicate?** A privacy-preserving in-toto Statement for an ML eval result. @@ -60,19 +65,24 @@ never a bare `timestamp`): | `verifier.id` | the emitter/verifier TypeURI | | `evaluatedAt` | when the eval ran (from the signed receipt) | | `suite` | `{name, version}` | -| `claims[]` | `{metric, comparator, threshold, passed}` — the threshold-based pass | +| `claims[]` | `{metric, comparator, threshold, passed}`. `passed` is the producer's **signed threshold verdict**, not a recomputable relation: proofbundle discards the exact score after the comparison, so without a disclosed value a generic consumer can authenticate the verdict but cannot recompute it | | `sampleSize` | `n` | | `commitments` | `{model, dataset}`, each `{alg, value, salted:true}` — a **salted commitment**, NOT an artifact hash | -| `assuranceLevel` | `self_attested` \| `third_party` \| `reproduced` \| `enclave_attested` | +| `assuranceLevel` | an **issuer-declared** assurance claim: `self_attested` \| `third_party` \| `reproduced` \| `enclave_attested`. The predicate does not corroborate it; external corroboration belongs in separately referenced evidence | | `subjectProfile` | which subject profile produced the `subject` (below) | | `preRegistration` | optional `{alg, value}` — present only if the receipt carries a prereg hash | | `receipt` | optional `{schema, merkleRootB64}` — binds to the external signed receipt | -| `harness` | optional `{name, version}` | -| `anchors` | optional external time anchors (RFC 3161 TSA / OpenTimestamps); experimental, a separate `[anchors]` extra that is proposed and not yet shipped | +| `harness` | optional `{name, version}` plus an optional `digest` ([DigestSet](https://github.com/in-toto/attestation/blob/main/spec/v1/digest_set.md)) for consumers that need to bind the exact artifact that produced the result. A `harness` carrying only `name` and `version` stays conforming. The digest binds **identity only** and asserts nothing about the harness's detection performance | + +`anchors` is **not** a field of this predicate. External time anchors were drafted in #565 and +deliberately scoped out of #575: they are not eval-specific and belong as a shared optional field in +their own discussion. Earlier revisions of this page listed them; that was wrong and is corrected here. **Parsing rules** follow in-toto Statement v1: matching is on the subject `digest` alone; unknown predicate fields are ignored by consumers; and the [Monotonic Principle](https://github.com/in-toto/attestation/blob/main/docs/validation.md) -applies — a verifier denies unless a valid attestation exists. +applies — a verifier denies unless a valid attestation exists. **Absence rule:** unless a field says +otherwise, the absence of an optional field means only that no claim is made for it — a consumer MUST +NOT infer or synthesize a default from absence. ## Subject profiles — what the `subject` IS @@ -108,6 +118,11 @@ Before you trust an `eval-result` attestation for a decision, answer: Everything in [NON_CLAIMS.md](NON_CLAIMS.md) applies unchanged. In short: authenticity and integrity of a claim, never its semantic truth, fairness, safety, or generalization. +Added to the submitted spec on 2026-08-07 and repeated here because it is easy to assume otherwise: +the predicate does **not** establish that the evaluation harness or grader is fit for purpose, or +that it has any particular detection performance. Binding a harness digest pins *which* artifact ran, +not *how well* it detects. + ## Migration path (vendor namespace → in-toto.io) Using a vendor `predicateType` for a v0.x predicate is common practice (cf. `cosign.sigstore.dev/…`, diff --git a/docs/upstream/eval-result.md b/docs/upstream/eval-result.md index 1bb51b01..099804e7 100644 --- a/docs/upstream/eval-result.md +++ b/docs/upstream/eval-result.md @@ -1,15 +1,16 @@ # Predicate type: ML eval-result - + Type URI: https://in-toto.io/attestation/eval-result/v0.1 Version: v0.1 -Authors: Konrad Gruszka (ORCID 0009-0006-8947-6065) +Authors: Konrad Gruszka (@b7n0de, ORCID 0009-0006-8947-6065) ## Purpose @@ -19,18 +20,18 @@ while keeping the evaluated model and dataset **private**. An ML eval has three against it, the need to withhold the model/dataset identity, and an optional binding to an external signed receipt (and, later, an external time anchor for pre-registration). -This predicate authenticates a *claim* — *who signed these exact eval bytes, and that nothing changed +This predicate authenticates a *claim*: *who signed these exact eval bytes, and that nothing changed since*. It does **not** assert the semantic truth, fairness, safety, or generalization of the result; those remain human judgements (see [Non-claims](#non-claims)). -## Use cases +## Use Cases -- **Private-model eval**: publish "model M passed safety suite S at `refusal_rate >= 0.98`" without - revealing M or the dataset, via salted commitments; a relying party verifies the signed claim offline. -- **Release gating**: bind a release artifact (image/wheel/service digest) to a passing eval — "deploy - only if the eval passed" — as the ML attach point for a policy/SLSA decision. -- **Pre-registration**: commit to the threshold and the dataset/model *before* the run, and later prove - the commitment predated the result (strengthened by an external time anchor). +- **Private-model eval**: publish "model M passed safety suite S at `refusal_rate >= 0.98`" without + revealing M or the dataset, via salted commitments; a relying party verifies the signed claim offline. +- **Release gating**: bind a release artifact (image/wheel/service digest) to a passing eval, "deploy + only if the eval passed", as the ML attach point for a policy/SLSA decision. +- **Pre-registration**: commit to the threshold and the dataset/model *before* the run, and later prove + the commitment predated the result (strengthened by an external time anchor). ## Prerequisites @@ -44,7 +45,7 @@ issuer and is never in the attestation. An evaluation run produces a signed, tamper-evident receipt. This predicate is a projection of that receipt onto an in-toto Statement: the `subject` is what the attestation is *about* (the receipt itself, a public model artifact, or a gated release artifact), and the predicate carries the eval's facts. The -detailed per-metric result lives here; a companion [SVR](https://github.com/in-toto/attestation/pull/470) +detailed per-metric result lives here; a companion [SVR](svr.md) may summarize "a verifier confirmed this passed" as passing property strings. ## Schema @@ -70,55 +71,62 @@ may summarize "a verifier confirmed this passed" as passing property strings. "subjectProfile": "receipt|public-model|release-gate", "preRegistration": { "alg": "sha256", "value": "" }, // OPTIONAL "receipt": { "schema": "", "merkleRootB64": "" }, // OPTIONAL - "harness": { "name": "", "version": "" }, // OPTIONAL - "anchors": [ /* external time anchors — OPTIONAL, see the anchors extension */ ] + "harness": { "name": "", "version": "", "digest": { "sha256": "" } } // OPTIONAL } } ``` -## Parsing rules +### Parsing Rules This predicate follows the in-toto attestation [spec v1 parsing rules](../v1/README.md#parsing-rules): consumers **match on the subject `digest` alone**; `subject[].name` is a hint and MAY be `"_"` or omitted; unknown predicate fields MUST be ignored (forward compatibility); and the -[Monotonic Principle](../../docs/validation.md) applies — a verifier denies unless a valid attestation +[Monotonic Principle](../../docs/validation.md) applies: a verifier denies unless a valid attestation exists. Time fields are RFC 3339. `threshold` is a decimal **string**, never a JSON float, so a value is never altered by float round-tripping. -## Fields +Unless a field specifies otherwise, absence of an optional field means only that no claim is made +for that field. Consumers MUST NOT infer or synthesize a default value from absence. -`verifier.id` _(TypeURI, required)_: the party that emitted/verified the result. +### Fields -`evaluatedAt` _(Timestamp, required)_: when the evaluation ran. +`verifier.id` *(TypeURI, required)*: the party that emitted/verified the result. -`suite` _(object, required)_: `{name, version}` of the eval suite. +`evaluatedAt` *(Timestamp, required)*: when the evaluation ran. -`claims` _(array, required)_: one or more `{metric, comparator, threshold, passed}`. `comparator` is one -of `>=`, `>`, `<=`, `<`; `passed` is the pass of `metric comparator threshold`. +`suite` *(object, required)*: `{name, version}` of the eval suite. -`sampleSize` _(int, required)_: number of samples the result is over. +`claims` *(array, required)*: one or more `{metric, comparator, threshold, passed}`. `comparator` is one +of `>=`, `>`, `<=`, `<`. `passed` is the producer's signed threshold verdict for the stated metric, +comparator and threshold. Unless an exact observed value is disclosed by a separate profile, a generic +consumer can authenticate the verdict but cannot recompute it from the predicate alone. -`commitments` _(object, required)_: `model` and `dataset`, each `{alg, value, salted}`. When `salted` is +`sampleSize` *(int, required)*: number of samples the result is over. + +`commitments` *(object, required)*: `model` and `dataset`, each `{alg, value, salted}`. When `salted` is `true` the `value` is a commitment (a hash over a secret salt ‖ identifier), **NOT** an artifact content -digest — a generic verifier MUST NOT treat it as one. This is what lets the evaluated model/dataset stay +digest; a generic verifier MUST NOT treat it as one. This is what lets the evaluated model/dataset stay private while the claim is still verifiable. -`assuranceLevel` _(string, required)_: how much a pass is worth — `self_attested` (producer testimony), -`third_party`, `reproduced`, or `enclave_attested`. +`assuranceLevel` *(string, required)*: an issuer-declared assurance claim about how the result was +produced: `self_attested` (producer testimony), `third_party`, `reproduced`, or `enclave_attested`. The +value is the issuer's own declaration; this predicate does not corroborate it. External corroboration +belongs in separately referenced evidence. -`subjectProfile` _(string, required)_: which subject the attestation binds to — `receipt` (a binder over +`subjectProfile` *(string, required)*: which subject the attestation binds to: `receipt` (a binder over the receipt; reveals nothing), `public-model` (a disclosed model's real digest), or `release-gate` (a release artifact gated on the pass). -`preRegistration` _(object, optional)_: `{alg, value}` over the eval protocol committed before the run. - -`receipt` _(object, optional)_: `{schema, merkleRootB64}` binding to the external signed receipt. +`preRegistration` *(object, optional)*: `{alg, value}` over the eval protocol committed before the run. -`harness` _(object, optional)_: `{name, version}` of the eval harness. +`receipt` *(object, optional)*: `{schema, merkleRootB64}` binding to the external signed receipt. -`anchors` _(array, optional)_: external time anchors (e.g. RFC 3161 TSA, OpenTimestamps) for the -receipt or the pre-registration. Defined by a separate anchors extension. +`harness` *(object, optional)*: the eval harness. `name` and `version` identify it. `digest` is an +optional [DigestSet](../v1/digest_set.md) over the harness artifact, for consumers that need to bind +the exact artifact that produced the result. A `harness` that carries only `name` and `version` +remains conforming. `digest` binds identity only. It asserts nothing about the harness's detection +performance. ## Non-claims @@ -126,6 +134,9 @@ A verifier that accepts this attestation learns that the signed claim is authent does **not** learn that the metric is correct, that the eval was well designed, that the model is safe or fair, or that the score generalizes. Those are out of scope for this predicate. +This predicate does not establish that the evaluation harness or grader is fit for purpose, or +that it has any particular detection performance. + ## Examples A private-model eval (subject is the receipt; the model stays secret): @@ -155,11 +166,14 @@ A private-model eval (subject is the receipt; the model stays secret): A release-gate example (subject is the deployed artifact's real digest) is in the reference implementation's `examples/intoto/release-gate.statement.json`. -## Changelog +## Changelog and Migrations + +- v0.1: initial draft. Reference emitter/verifier: [proofbundle](https://github.com/b7n0de/proofbundle) + (`proofbundle intoto`). Discussion: in-toto/attestation#565. + +## proofbundle-specific note (not part of the upstream file) -- v0.1 — initial draft. Reference emitter/verifier: [proofbundle](https://github.com/b7n0de/proofbundle) - (`proofbundle intoto`). Discussion: in-toto/attestation#565. Until this type is registered upstream, - the reference implementation emits the vendor-namespaced `predicateType` - `https://b7n0de.com/attestation/eval-result/v0.1` and migrates to the `in-toto.io` URI on - registration (a redirect/alias is added at that point). Consumers match on the subject digest, so a - `predicateType` rename does not affect binding. +Until this type is registered upstream, the reference implementation emits the vendor-namespaced +`predicateType` `https://b7n0de.com/attestation/eval-result/v0.1` and migrates to the `in-toto.io` +URI on registration (a redirect/alias is added at that point). Consumers match on the subject digest, +so a `predicateType` rename does not affect binding. From b48e52b2ac941bc748df66be17d83b99e23ec7c0 Mon Sep 17 00:00:00 2001 From: kraxo Date: Fri, 7 Aug 2026 20:58:29 +0200 Subject: [PATCH 05/28] fix: anchor the post-tag drift check on the last RELEASE tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on 2026-08-07: git describe --tags returned corpus-review-2026-07-25-iter10, _semver_tuple read it as (0, 0, 0), every real version compared as bumped past it, and check 3 stopped applying. Under that blind spot one non-trivial commit sat undelivered since v3.7.0 with no [Unreleased] section, and the gate reported OK. The check did not fail. It stopped checking, and silence looked exactly like agreement — the same shape as the vanished-anchor case in check 4. Three states: a release tag, no tags at all, or tags that exist but none is a release. The third is reported in its own words. --- scripts/check_version_and_changelog.py | 33 +++++++++++++++-- tests/test_release_integrity_gate.py | 49 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/scripts/check_version_and_changelog.py b/scripts/check_version_and_changelog.py index 9147d00a..813c4cde 100644 --- a/scripts/check_version_and_changelog.py +++ b/scripts/check_version_and_changelog.py @@ -97,6 +97,33 @@ def _git(repo: Path, *args: str) -> tuple[int, str]: return 1, "" +_RELEASE_TAG_GLOB = "v[0-9]*" +_RELEASE_TAG_RE = re.compile(r"^v?[0-9]+\.[0-9]+\.[0-9]+") + + +def _last_release_tag(repo: Path) -> tuple[str | None, str]: + """The most recent RELEASE tag reachable from HEAD, plus a reason when there is none. + + WHY THIS IS NOT `git describe --tags`: measured in this repo on 2026-08-07, that returned + `corpus-review-2026-07-25-iter10` — a review tag. `_semver_tuple` reads it as (0, 0, 0), so any + real version compares as "bumped past it", and check 3 passed while a non-trivial commit sat + undelivered with no `## [Unreleased]` section. The check did not fail; it stopped applying, and + silence looked exactly like agreement. A gate anchored on "the latest tag" is anchored on + whatever anyone tagged last. + + Three states, not two: a release tag, no tags at all, or tags that exist but none of them is a + release. The third is reported in its own words instead of being folded into the second. + """ + rc, raw = _git(repo, "describe", "--tags", "--abbrev=0", "--match", _RELEASE_TAG_GLOB) + if rc == 0 and raw and _RELEASE_TAG_RE.match(raw): + return raw, "" + rc_any, any_tag = _git(repo, "describe", "--tags", "--abbrev=0") + if rc_any != 0 or not any_tag: + return None, "no git tags available" + return None, (f"tags exist but none is a release tag reachable from HEAD " + f"(latest reachable tag: {any_tag})") + + def _semver_tuple(v: str) -> tuple: core = v.split("-")[0].split("+")[0] parts = core.split(".") @@ -124,9 +151,9 @@ def check(repo: Path) -> list[str]: f"(headings seen: {headings[:5]})") # 3. Post-tag drift (M2 catcher), git-gated - rc, last_tag_raw = _git(repo, "describe", "--tags", "--abbrev=0") - if rc != 0 or not last_tag_raw: - print("check_version_and_changelog: NOTE post-tag-drift check skipped (no git tags available)") + last_tag_raw, tag_note = _last_release_tag(repo) + if not last_tag_raw: + print(f"check_version_and_changelog: NOTE post-tag-drift check skipped ({tag_note})") else: last_tag = last_tag_raw.lstrip("v") rc2, log = _git(repo, "log", "--format=%s", f"{last_tag_raw}..HEAD") diff --git a/tests/test_release_integrity_gate.py b/tests/test_release_integrity_gate.py index 44fb0ff8..aad64615 100644 --- a/tests/test_release_integrity_gate.py +++ b/tests/test_release_integrity_gate.py @@ -102,6 +102,55 @@ def g(*a): assert chk.check(t) == [] +def test_review_tag_does_not_disable_the_drift_check(tmp_path): + """A non-release tag must not switch check 3 off. + + Measured in the real repo on 2026-08-07: `git describe --tags` returned a corpus review tag, + `_semver_tuple` read it as (0, 0, 0), every real version compared as "bumped past it", and a + non-trivial commit sat undelivered with no [Unreleased] section while the gate reported OK. + """ + t = tmp_path + + def g(*a): + return subprocess.run(["git", "-C", str(t), *a], capture_output=True, text=True) + + g("init", "-q", "-b", "main") + g("config", "user.email", "t@t") + g("config", "user.name", "t") + _write_repo(t, "3.0.0", ["3.0.0"]) + g("add", "-A") + g("commit", "-qm", "release: 3.0.0") + g("tag", "v3.0.0") + (t / "src" / "proofbundle" / "adapters.py").write_text("# security fix\n", encoding="utf-8") + g("add", "-A") + g("commit", "-qm", "security(M2): strip evaluation_result_id from the EEE digest") + g("tag", "corpus-review-2026-07-25-iter10") # the tag that used to blind the check + + assert chk._last_release_tag(t)[0] == "v3.0.0" + probs = chk.check(t) + assert any("non-trivial" in p and "v3.0.0" in p for p in probs), probs + + +def test_no_release_tag_at_all_is_reported_separately(tmp_path): + # Tags exist, none of them a release: that is its own state, not "no tags". + t = tmp_path + + def g(*a): + return subprocess.run(["git", "-C", str(t), *a], capture_output=True, text=True) + + g("init", "-q", "-b", "main") + g("config", "user.email", "t@t") + g("config", "user.name", "t") + _write_repo(t, "3.0.0", ["3.0.0"]) + g("add", "-A") + g("commit", "-qm", "release: 3.0.0") + g("tag", "corpus-review-2026-07-25-iter10") + + tag, grund = chk._last_release_tag(t) + assert tag is None + assert "none is a release tag" in grund, grund + + # -------------------------------------------------------------------------------------------- # Check 4: the prose places that state the CURRENT version # -------------------------------------------------------------------------------------------- From 03bcc03e71460dd6f173e144c5459643d4e0e1fb Mon Sep 17 00:00:00 2001 From: kraxo Date: Fri, 7 Aug 2026 20:59:29 +0200 Subject: [PATCH 06/28] docs: add the Unreleased section the drift check was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corrected check 3 immediately found a real gap: one non-trivial commit had been sitting since v3.7.0 with no changelog trace. This is that trace, and it states plainly what the release gate asks for — semantics unchanged. --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58019e06..e2c476c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,48 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). _Editorial 2026-07-20: internal gate codename replaced by its external name throughout; content unchanged._ +## [Unreleased] + +**Semantics: unchanged.** Everything below is CI, checks and documentation. Nothing under `src/` +changes, no public interface gains or loses a field, and no consumer has to do anything differently. +This is the explicit statement the release gate in [RELEASE.md](RELEASE.md) asks for; the planned +scope for the next patch is written down in [docs/release_scope/3.7.1.md](docs/release_scope/3.7.1.md). + +### Fixed + +- The post-tag drift check anchored on `git describe --tags`, which returns *whatever was tagged + last*. Measured on 2026-08-07 it returned a corpus review tag; `_semver_tuple` reads that as + `(0, 0, 0)`, so any real version compares as "bumped past it" and the check stopped applying. It + did not fail — it went silent, and silence looked like agreement. Under that blind spot one + non-trivial commit sat undelivered since `v3.7.0` with no `## [Unreleased]` section (this one). + The check now anchors on the last **release** tag and distinguishes three states: a release tag, + no tags at all, or tags that exist but none of them is a release. +- `pyproject.toml` pins the ruff **rule set**, not just its version, and raises the cap to `<0.17` + (#134). Measured on the identical tree: ruff 0.15.x applies 59 default rules and exits 0 over all + 258 tracked `.py` files, ruff 0.16.x applies 413 and reports 1168 findings. The cap alone would + have silently stopped checking the 18 rules 0.16 removed. `mypy` is bounded at `<3` for the same + reason, deliberately and without a measured failure. + +### Added + +- `scripts/check_version_and_changelog.py` also compares the two prose places that state the current + version (`RELEASE.md`, `docs/readiness_pack/PROGRESS.md`), and optionally PyPI and the project page + (`--external`). External surfaces have three states: agreement, disagreement, and NICHT MESSBAR — + unreachable never counts as green, and `--require-external` turns it into a failure for the release + checklist. Historical statements (`since vX`, `as of vX`, old changelog headings) are deliberately + out of scope: bumping them would turn a fact into a false claim. +- A release gate in `RELEASE.md`: a checkable list a release answers *before* the Owner-GO is asked + for. No date, no cadence — what is slowed down is vagueness, not speed. + +### Changed + +- `docs/IN_TOTO_PROFILE.md` and `docs/upstream/eval-result.md` now say what was actually submitted as + in-toto/attestation#575. Both still listed `anchors[]` as a predicate field, which that PR never + had, and neither carried the absence rule, the optional harness `DigestSet`, the non-claim on + harness/grader fitness, `passed` as a **signed threshold verdict**, or `assuranceLevel` as + **issuer-declared**. The upstream copy now states that the PR is the source of truth when the two + differ. + ## [3.7.0] - 2026-07-23 (adapter sample-count provenance, BETA, relation EXPERIMENTAL) Status boundary (No-Overclaim): 3.7.0 remains audit-candidate BETA, relation/v0.1 EXPERIMENTAL. This is a From 2c5e7a5a981634233eedab1dc4ba39f5ba91a4bb Mon Sep 17 00:00:00 2001 From: kraxo Date: Fri, 7 Aug 2026 21:06:52 +0200 Subject: [PATCH 07/28] test: pin the invariants both in-toto docs must keep saying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligning the two docs today fixed the instance and left the class open: nothing checked that they stay aligned, which is how they drifted apart in the first place. Both had listed anchors as a predicate field, neither carried the absence rule, and the mirror still claimed the PR was unopened. Deliberately not a byte comparison against the upstream file — that lives in another repository, so the test would pass or fail depending on what happens to be checked out next to this one. These are the sentences the submission makes, each one droppable by a future edit without anyone noticing. --- tests/test_intoto_spec_diff.py | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_intoto_spec_diff.py b/tests/test_intoto_spec_diff.py index da2e1f90..1435961a 100644 --- a/tests/test_intoto_spec_diff.py +++ b/tests/test_intoto_spec_diff.py @@ -34,5 +34,68 @@ def test_upstream_draft_uses_the_intoto_namespace_and_notes_the_vendor_alias(sel self.assertIn(EVAL_RESULT_PREDICATE_TYPE, draft) # the vendor alias is disclosed, not hidden +class TestSubmittedPredicateInvariants(unittest.TestCase): + """Both docs must keep saying what in-toto/attestation#575 actually says. + + WHY THIS EXISTS. On 2026-08-07 an external audit found that the PR description scoped `anchors[]` + out while the spec text still carried it. Aligning our two copies fixed that instance — and left + the class wide open, because nothing checked that they stay aligned. They had drifted apart + silently once already: both still listed `anchors` as a predicate field, neither carried the + absence rule, and one claimed the PR was not yet opened. + + Deliberately NOT a byte-for-byte comparison against the upstream file: it lives in a different + repository, so a test that reads it would pass or fail depending on what happens to be checked + out next to this one. These are the invariants instead — each one a sentence the submission + makes, each one a thing a future edit could quietly drop. + """ + + def _docs(self): + return { + "docs/IN_TOTO_PROFILE.md": (ROOT / "docs" / "IN_TOTO_PROFILE.md").read_text(encoding="utf-8"), + "docs/upstream/eval-result.md": (ROOT / "docs" / "upstream" / "eval-result.md").read_text(encoding="utf-8"), + } + + def test_anchors_is_not_listed_as_a_predicate_field(self): + # Prose about time anchors is fine — the concept is real and comes later. What must not come + # back is `anchors` AS A FIELD: in the schema block, in the field list, or as a table row. + feld_formen = ('"anchors"', "`anchors` _(array", "`anchors` *(array", "| `anchors` |") + for pfad, text in self._docs().items(): + for form in feld_formen: + self.assertNotIn(form, text, f"{pfad} lists anchors as a predicate field ({form!r}); " + f"#575 deliberately scopes it out") + + def test_both_docs_carry_the_absence_rule(self): + for pfad, text in self._docs().items(): + self.assertIn("absence of an optional field", text, + f"{pfad} lost the absence rule (absence means no claim, never a default)") + + def test_both_docs_state_passed_as_a_signed_threshold_verdict(self): + for pfad, text in self._docs().items(): + self.assertIn("signed threshold verdict", text, + f"{pfad} no longer says that `passed` is a signed threshold verdict — " + f"without a disclosed value it is not recomputable") + + def test_both_docs_call_assurancelevel_issuer_declared(self): + for pfad, text in self._docs().items(): + self.assertTrue("issuer-declared" in text or "issuer declared" in text, + f"{pfad} no longer marks assuranceLevel as issuer-declared") + + def test_both_docs_carry_the_harness_digest_and_its_non_claim(self): + for pfad, text in self._docs().items(): + self.assertIn("DigestSet", text, f"{pfad} lost the optional harness DigestSet") + self.assertIn("detection performance", text, + f"{pfad} lost the non-claim that a harness digest says nothing about " + f"detection performance") + + def test_the_mirror_names_the_pr_as_the_source_of_truth(self): + # The copy must not drift into looking authoritative. It also must not keep claiming the PR + # is unopened, which is how it read until 2026-08-07. + draft = (ROOT / "docs" / "upstream" / "eval-result.md").read_text(encoding="utf-8") + self.assertIn("575", draft, "the mirror does not name the PR it mirrors") + self.assertIn("source of truth", draft, + "the mirror does not say which side wins when the two differ") + self.assertNotIn("NOT yet opened as", draft, "the mirror still claims the PR is unopened") + + if __name__ == "__main__": unittest.main() From 3267104ea66094ddf70d053a4afc6339637a0b9c Mon Sep 17 00:00:00 2001 From: kraxo Date: Fri, 7 Aug 2026 21:21:39 +0200 Subject: [PATCH 08/28] docs: the scope list was missing two of the things that ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured at 19:19: the list named four items while the branch carried seven commits. The two it omitted were the two that were found rather than planned — the post-tag drift fix and the invariant tests. A scope list that does not contain what actually ships is the thing it was built against, so the omission is recorded in the list rather than quietly filled in. --- docs/release_scope/3.7.1.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/release_scope/3.7.1.md b/docs/release_scope/3.7.1.md index b9f208d8..a394a793 100644 --- a/docs/release_scope/3.7.1.md +++ b/docs/release_scope/3.7.1.md @@ -15,6 +15,13 @@ No date and no cadence. 3.7.1 goes out when it can answer the release gate, not | The CI work from PR #134 (`pyproject.toml`: pin the ruff *rule set*, cap `<0.17`, `mypy<3`) | Build tooling only, not shipped behaviour. Merged 2026-08-07 as `6a3011f`, 22/22 checks green. The measurement that justifies it is recorded in `pyproject.toml` itself: on the identical tree, ruff 0.15.x applies 59 default rules and exits 0 over all 258 tracked `.py`, ruff 0.16.x applies 413 and reports 1168 findings. | | The version-consistency gate (`scripts/check_version_and_changelog.py` extension + tests) | A check, not a shipped code path. Nothing in `src/` changes. Its first job is to verify its own release. | | The release gate section in `RELEASE.md` and this scope file | Process documentation. | +| The post-tag drift fix (`b48e52b`): anchor check 3 on the last **release** tag | A check, not a shipped code path. It was not planned — it fell out of writing the changelog entry, because the check had stopped applying: `git describe --tags` returned a corpus review tag, `_semver_tuple` read it as `(0, 0, 0)`, and every real version compared as bumped past it. One non-trivial commit had been sitting undelivered since `v3.7.0` under that blind spot. | +| The invariant tests for the two in-toto docs (`2c5e7a5`) | Tests only. Aligning the docs fixed the instance; nothing kept them aligned, which is how they drifted apart in the first place. | + +Two of these five were not in the first version of this list. That is the point of writing a scope +list down rather than remembering it: on 2026-08-07 at 19:19 the list said four items while the +branch carried seven commits, and the two missing ones were the two that had been found rather than +planned. A list that does not contain what actually ships is the thing it was built against. ## Out, and why From b6f93958caaa28909e12e9ee0b4a7cae2bfbb446 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 01:09:12 +0200 Subject: [PATCH 09/28] docs: carry the asymmetry non-claim into both in-toto copies The submitted spec gained a third Non-claims paragraph today (35c83da). The mirror was pinned at 6fdf5bf and the profile page never carried it, so both described a predicate that says less than the one actually submitted. The mirror takes the paragraph verbatim; the profile page states it in its own voice and keeps the 'can be' hedge, because the predicate never fixes which class a detector counts as positive. Measured, not assumed: the other five upstream changes of today were already present in both files via 871453c. Only this one had drifted. --- docs/IN_TOTO_PROFILE.md | 8 ++++++++ docs/upstream/eval-result.md | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/IN_TOTO_PROFILE.md b/docs/IN_TOTO_PROFILE.md index 3bc5b8a2..605c36f8 100644 --- a/docs/IN_TOTO_PROFILE.md +++ b/docs/IN_TOTO_PROFILE.md @@ -123,6 +123,14 @@ the predicate does **not** establish that the evaluation harness or grader is fi that it has any particular detection performance. Binding a harness digest pins *which* artifact ran, not *how well* it detects. +The consequences of that non-claim **can be asymmetric**, and the direction depends on which class the +detector counts as positive. Where detected positives are evidence of capability, missed positives can +understate the subject. Where `passed: true` depends on the *absence* of detected failures, missed +failures can instead yield a passing verdict although the failures occurred. The attestation +authenticates either verdict without establishing the harness's detection capability. The submitted +spec carries this as its own paragraph in `## Non-claims`; it deliberately says *can be* rather than +*is*, because the predicate never fixes which class a detector counts. + ## Migration path (vendor namespace → in-toto.io) Using a vendor `predicateType` for a v0.x predicate is common practice (cf. `cosign.sigstore.dev/…`, diff --git a/docs/upstream/eval-result.md b/docs/upstream/eval-result.md index 099804e7..ef0a0134 100644 --- a/docs/upstream/eval-result.md +++ b/docs/upstream/eval-result.md @@ -3,7 +3,7 @@ Type URI: https://in-toto.io/attestation/eval-result/v0.1 @@ -137,6 +137,12 @@ or fair, or that the score generalizes. Those are out of scope for this predicat This predicate does not establish that the evaluation harness or grader is fit for purpose, or that it has any particular detection performance. +The consequences can be asymmetric. When detected positives are evidence of capability, missed +positives can understate performance. When `passed: true` depends on the absence of detected +failures, missed failures can instead yield a passing verdict even though the failures occurred. +This attestation authenticates either verdict without establishing the harness's detection +capability. + ## Examples A private-model eval (subject is the receipt; the model stays secret): From 768e299edaa3791104cfc613077c1a9d22f77863 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 01:17:41 +0200 Subject: [PATCH 10/28] feat(gate): catch the version place nobody declared, and name every number's object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check 4 watches the places somebody entered into _TRACKED_PLACES. The place that goes stale is the one nobody entered, so check 6 sweeps tracked files for current-release claim shapes outside the declared set and asks for a decision: declare it, or reword it. It fires even when the number is right today — agreeing now is not the property. Measured, not assumed: it caught its own author. The first version of docs/version_truth_list.md quoted the anchor form literally, and a page about version places became one. Every number now carries its object and the file it was read from. _source_version() returns value and origin together, because deriving them separately at two call sites is how one run reports two different values for 'the version' — measured today, three times, in this repo's neighbours. The truth list itself is measured (git ls-files + line scan): one source, no derived places at all (nothing pulls the version automatically), four checked copies plus two external surfaces, 17 historical lines that must stay old. Tests +8 (28 total in this file): undeclared claim in README, undeclared claim that matches today's version, historical forms must not trip, declared places not double-reported, untracked file is not a repo claim, plus three that pin the object-and-source wording. --- docs/version_truth_list.md | 98 +++++++++++++++++++++ scripts/check_version_and_changelog.py | 117 +++++++++++++++++++++---- tests/test_release_integrity_gate.py | 97 ++++++++++++++++++++ 3 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 docs/version_truth_list.md diff --git a/docs/version_truth_list.md b/docs/version_truth_list.md new file mode 100644 index 00000000..d6a20a2e --- /dev/null +++ b/docs/version_truth_list.md @@ -0,0 +1,98 @@ +# Where the version number lives, and which places are allowed to be wrong + +Erhoben am 2026-08-07 unter `QITEM-PB-RUNDE-DOKU-UND-RIEGEL-01`. Die Liste ist **gemessen** +(`git ls-files` plus Zeilen-Scan), nicht aus dem Gedächtnis geschrieben. Der Riegel dazu ist +`scripts/check_version_and_changelog.py`. + +## Die eine Quelle + +| Ort | Zeile | Wert | +|---|---|---| +| `pyproject.toml` | 7 | `version = "3.7.0"` | + +**Bestätigt wie vorgeschlagen.** Begründung, nicht nur Zustimmung: `pyproject.toml` ist die einzige +Stelle, die das Build-Werkzeug beim Paketieren tatsächlich liest. Jede andere Stelle kann falsch +sein, ohne dass ein Artefakt anders wird; diese nicht. Der Riegel liest die Quelle über +`_source_version()`, das Wert **und** Herkunftsdatei zusammen zurückgibt — getrennt gelesen wäre der +nächste Fall von „Zahl ohne Gegenstand". + +## Abgeleitet — **keine** + +Gemessen: es gibt **keine** Stelle, die die Version automatisch zieht. Weder `importlib.metadata` +noch ein `dynamic`-Feld in `pyproject.toml`. `src/proofbundle/__init__.py` trägt die Zahl von Hand. + +Das ist ein Befund, keine Einstufung: die Kategorie „abgeleitet" ist im Repo derzeit leer, und jede +Kopie hängt an einem Menschen, der sie nachzieht. Der Riegel gleicht das aus, indem er vergleicht — +er ersetzt die Ableitung nicht. **Nicht in dieser Runde geändert** (der Auftrag verbietet +Semantikänderungen an Code). + +## Geprüft — bleibt von Hand, wird aber verglichen + +Diese Stellen müssen die Quelle wörtlich nennen. Eine Abweichung ist ein Fehlschlag, kein Hinweis. + +| Ort | Zeile | Ankerform | am 2026-08-07 gemessen | Prüfung | +|---|---|---|---|---| +| `src/proofbundle/__init__.py` | 16 | `__version__ = ""` | 3.7.0 | Check 1 (Single-Sourcing) | +| `CITATION.cff` | 18 | `version: ` | 3.7.0 | Check 1 (Single-Sourcing) | +| `RELEASE.md` | 81 | `(current: )` | 3.7.0 | Check 4 (`_TRACKED_PLACES`) | +| `docs/readiness_pack/PROGRESS.md` | 3 | `(current release: )` | 3.7.0 | Check 4 (`_TRACKED_PLACES`) | +| PyPI | — | `info.version` | 3.7.0 | Check 5, nur mit `--external` | +| `b7n0de.com/proofbundle` | — | `PyPI latest <v>` | nicht in dieser Runde abgefragt | Check 5, nur mit `--external` | + +Die Ankerformen stehen hier mit Platzhalter statt mit der Zahl, und das ist kein Schönheitsgriff: +in der ersten Fassung dieser Seite stand die Form wörtlich mit `3.7.0` — **Check 6 hat genau das +gefangen**, weil eine Doku über Versionsstellen sonst selbst zu einer wird. Der Riegel hat seinen +ersten echten Fund an seinem eigenen Autor gemacht. + +Die beiden Aussenstellen kennen drei Zustände. Nicht erreichbar heisst `NICHT MESSBAR` und ist +**weder grün noch rot**; `--require-external` macht daraus einen Fehlschlag und gehört in die +Release-Checkliste, wo „wir konnten nicht nachsehen" blockieren muss. + +## Historisch — darf und soll alt bleiben + +17 gemessene Zeilen. Sie halten fest, **wann** etwas wahr wurde. Wer sie mitzieht, macht aus einer +Tatsache eine Falschaussage, deshalb stehen sie ausdrücklich **nicht** unter dem Riegel: + +- `CHANGELOG.md` — alle `## [X.Y.Z]`-Überschriften und die Prosa darunter +- `INTEGRATIONS.md:75,91` — „sample-count provenance **since** v3.7.0" +- `CROSS_IMPLEMENTATION_REPORT.md:86`, `docs/readiness_pack/differential_matrix.md:24`, + `docs/readiness_pack/rust_parity_scope.md:27` — „56/56 **as of** v3.7.0" +- `docs/release_scope/3.7.1.md` — Planung der **nächsten** Fassung, keine Aussage über die aktuelle +- `audit_artifacts/370/` — eingefrorene Artefakte einer vergangenen Fassung + +Zwei weitere Mengen bleiben ebenfalls aussen vor, aus je eigenem Grund: `tests/` nennt falsche +Versionen **mit Absicht** (20 Zeilen, das sind die Fixtures der Muss-Fangen-Tests), und untracked +Dateien sind keine Aussage des Repos. + +## Was der Riegel deswegen prüft + +| # | Prüfung | Wirkung | +|---|---|---| +| 1 | Single-Sourcing über die drei Code-/Metadaten-Stellen | fail-closed | +| 2 | `CHANGELOG.md` trägt einen Abschnitt für die aktuelle Version | fail-closed | +| 3 | Post-Tag-Drift, verankert am letzten **Release**-Tag | fail-closed, git-gated | +| 4 | Die deklarierten Prosa-Stellen nennen die Quelle; ein **verschwundener Anker ist ebenfalls ein Fehlschlag** | fail-closed | +| 5 | PyPI und Projektseite, drei Zustände | fail-closed bei Abweichung | +| 6 | **Neu:** eine Stelle, die eine aktuelle Version behauptet, ohne deklariert zu sein | fail-closed | + +Check 6 schliesst die Lücke, die Check 4 bauartbedingt hat: Check 4 bewacht, was jemand +**eingetragen** hat. Eine neue Zeile, die anfängt, die aktuelle Fassung zu nennen, war bis dahin +unsichtbar — und genau die Stelle, die niemand deklariert hat, ist die, die veraltet. Der Fund +verlangt eine Entscheidung (eintragen oder umformulieren), weil ein Scan nicht wissen kann, ob eine +Aussage aktuell gemeint ist. Er greift auch dann, wenn die Zahl **heute stimmt**: Übereinstimmung im +Moment ist nicht die Eigenschaft, um die es geht. + +## Wo der Riegel läuft + +- CI: `.github/workflows/release-integrity.yml:31` — `python3 scripts/check_version_and_changelog.py --repo .` +- Release-Checkliste: `RELEASE.md:20` — `--external --require-external` muss grün sein + +## Ehrliche Grenzen + +- Der Riegel prüft **Übereinstimmung von Zeichenketten**, nicht ob die Version die richtige ist. Eine + überall konsistente falsche Zahl besteht ihn. +- Check 6 findet Behauptungs-**Formen** (`current: X.Y.Z`, `latest release: X.Y.Z`). Eine Zeile, die + die aktuelle Fassung in einer anderen Formulierung behauptet, findet er nicht. Er verengt die + Lücke, er schliesst sie nicht. +- Die Aussenstellen werden nur mit `--external` befragt. Ohne das Flag sagt die Ausgabe + ausdrücklich, dass sie **nicht** geprüft wurden — kein stilles Grün. diff --git a/scripts/check_version_and_changelog.py b/scripts/check_version_and_changelog.py index 813c4cde..50e6e077 100644 --- a/scripts/check_version_and_changelog.py +++ b/scripts/check_version_and_changelog.py @@ -2,7 +2,7 @@ """check_version_and_changelog.py — release-integrity gate for proofbundle. Closes the "merged but never released / version drift" class (the M2 security fix and the 811-vs-817 -typo both sat unreleased on main because nothing enforced this). Five checks: +typo both sat unreleased on main because nothing enforced this). Six checks: 1. VERSION SINGLE-SOURCING: pyproject.toml, src/proofbundle/__init__.py and CITATION.cff MUST agree. 2. CHANGELOG DOCUMENTS THE VERSION: the current version has a `## []` section in CHANGELOG.md. @@ -14,6 +14,19 @@ MUST state the source version. A place whose anchor phrase has vanished is a failure too, not a silent pass — a gate that stops finding its anchor stops gating. 5. EXTERNAL SURFACES (opt-in, --external): PyPI and the project page must state the same version. + 6. UNDECLARED PLACES: a tracked file that states a *current* version while not being a declared + place in _TRACKED_PLACES is a finding. Check 4 can only watch what someone remembered to + declare; a new sentence that starts claiming the current release is invisible to it, and the + place nobody declared is exactly the one that goes stale. Historical forms ("since X.Y.Z", + "as of X.Y.Z") do not match — only claim shapes that mean "this is the current release". + +EVERY NUMBER NAMES ITS OBJECT AND ITS SOURCE. Not "version 0.49.1" but "markdownlint-cli 0.49.1, +read from package.json". A bare number is real and still says nothing: on 2026-08-07 a single day +produced a library version reported as a CLI version, a bundling threshold read from the wrong call, +and an exit code that belonged to `tail`. Each number was correct about something other than the +thing it was named for. The output below therefore always carries the object a number describes and +the file it was read from. (The example deliberately uses a foreign tool's version: an illustration +that spells out this project's current release would itself become a place that goes stale.) WHAT THIS DOES NOT TOUCH, deliberately: historical statements. "since v3.7.0", "as of v3.7.0" and old CHANGELOG headings record *when* something became true. Bumping them would turn a fact into a lie, so @@ -54,6 +67,13 @@ re.compile(r"current release:\s*v?" + _SEMVER), "the `(current release: X.Y.Z)` note"), ] +# Check 6 — shapes that mean "this IS the current release". Deliberately narrow: "since X.Y.Z" and +# "as of X.Y.Z" record history and must never match, or the sweep would demand that facts be bumped. +_CURRENT_CLAIM = re.compile( + r"(?:current|latest)(?:\s+(?:release|version))?\s*:?\s*v?" + _SEMVER, re.IGNORECASE) +# Not swept: test fixtures state wrong versions ON PURPOSE, and audit artifacts are frozen history. +_SWEEP_EXCLUDE_PREFIXES = ("tests/", "audit_artifacts/") + _PYPI_JSON = "https://pypi.org/pypi/proofbundle/json" _PROJECT_PAGE = "https://b7n0de.com/proofbundle/" # The page states the published version as `PyPI latest X.Y.Z` (and `PyPI-latest` in the @@ -84,6 +104,22 @@ def _citation_version(repo: Path) -> str | None: return m.group(1) if m else None +def _source_version(repo: Path) -> tuple[str | None, str]: + """The source version AND the file it was read from. + + Returned together on purpose: a version without its origin is the defect class this gate exists + to catch. Callers must not re-derive the number separately — that is how two call sites end up + reporting different values for "the version". + """ + for datei, leser in (("pyproject.toml", _pyproject_version), + ("src/proofbundle/__init__.py", _init_version), + ("CITATION.cff", _citation_version)): + v = leser(repo) + if v: + return v, datei + return None, "no source file carried a version" + + def _changelog_headings(repo: Path) -> list[str]: # Every `## [x.y.z]` or `## [Unreleased]` heading, in file order. return re.findall(r"(?m)^##\s*\[([^\]]+)\]", _read(repo / "CHANGELOG.md")) @@ -142,7 +178,7 @@ def check(repo: Path) -> list[str]: if len(distinct) > 1: problems.append(f"version disagreement across sources: {versions}") - version = pv or iv or cv + version, herkunft = _source_version(repo) headings = _changelog_headings(repo) # 2. CHANGELOG documents the current version @@ -167,12 +203,50 @@ def check(repo: Path) -> list[str]: f"(e.g. {nontrivial[:3]})") # 4. Tracked prose places state the current version + # 6. Places that state a current version without being declared if version: - problems.extend(check_tracked_places(repo, version)) + problems.extend(check_tracked_places(repo, version, herkunft)) + problems.extend(check_undeclared_places(repo)) + return problems + + +def _tracked_files(repo: Path) -> list[str]: + """Tracked files only. An untracked scratch file is not a claim this repo makes — reading one as + a repo statement is the same defect this gate reports about numbers.""" + rc, out = _git(repo, "ls-files") + return out.splitlines() if rc == 0 else [] + + +def check_undeclared_places(repo: Path) -> list[str]: + """Find "this is the current release" claims outside _TRACKED_PLACES. + + Check 4 watches the places somebody declared. This one watches for places nobody did: a sentence + that starts stating the current release is, from that moment, a place that can go stale, and + nothing was looking at it. The finding asks for a decision (declare it, or reword it), because a + sweep cannot know whether a claim is meant to be current. + """ + declared = {rel for rel, _, _ in _TRACKED_PLACES} + problems: list[str] = [] + for rel in _tracked_files(repo): + if rel in declared or rel.startswith(_SWEEP_EXCLUDE_PREFIXES): + continue + p = repo / rel + try: + text = p.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue # binary or unreadable: no claim to read, not a failure + for nr, zeile in enumerate(text.splitlines(), 1): + treffer = _CURRENT_CLAIM.search(zeile) + if treffer: + problems.append( + f"{rel}:{nr}: states a current version ({treffer.group(1)}, in " + f"\"{treffer.group(0).strip()}\") but is not a declared place. Either add it to " + f"_TRACKED_PLACES so it is kept current, or reword it so it does not claim to be.") + break # one finding per file is enough to force the decision return problems -def check_tracked_places(repo: Path, version: str) -> list[str]: +def check_tracked_places(repo: Path, version: str, herkunft: str = "the source file") -> list[str]: """Every declared "this is the current release" statement must name `version`. A missing file or a vanished anchor phrase is a failure, not a pass: if the sentence was @@ -192,7 +266,8 @@ def check_tracked_places(repo: Path, version: str) -> list[str]: continue wrong = sorted({v for v in found if v != version}) if wrong: - problems.append(f"{rel}: {beschreibung} states {wrong} but the source version is {version}") + problems.append(f"{rel}: {beschreibung} states {wrong} but the source version is " + f"{version}, read from {herkunft}") return problems @@ -206,7 +281,8 @@ def _fetch(url: str, timeout: float) -> str | None: return None -def check_external(version: str, timeout: float = 15.0) -> list[tuple[str, str, str]]: +def check_external(version: str, timeout: float = 15.0, + herkunft: str = "the source file") -> list[tuple[str, str, str]]: """Compare the source version against PyPI and the project page. Returns (surface, state, detail) with state in {"OK", "ABWEICHUNG", NICHT_MESSBAR}. @@ -224,7 +300,8 @@ def check_external(version: str, timeout: float = 15.0) -> list[tuple[str, str, ergebnisse.append(("PyPI", NICHT_MESSBAR, "response was not the expected JSON shape")) else: ergebnisse.append(("PyPI", "OK" if veroeffentlicht == version else "ABWEICHUNG", - f"PyPI states {veroeffentlicht}, source states {version}")) + f"PyPI states {veroeffentlicht} (published sdist/wheel version), " + f"source states {version}, read from {herkunft}")) seite = _fetch(_PROJECT_PAGE, timeout) if seite is None: @@ -237,10 +314,13 @@ def check_external(version: str, timeout: float = 15.0) -> list[tuple[str, str, ergebnisse.append(("project page", NICHT_MESSBAR, "no `PyPI latest X.Y.Z` statement found on the page")) elif genannt == [version]: - ergebnisse.append(("project page", "OK", f"page states {version}")) + ergebnisse.append(("project page", "OK", + f"page states {version} as `PyPI latest`, matching {version} " + f"read from {herkunft}")) else: ergebnisse.append(("project page", "ABWEICHUNG", - f"page states {genannt}, source states {version}")) + f"page states {genannt} as `PyPI latest`, source states {version}, " + f"read from {herkunft}")) return ergebnisse @@ -256,13 +336,14 @@ def main() -> int: repo = Path(a.repo).resolve() problems = check(repo) + version, herkunft = _source_version(repo) + aussen: list[tuple[str, str, str]] = [] if a.external or a.require_external: - version = _pyproject_version(repo) or _init_version(repo) or _citation_version(repo) if not version: problems.append("cannot check external surfaces: no source version found") else: - aussen = check_external(version, a.timeout) + aussen = check_external(version, a.timeout, herkunft) print("external surfaces:") for name, state, detail in aussen: print(f" - {name}: {state} ({detail})") @@ -278,15 +359,21 @@ def main() -> int: print(f" - {p}") return 1 - geprueft = "version single-sourced, tracked places current, changelog current, no undelivered drift" + # The number names its object and its source, here too: an OK line that does not say WHICH + # version was verified leaves the reader to assume one. + quelle = f"source version {version}, read from {herkunft}" + geprueft = ("single-sourced across pyproject.toml/__init__.py/CITATION.cff, tracked places " + "current, changelog carries the section, no undelivered post-tag drift, " + "no undeclared place claiming a current version") if not aussen: - print(f"check_version_and_changelog: OK — {geprueft}. External surfaces NOT checked.") + print(f"check_version_and_changelog: OK — {quelle}; {geprueft}. " + f"External surfaces NOT checked (neither --external nor --require-external given).") elif any(s == NICHT_MESSBAR for _, s, _ in aussen): offen = ", ".join(n for n, s, _ in aussen if s == NICHT_MESSBAR) - print(f"check_version_and_changelog: OK — {geprueft}. " + print(f"check_version_and_changelog: OK — {quelle}; {geprueft}. " f"NOT verified ({NICHT_MESSBAR}): {offen}.") else: - print(f"check_version_and_changelog: OK — {geprueft}, external surfaces agree.") + print(f"check_version_and_changelog: OK — {quelle}; {geprueft}; external surfaces agree.") return 0 diff --git a/tests/test_release_integrity_gate.py b/tests/test_release_integrity_gate.py index aad64615..52944e89 100644 --- a/tests/test_release_integrity_gate.py +++ b/tests/test_release_integrity_gate.py @@ -284,5 +284,102 @@ def test_malformed_pypi_response_is_not_measurable(monkeypatch): assert zustaende["PyPI"] == chk.NICHT_MESSBAR +# -------------------------------------------------------------------------------------------- +# Check 6: places that claim a current version without being declared +# +# Check 4 can only watch what somebody declared. This is the must-catch for the README case: the +# README states no version today, so a test that "the README number is stale" would be testing a +# sentence that does not exist. What CAN go wrong is that a version claim appears there — and from +# that moment it is a place that can go stale with nothing watching it. +# -------------------------------------------------------------------------------------------- + +def _git_repo(t: Path, version: str, headings: list[str]): + def g(*a): + return subprocess.run(["git", "-C", str(t), *a], capture_output=True, text=True) + + g("init", "-q", "-b", "main") + g("config", "user.email", "t@t") + g("config", "user.name", "t") + _write_repo(t, version, headings) + return g + + +def test_undeclared_current_version_claim_in_readme_is_caught(tmp_path): + g = _git_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "README.md").write_text( + "# proofbundle\n\nInstall it. The current release: 3.0.0 ships the adapter.\n", encoding="utf-8") + g("add", "-A") + g("commit", "-qm", "docs: readme") + probs = chk.check_undeclared_places(tmp_path) + assert any("README.md" in p and "3.0.0" in p and "not a declared place" in p for p in probs), probs + + +def test_undeclared_claim_is_caught_even_when_it_matches_the_source(tmp_path): + # Subtle and the whole point: agreeing TODAY is not the property. An undeclared place that + # happens to be right is one release away from being wrong with nobody looking. + g = _git_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "README.md").write_text("current release: 3.0.1\n", encoding="utf-8") + g("add", "-A") + g("commit", "-qm", "docs: readme") + assert any("README.md" in p for p in chk.check_undeclared_places(tmp_path)) + + +def test_historical_statements_do_not_trip_the_sweep(tmp_path): + # "since"/"as of" record when something became true. A sweep that demanded they be bumped would + # manufacture false claims — the exact thing check 4 refuses to do. + g = _git_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "INTEGRATIONS.md").write_text( + "sample-count provenance since v3.7.0; corpus 56/56 as of v3.2.0.\n", encoding="utf-8") + g("add", "-A") + g("commit", "-qm", "docs: integrations") + assert chk.check_undeclared_places(tmp_path) == [] + + +def test_declared_places_are_not_reported_twice(tmp_path): + # RELEASE.md legitimately states the current version and IS declared — check 4 owns it. + g = _git_repo(tmp_path, "3.0.1", ["3.0.1"]) + g("add", "-A") + g("commit", "-qm", "init") + probs = chk.check_undeclared_places(tmp_path) + assert not any("RELEASE.md" in p or "PROGRESS.md" in p for p in probs), probs + + +def test_untracked_file_is_not_a_repo_claim(tmp_path): + # An untracked scratch file is not something the repo says. Reading one as a repo statement is + # the same defect as reporting a number about the wrong object. + g = _git_repo(tmp_path, "3.0.1", ["3.0.1"]) + g("add", "-A") + g("commit", "-qm", "init") + (tmp_path / "scratch.md").write_text("current release: 2.0.0\n", encoding="utf-8") + assert chk.check_undeclared_places(tmp_path) == [] + + +# -------------------------------------------------------------------------------------------- +# Every number names its object and its source +# -------------------------------------------------------------------------------------------- + +def test_source_version_carries_its_origin(tmp_path): + _write_repo(tmp_path, "3.0.1", ["3.0.1"]) + assert chk._source_version(tmp_path) == ("3.0.1", "pyproject.toml") + + +def test_stale_place_finding_names_where_the_source_was_read(tmp_path): + _write_repo(tmp_path, "3.0.1", ["3.0.1"]) + (tmp_path / "RELEASE.md").write_text( + "# Release\n\n(current: 3.0.0) and so on.\n", encoding="utf-8") + probs = chk.check(tmp_path) + assert any("read from pyproject.toml" in p for p in probs), probs + + +def test_external_finding_names_both_objects(monkeypatch): + monkeypatch.setattr(chk, "_fetch", _fake_fetch({ + chk._PYPI_JSON: json.dumps({"info": {"version": "3.6.2"}}), + chk._PROJECT_PAGE: _page("3.7.0"), + })) + detail = dict((n, d) for n, _, d in chk.check_external("3.7.0", herkunft="pyproject.toml")) + assert "published sdist/wheel version" in detail["PyPI"] + assert "read from pyproject.toml" in detail["PyPI"] + + if __name__ == "__main__": raise SystemExit(__import__("pytest").main([__file__, "-q"])) From 9e3e53d4b729c8411e9852de22b5edcb56272099 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 01:27:20 +0200 Subject: [PATCH 11/28] docs: the three things an integrator looks for first, and the measured assurance state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SUPPORT.md and COMPATIBILITY.md add no promise. SUPPORT repeats SECURITY.md's supported-version sentence instead of softening it — two rules would mean the stricter one is only true on paper — and says plainly that there is no response-time commitment, because inventing one would be worth less. COMPATIBILITY writes down what the existing SemVer commitment already implies, including the part that is easy to miss: adding a key to a signed structure is breaking, which is why stash@{0} stayed out of 3.7.1. The self-assessment states the Scorecard value as measured and unsoftened: 6.5/10 (v5.5.0, api.securityscorecards.dev, 2026-08-07T18:37:54Z), with the four zeros named and their three different causes separated. No application was filed — that is outward-facing and needs its own GO. It also caught an overclaim in scorecard.yml: the comment said Pinned-Dependencies was maxed because actions are SHA-pinned. Measured 3/10. Actions are one input among several. The comment now carries its measurement date, because a score nobody re-measured is the same defect class this repo reports about version numbers. Scope list extended by the five items this and the previous round produced. --- .github/workflows/scorecard.yml | 8 +- COMPATIBILITY.md | 89 +++++++++++++++++ SUPPORT.md | 51 ++++++++++ .../openssf_best_practices_self_assessment.md | 98 +++++++++++++++++++ docs/release_scope/3.7.1.md | 4 + 5 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 COMPATIBILITY.md create mode 100644 SUPPORT.md create mode 100644 docs/openssf_best_practices_self_assessment.md diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 9f063c7f..096eca2a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -2,8 +2,12 @@ name: Scorecard # OpenSSF Scorecard — supply-chain posture (pinned deps, token permissions, branch protection, # dangerous workflows, SAST, fuzzing, signed releases). Publishes results so the badge works. -# The high-leverage checks this repo already maxes: Pinned-Dependencies (all actions SHA-pinned), -# Token-Permissions (read-all top-level, per-job escalation), SAST (CodeQL), Fuzzing (Hypothesis). +# Measured 2026-08-07 (v5.5.0, overall 6.5/10), not assumed: Token-Permissions, SAST and Fuzzing do +# score 10/10. Pinned-Dependencies does NOT — it scores 3/10, while an earlier version of this +# comment claimed the check was maxed because all actions are SHA-pinned. Actions are only one input; +# the check also weighs pip/Dockerfile pinning. A comment that states a score nobody re-measured is +# the same defect class this project reports about version numbers, so it now carries its date. +# Current state per check: docs/openssf_best_practices_self_assessment.md. on: branch_protection_rule: diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md new file mode 100644 index 00000000..a697ab6f --- /dev/null +++ b/COMPATIBILITY.md @@ -0,0 +1,89 @@ +# Compatibility and deprecation + +What SemVer means here concretely, what counts as a breaking change, how long a deprecated thing +stays, and which parts are exempt because they are labelled EXPERIMENTAL. + +**Nothing on this page is a new promise.** README already states the project is SemVer-committed; +this file writes down what that sentence already implies, so that a reader does not have to infer it +and a maintainer cannot quietly narrow it. + +## What counts as the public interface + +Compatibility statements are worthless without naming the surface they cover. For proofbundle it is: + +1. **The Python API** — names importable from `proofbundle` and its documented submodules, their + parameters, and the **shape of what they return**. +2. **The CLI** — subcommands, flags, and the meaning of exit codes. +3. **The emitted bytes** — the receipt/bundle formats, the in-toto Statement and the DSSE envelope. + A signed structure is an interface even when no function signature changes. +4. **The verification verdict** — whether a given input verifies. A change that makes previously + valid input invalid is breaking even if every signature stayed the same. + +Point 3 is the one that is easy to miss, and it is the reason `stash@{0}` did not ride along in +3.7.1: adding one key to a per-edge result entry changes a structure that ends up signed. Measured, +recorded in [`docs/release_scope/3.7.1.md`](docs/release_scope/3.7.1.md), and kept out of a PATCH. + +## What is a breaking change + +- Removing or renaming anything in the four surfaces above. +- Changing the **type** or **meaning** of a field, including a field that is only ever read. +- **Adding** a field to an emitted, signed structure — the bytes change even though nothing was + taken away. +- Making a previously accepted input fail verification. +- Turning an optional obligation into a required one. + +**Not breaking:** new optional CLI flags, new functions, additional keys in a structure that is +neither signed nor part of a documented return shape, documentation, tests, build tooling, and +performance. Tightening a check that only ever accepted input the spec already called invalid is a +fix, not a break — and the CHANGELOG entry has to say so explicitly, because from the outside a +stricter check and a break look identical. + +## What each version step allows + +| Step | Allowed | +|---|---| +| PATCH (`x.y.Z`) | fixes only. **No semantic change, no new obligation, no changed behaviour at a public interface.** | +| MINOR (`x.Y.0`) | additive changes, new optional fields, new commands, deprecation *announcements* | +| MAJOR (`X.0.0`) | removals, renames, meaning changes — that is, everything above | + +That PATCH row is not decoration: it is the question the release gate in [RELEASE.md](RELEASE.md) +makes someone answer per line of the scope list, in writing, before a release is asked for. + +## How long a deprecated thing stays + +**A deprecated stable element is removed no earlier than the next MAJOR.** That is not an extra +guarantee — it is what SemVer already means, written down so nobody has to derive it. In practice: + +1. The deprecation is announced in the CHANGELOG of the MINOR that announces it, and the element + keeps working unchanged. +2. It stays through every following MINOR and PATCH of that major line. +3. It may be removed in the next MAJOR, and that removal is named in the CHANGELOG. + +**No calendar.** No "six months", no "two releases" — a period nobody schedules is a promise that +breaks itself. The bound is the next MAJOR, and MAJORs happen when they happen. + +**Honest limit:** as of this writing no stable element has gone through the full cycle, so this +describes a rule, not a track record. It is written down precisely because a rule that only exists +in someone's head is not one. + +## What is EXPERIMENTAL, and what that costs you + +EXPERIMENTAL parts are **excluded from all of the above**. They may change or disappear in any +release, including a PATCH, without a deprecation period. That is the whole point of the label: it +buys the freedom to get a design wrong in public. + +Currently labelled EXPERIMENTAL (see CHANGELOG and README for the authoritative statement per +release): + +- **`relation/v0.1`** — the relation/lineage surface +- **the `[experimental]` extra** — the TEE-attestation bridge, see + [docs/EXPERIMENTAL_ENCLAVE.md](docs/EXPERIMENTAL_ENCLAVE.md) + +The `eval-result` predicate is a further case and is labelled separately: its `predicateType` sits +in a **vendor namespace** until in-toto registers the type, and the migration path (registered URI +plus alias) is written down in [docs/IN_TOTO_PROFILE.md](docs/IN_TOTO_PROFILE.md). Consumers match +on the subject digest, so that rename does not affect binding. + +The current release line as a whole carries a status boundary of its own (audit-candidate **BETA**), +stated per release in the CHANGELOG. A BETA line still follows the table above; the label says how +much external assurance exists, not how freely the interface may move. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..2901b861 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,51 @@ +# Support + +Where to ask, what is maintained, and what belongs somewhere else. This page describes what already +happens; it does not add a promise. + +## Which versions are maintained + +**Only the latest released minor version of the current major line.** That is the same sentence +[SECURITY.md](SECURITY.md) states for security fixes, and it is deliberately not a second, softer +rule for non-security questions — two rules would mean the stricter one is only true on paper. + +At the time of writing the current line is **3.x**, and the latest release is the version in +[`pyproject.toml`](pyproject.toml). This page does not repeat the number: a page that states a +version becomes a place that goes stale, and `scripts/check_version_and_changelog.py` reports exactly +that class of drift. + +Older lines (`release/*` branches, older tags) stay readable and stay published on PyPI. They do +**not** receive fixes. If you depend on one, pin it and read the +[CHANGELOG](CHANGELOG.md) before you move. + +## Where to ask + +| You want to | Go to | +|---|---| +| ask a question, report a bug, request a feature | **[GitHub Issues](https://github.com/b7n0de/proofbundle/issues)** | +| report a vulnerability | **not here** — see [SECURITY.md](SECURITY.md) | +| propose a change | [CONTRIBUTING.md](CONTRIBUTING.md) | +| know who decides | [GOVERNANCE.md](GOVERNANCE.md), [MAINTAINERS.md](MAINTAINERS.md) | + +GitHub Discussions is **switched off** for this repository, so Issues is the one place. That is a +measured statement, not a preference: a link to a forum that does not exist is worse than no link. + +## A question and a security report are not the same thing + +A question is public by design: it is filed in the open, answered in the open, and the answer helps +the next reader. + +A vulnerability report is **not**, and it must not arrive as an Issue. The process, the contact and +the disclosure handling live in [SECURITY.md](SECURITY.md) and are deliberately **not repeated here** — +a duplicated security process is a process with two versions, and the one you read might be the old +one. If you are unsure which of the two you have: treat it as a vulnerability and follow SECURITY.md. + +## What you can expect, honestly + +There is no response-time commitment, and this page will not invent one. proofbundle is maintained +by a single maintainer (see [MAINTAINERS.md](MAINTAINERS.md)); issues are read, and there is no +staffed rotation behind them. Stating a service level nobody is on call to keep would be worth less +than saying so plainly. + +What *is* enforced rather than promised: every release answers the release gate in +[RELEASE.md](RELEASE.md) before it goes out, and the CI checks that gate mechanically. diff --git a/docs/openssf_best_practices_self_assessment.md b/docs/openssf_best_practices_self_assessment.md new file mode 100644 index 00000000..4f970c05 --- /dev/null +++ b/docs/openssf_best_practices_self_assessment.md @@ -0,0 +1,98 @@ +# OpenSSF Best Practices — honest self-assessment + +Erhoben am 2026-08-07 unter `QITEM-PB-RUNDE2-371-AUSLIEFERUNGSFAEHIG-01`. + +**Kein Antrag, keine Anmeldung, kein Profil.** Diese Datei ist eine Selbsteinschätzung gegen den +Kriterienkatalog, damit man sieht, wo das Projekt steht, bevor jemand ein Abzeichen beantragt. Der +Antrag selbst ist Aussenwirkung und braucht einen eigenen Owner-GO. + +Drei Zustände, nie zwei: **erfüllt** · **nicht erfüllt** · **nicht messbar / nicht anwendbar**. Der +dritte wird nicht zu „erfüllt" geschönt. + +--- + +## Teil 1: OpenSSF Scorecard — der gemessene Wert + +Kein Fragebogen, sondern ein Werkzeug, das das Repository von aussen abtastet. Der Lauf ist bereits +verdrahtet (`.github/workflows/scorecard.yml`, `publish_results: true`) und das Ergebnis öffentlich +abrufbar. + +**Gesamtwert: 6,5 von 10.** Gelesen aus `api.securityscorecards.dev` für +`github.com/b7n0de/proofbundle`, Stand `2026-08-07T18:37:54Z`, Scorecard **v5.5.0**. + +| Wert | Check | Anmerkung | +|---:|---|---| +| 0/10 | Maintained | überrascht bei täglicher Arbeit — der Check zählt Aktivität auf dem **Standard-Zweig** und Issue-Verkehr, und beides läuft hier über Arbeitszweige. **Nicht weginterpretiert:** der Wert steht so da. | +| 0/10 | CII-Best-Practices | genau das Abzeichen, um das es in Teil 2 geht. Kein Antrag gestellt → 0. | +| 0/10 | Signed-Releases | der Release-Workflow hängt eine **SLSA-Build-Provenance** an und gated den Upload auf sha256. Der Check sucht Sigstore-Signaturen an den Release-Assets und findet keine. Zwei verschiedene Dinge; der Wert ist trotzdem 0 und wird hier nicht schöngeredet. | +| 0/10 | Contributors | verlangt Beiträger aus mindestens zwei Organisationen. Ein-Personen-Projekt. | +| 1/10 | Code-Review | die meisten Commits sind nicht von einer zweiten Person geprüft. Strukturell, nicht behebbar durch Fleiss. | +| 3/10 | Pinned-Dependencies | **widerspricht dem eigenen Kommentar** in `scorecard.yml`, der behauptet, dieser Check sei ausgereizt („all actions SHA-pinned"). Gemessen: 3/10. Der Kommentar ist eine Überbehauptung und gehört korrigiert. | +| 3/10 | Branch-Protection | teilweise. | +| 9/10 | Binary-Artifacts | | +| 10/10 | Security-Policy · Dependency-Update-Tool · Dangerous-Workflow · Token-Permissions · Vulnerabilities · Packaging · License · SAST · Fuzzing · CI-Tests | zehn Checks auf Vollwert | + +**Der Wert wird weder geschönt noch verschwiegen.** 6,5 ist mittelmässig, und die vier Nullen haben +drei verschiedene Ursachen: eine ist ein fehlender Antrag (behebbar), eine ist ein Werkzeug, das eine +andere Signaturform sucht als die vorhandene (erklärbar), und zwei sind Eigenschaften eines +Ein-Personen-Projekts (nicht behebbar, ohne dass eine zweite Person dazukommt). + +--- + +## Teil 2: Best-Practices-Katalog, Stufe `passing` + +| Kriterium | Zustand | Beleg / was fehlt | +|---|---|---| +| Projektseite beschreibt Zweck | **erfüllt** | `README.md` | +| Projektseite nennt Beitrags-Weg | **erfüllt** | `CONTRIBUTING.md`, verlinkt | +| Freie Lizenz, in der üblichen Datei | **erfüllt** | `LICENSE`, MIT | +| Dokumentation der Grundfunktionen | **erfüllt** | `README.md`, `SPEC.md`, `docs/` | +| Dokumentierte Schnittstelle | **erfüllt** | `SPEC.md`, `docs/IN_TOTO_PROFILE.md` | +| Fehlerberichte werden angenommen | **erfüllt** | GitHub Issues aktiv (gemessen: `hasIssuesEnabled: true`) | +| Weg für Sicherheitsmeldungen | **erfüllt** | `SECURITY.md` | +| Wie man Unterstützung bekommt | **erfüllt** | `SUPPORT.md` (neu in dieser Runde) | +| Öffentliches Versionskontroll-Repository | **erfüllt** | GitHub, öffentlich | +| Eindeutige Versionsnummerierung | **erfüllt** | SemVer; `scripts/check_version_and_changelog.py` erzwingt Einheitlichkeit über alle Stellen | +| Release-Notizen je Version | **erfüllt** | `CHANGELOG.md`; der Riegel prüft, dass die aktuelle Version einen Abschnitt hat | +| Release-Notizen nennen behobene Schwachstellen | **erfüllt** | z. B. der M2-Eintrag | +| Automatisierte Testsuite | **erfüllt** | `make test` (`unittest discover -s tests`), 155 Testdateien, in `ci.yml`. Gemessen am 2026-08-07 mit dem Läufer des Repos: **2007 Tests, OK, 7 übersprungen, rc 0** in 75,8 s. (Nebenbei ein Beispiel für den Punkt, um den es hier geht: derselbe Baum unter `pytest` meldet 740 Sammelfehler — das ist eine Aussage über das fremde Werkzeug, nicht über das Repo, und wäre als Repo-Zahl falsch.) | +| Neue Funktionalität braucht Tests | **erfüllt** | `CONTRIBUTING.md`; zusätzlich ein Mutations-Gate | +| Warnungen behandelt | **erfüllt** | `ruff` mit gepinntem Regelsatz, `mypy` | +| Statische Analyse | **erfüllt** | CodeQL (`codeql.yml`), Scorecard SAST 10/10 | +| Dynamische Analyse | **erfüllt** | Property-based Fuzzing (Hypothesis), Scorecard Fuzzing 10/10 | +| Gesicherte Auslieferung | **erfüllt** | HTTPS durchgehend; PyPI-Upload sha256-gated gegen das attestierte Artefakt | +| Keine bekannten offenen Schwachstellen | **erfüllt** | Scorecard Vulnerabilities 10/10 | +| Öffentlich bekannte Schwachstellen binnen 60 Tagen behoben | **nicht messbar** | es gab bisher keine gemeldete öffentliche Schwachstelle — kein Nachweis, aber auch kein Verstoss. Wird nicht als „erfüllt" gebucht. | +| Kryptographie: öffentliche Standardverfahren | **erfüllt** | Ed25519 über `cryptography`, DSSE, sha256 — keine Eigenbauten | +| Zwei-Personen-Review | **nicht erfüllt** | Ein-Personen-Projekt; deckungsgleich mit Scorecard Code-Review 1/10 | +| Beitrags-Weg für Änderungsvorschläge | **erfüllt** | Pull Requests | +| Verhaltenskodex | **erfüllt** | `CODE_OF_CONDUCT.md` | +| Wer entscheidet | **erfüllt** | `GOVERNANCE.md`, `MAINTAINERS.md` | +| Abkündigungs- und Kompatibilitätszusage | **erfüllt** | `COMPATIBILITY.md` (neu in dieser Runde) | + +**Zusammengefasst:** die `passing`-Kriterien sind bis auf **zwei** erfüllt. Beide verbleibenden sind +dieselbe Tatsache aus zwei Blickwinkeln — es gibt eine Person. Zwei-Personen-Review ist **nicht +erfüllt**, und die 60-Tage-Zusage ist **nicht messbar**, weil der Fall nie eintrat. + +## Stufen `silver` und `gold` + +Nicht durchgegangen, und das ist eine bewusste Grenze statt einer Lücke: beide verlangen mehrere +Beiträger beziehungsweise mehrere Prüfer, und daran scheitert es vorher. Eine Bewertung der übrigen +Kriterien wäre Aufwand ohne Aussage, solange das eine bindende Kriterium nicht erfüllbar ist. + +## Was der Owner entscheiden muss + +1. **Scorecard-Ergebnis veröffentlichen** (Badge im README)? Der Wert **6,5** würde damit sichtbar. + Die Empfehlung ist ja: ein veröffentlichter mittelmässiger Wert ist mehr wert als ein + verschwiegener guter, und die Zahl ist über die API ohnehin öffentlich abrufbar. +2. **Best-Practices-Abzeichen beantragen?** Das ist eine Anmeldung bei einem Dienst und damit + Aussenwirkung — in dieser Runde ausdrücklich **nicht** getan. + +## Ehrliche Grenzen dieser Seite + +- Der Katalog wurde **aus dem bekannten Kriterienbestand** abgearbeitet, nicht von der Website + abgerufen — der Antrag ist nicht gestellt und die Seite nicht aufgerufen worden. Einzelne + Formulierungen können abweichen; die Zustände beruhen auf gemessenen Repo-Eigenschaften. +- Der Scorecard-Wert ist ein Stand vom `2026-08-07T18:37:54Z`. Er ändert sich mit jedem Lauf. +- „Erfüllt" heisst hier: die geforderte Sache existiert und ist belegbar. Es heisst nicht, dass sie + gut ist. diff --git a/docs/release_scope/3.7.1.md b/docs/release_scope/3.7.1.md index a394a793..a1c2b900 100644 --- a/docs/release_scope/3.7.1.md +++ b/docs/release_scope/3.7.1.md @@ -17,6 +17,10 @@ No date and no cadence. 3.7.1 goes out when it can answer the release gate, not | The release gate section in `RELEASE.md` and this scope file | Process documentation. | | The post-tag drift fix (`b48e52b`): anchor check 3 on the last **release** tag | A check, not a shipped code path. It was not planned — it fell out of writing the changelog entry, because the check had stopped applying: `git describe --tags` returned a corpus review tag, `_semver_tuple` read it as `(0, 0, 0)`, and every real version compared as bumped past it. One non-trivial commit had been sitting undelivered since `v3.7.0` under that blind spot. | | The invariant tests for the two in-toto docs (`2c5e7a5`) | Tests only. Aligning the docs fixed the instance; nothing kept them aligned, which is how they drifted apart in the first place. | +| The asymmetry non-claim in both in-toto copies (`b6f9395`) | Documentation only. The submitted spec gained a third `## Non-claims` paragraph on 2026-08-07 (`35c83da`); both copies described a predicate that claimed *more* than the one actually submitted. Measured: the other five upstream changes were already carried by `871453c`. | +| Check 6 of the version gate + the truth list (`768e299`, `docs/version_truth_list.md`) | A check and a document, not a shipped code path. Check 6 finds places that claim a current version without being declared — its first finding was the truth list itself. | +| `SUPPORT.md` and `COMPATIBILITY.md` | New documentation, no obligation added. Both write down what already holds: SUPPORT.md repeats SECURITY.md's supported-version sentence rather than softening it, COMPATIBILITY.md spells out what the existing SemVer commitment already implies. | +| `docs/openssf_best_practices_self_assessment.md` | Documentation. States the measured Scorecard value (6.5/10, v5.5.0, 2026-08-07) and the honest per-criterion state. No application filed. | Two of these five were not in the first version of this list. That is the point of writing a scope list down rather than remembering it: on 2026-08-07 at 19:19 the list said four items while the From d3401a7d3ff8fc63bfb3cd84da9368921734f0f2 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 01:41:32 +0200 Subject: [PATCH 12/28] docs+ci: publish the Scorecard value, and put the provenance where the check looks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision 2026-08-07: publish, do not curate. The badge is live, so it moves; the README states the measured number next to it (6.5/10, v5.5.0, 2026-08-07) and explains all four zeros in one sentence each, including the two that a single maintainer cannot fix. Signed-Releases was measured, not guessed. ossf/scorecard docs/checks.md: the check reads GitHub RELEASE ASSETS for *.sig / *.sigstore.json / *.intoto.jsonl. Measured the same day: v3.7.0's assets are the wheel, the sdist and SHA256SUMS. The provenance was never missing — attest-build-provenance puts it in GitHub's attestation store and on PyPI, which is not where the check looks. The bundle is now copied, unchanged, to a second location and attached to the release. Nothing is re-signed. On the asset name: .intoto.jsonl describes the content. The check does not verify signatures, so a name alone can buy points — picking one for that reason would be the exact defect this project exists to make visible. Not verified: the release workflow was not run. The next release shows whether it takes effect. --- .github/workflows/release.yml | 29 +++++++++++++++++++++++++++++ README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50dda3da..14a84361 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,10 +63,38 @@ jobs: sha256sum dist/* | tee dist/SHA256SUMS - name: Generate SLSA build provenance for the artifacts + id: provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: "dist/*.whl,dist/*.tar.gz" + - name: Also place the provenance next to the release assets + run: | + # WHY: OpenSSF Scorecard's Signed-Releases check reads the RELEASE ASSETS and looks for + # *.minisig / *.asc / *.sig / *.sign / *.sigstore / *.sigstore.json / *.intoto.jsonl + # (ossf/scorecard docs/checks.md, read 2026-08-07). Measured the same day: this repo scores + # 0/10 there while the assets of v3.7.0 are exactly the wheel, the sdist and SHA256SUMS. + # The provenance is NOT missing — it lives in GitHub's attestation store and on PyPI, i.e. + # somewhere the check never looks. + # + # NOTHING IS RE-SIGNED HERE. The bundle produced by the step above is copied unchanged to a + # second location. If that file is ever absent, this fails loudly rather than shipping a + # release that silently lost its provenance copy. + # + # ON THE NAME: `.intoto.jsonl` describes the content — in-toto attestations, one JSON per + # line. It is not chosen because that pattern scores 10 while `.sigstore.json` scores 8; + # the check does not verify signatures at all, so a name could buy points on its own, and + # picking one for that reason is exactly the defect this project exists to make visible. + set -euo pipefail + src="${{ steps.provenance.outputs.bundle-path }}" + if [ ! -s "$src" ]; then + echo "provenance bundle missing or empty at '$src' — refusing to publish a release" + echo "whose provenance copy would be silently absent." + exit 1 + fi + cp "$src" "dist/proofbundle-${GITHUB_REF_NAME#v}-provenance.intoto.jsonl" + echo "attached: $(ls -l dist/*provenance.intoto.jsonl)" + - name: Upload the attested dist for the publish job (exact bytes, no rebuild) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -94,6 +122,7 @@ jobs: dist/*.whl dist/*.tar.gz dist/SHA256SUMS + dist/*-provenance.intoto.jsonl generate_release_notes: true prerelease: ${{ steps.relmeta.outputs.prerelease }} make_latest: ${{ steps.relmeta.outputs.make_latest }} diff --git a/README.md b/README.md index 62b7950e..67f07499 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,41 @@ Merkle, one file, no server, no network. [![Python](https://img.shields.io/pypi/pyversions/proofbundle.svg)](https://pypi.org/project/proofbundle/) [![License: MIT](https://img.shields.io/badge/license-MIT-D6248A.svg)](https://github.com/b7n0de/proofbundle/blob/main/LICENSE) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21110642.svg)](https://doi.org/10.5281/zenodo.21110642) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/b7n0de/proofbundle/badge)](https://scorecard.dev/viewer/?uri=github.com/b7n0de/proofbundle) + +Scorecard **6.5/10** — [what the four zeros mean, in one sentence each](#what-the-scorecard-badge-says-including-the-parts-that-are-low) **Reviewing this for adoption?** Start with the 30-minute adversarial audit path: **[docs/REVIEWERS.md](https://github.com/b7n0de/proofbundle/blob/main/docs/REVIEWERS.md)**. +## What the Scorecard badge says, including the parts that are low + +The badge is live, so it will move. Measured 2026-08-07 (Scorecard v5.5.0): **6.5 / 10**. Ten checks +score 10/10 — Security-Policy, Token-Permissions, SAST, Fuzzing, CI-Tests, Vulnerabilities, +Dangerous-Workflow, Dependency-Update-Tool, Packaging, License. Four score 0, and rather than let you +wonder, here is each cause in one sentence: + +- **Maintained (0/10)** — the check wants sustained activity on the default branch over 90 days, and + this repository is younger than that window. It resolves itself with time and is not worth chasing. +- **CII-Best-Practices (0/10)** — the OpenSSF Best Practices badge has not been applied for. The + criteria were walked through honestly first: + [docs/openssf_best_practices_self_assessment.md](https://github.com/b7n0de/proofbundle/blob/main/docs/openssf_best_practices_self_assessment.md). +- **Contributors (0/10)** — it counts contributors from two or more organisations. This is a + one-person project, and the zero is an accurate description of that. +- **Signed-Releases (0/10)** — the check reads GitHub **release assets** looking for a signature file. + Every release is attested (SLSA build provenance over the exact built bytes, PyPI upload gated on a + sha256 match), but that attestation lived in GitHub's attestation store and on PyPI — not next to + the release, which is where the check looks. The provenance bundle is now attached as a release + asset too. Nothing was re-signed; an existing file was placed in a second location. + +Two further checks sit in between: **Code-Review 1/10** (most commits are not reviewed by a second +person — structural for a single maintainer) and **Pinned-Dependencies 3/10** / **Branch-Protection +3/10**, both measured and not yet addressed. + +Publishing a middling number with its causes is the point. A project that sells evidence cannot +withhold its own. + ## 60-second try (offline) ```bash From 9e523ba24c516ea354067b88e31f604f8cff7e75 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 04:35:44 +0200 Subject: [PATCH 13/28] fix(release): the provenance leaves dist/, and the publish gate becomes an allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L6-01 from the deep gate on d3401a7 (P1, jury-confirmed): the step I added three hours earlier copied the provenance bundle into dist/ — the same directory handed to packages-dir. Measured with twine 7.0.0: InvalidDistribution, before any network I/O. The GitHub Release would have gone out and the PyPI leg would have failed, splitting the release in half. My own commit message conceded 'Not verified: the release workflow was not run'; the gate ran it instead. Two changes, and the second is the point. (a) The provenance and SHA256SUMS now go to release-assets/, a directory with one consumer. A directory serving two consumers with different admissible contents IS the coupling; a second directory removes it. (b) The publish gate carried a hand-maintained removal list ('rm -f dist/SHA256SUMS # not a distributable'). It did not fail on my new file — it simply never mentioned it, and silence read as approval. A blocklist can only name what someone already thought of. The gate now asserts the property: after removals, dist/ holds EXACTLY one wheel and one sdist; anything else stops the publish loudly, on the leg where it is still cheap. That is finding L6-03's shape, which says the same thing about a sibling list and notes it is 'exactly like L4-01' — a class closed at one call site. Third instance of that pattern in one night. Measured bidirectionally: wheel+sdist pass; SHA256SUMS caught; the d3401a7 case caught; two wheels caught. Not verified: the release workflow still has not run. The next release shows whether the split is really gone. --- .github/workflows/release.yml | 50 ++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14a84361..d6e7741f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,9 +77,18 @@ jobs: # The provenance is NOT missing — it lives in GitHub's attestation store and on PyPI, i.e. # somewhere the check never looks. # - # NOTHING IS RE-SIGNED HERE. The bundle produced by the step above is copied unchanged to a - # second location. If that file is ever absent, this fails loudly rather than shipping a - # release that silently lost its provenance copy. + # NOT INTO dist/ — and that is the whole point of this directory existing. The first version + # of this step (d3401a7) copied the bundle into dist/, which is ALSO what feeds the PyPI + # upload via packages-dir. A deep gate on that very commit measured the consequence with + # twine 7.0.0: InvalidDistribution, before any network I/O — the GitHub Release would have + # been published and the PyPI leg would have failed, splitting the release in half. + # A directory that serves two consumers with different admissible contents is the coupling; + # a second directory removes it, whereas extending the removal list downstream would only + # have patched this one file. + # + # NOTHING IS RE-SIGNED HERE. The bundle produced by the step above is copied unchanged. If + # it is ever absent, this fails loudly rather than shipping a release whose provenance copy + # silently went missing. # # ON THE NAME: `.intoto.jsonl` describes the content — in-toto attestations, one JSON per # line. It is not chosen because that pattern scores 10 while `.sigstore.json` scores 8; @@ -92,8 +101,10 @@ jobs: echo "whose provenance copy would be silently absent." exit 1 fi - cp "$src" "dist/proofbundle-${GITHUB_REF_NAME#v}-provenance.intoto.jsonl" - echo "attached: $(ls -l dist/*provenance.intoto.jsonl)" + mkdir -p release-assets + cp "$src" "release-assets/proofbundle-${GITHUB_REF_NAME#v}-provenance.intoto.jsonl" + cp dist/SHA256SUMS release-assets/SHA256SUMS + echo "release-only assets:"; ls -l release-assets/ - name: Upload the attested dist for the publish job (exact bytes, no rebuild) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -121,8 +132,8 @@ jobs: files: | dist/*.whl dist/*.tar.gz - dist/SHA256SUMS - dist/*-provenance.intoto.jsonl + release-assets/SHA256SUMS + release-assets/*-provenance.intoto.jsonl generate_release_notes: true prerelease: ${{ steps.relmeta.outputs.prerelease }} make_latest: ${{ steps.relmeta.outputs.make_latest }} @@ -150,6 +161,31 @@ jobs: run: | set -euo pipefail rm -f dist/SHA256SUMS # not a distributable; keep only the wheel + sdist for upload + + # ALLOWLIST, NOT A REMOVAL LIST — and this is a class fix, not a tidy-up. + # + # The line above is a hand-maintained blocklist of "things that are not distributables". + # A deep gate measured what that costs (finding L6-01 on d3401a7): a new file was added to + # dist/, the removal list did not know about it, and twine 7.0.0 rejects the upload with + # InvalidDistribution — after the GitHub Release has already been published. The list did + # not fail; it simply did not mention the new file, and silence read as approval. + # + # A blocklist can only ever name what someone already thought of. This asserts the property + # instead: after the removals, dist/ contains EXACTLY the wheel and the sdist. Anything + # else — a new artefact, a stray log, a future non-distributable — stops the publish here, + # loudly, on the leg where it is still cheap. + unerwartet=$(find dist -maxdepth 1 -type f ! -name '*.whl' ! -name '*.tar.gz' -printf '%f\n') + if [ -n "$unerwartet" ]; then + echo "dist/ carries files that are not distributables:" + echo "$unerwartet" | sed 's/^/ - /' + echo "Publishing would hand these to twine via packages-dir and fail AFTER the GitHub" + echo "Release is out. Put release-only assets in release-assets/, or extend this gate" + echo "deliberately if they really belong in the upload." + exit 1 + fi + test "$(find dist -maxdepth 1 -name '*.whl' | wc -l)" = 1 + test "$(find dist -maxdepth 1 -name '*.tar.gz' | wc -l)" = 1 + got_wheel=$(sha256sum dist/*.whl | cut -d' ' -f1) got_sdist=$(sha256sum dist/*.tar.gz | cut -d' ' -f1) echo "attested wheel: ${{ needs.build-and-attest.outputs.digest-wheel }}" From ee356c32e054fb709186727e71975fabda3ca260 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 07:14:30 +0200 Subject: [PATCH 14/28] fix(relation): relation gates bind at every hop, not only at the receipt's own edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-gate finding L4-01 (P1, jury-confirmed, wf_1c023644-953). verify_relationship_edges checked at the receipt's OWN edge whether the target verifies standalone and whether a declared targetSubjectDigest binds. _walk_chain did neither: it adjudicated ancestor SYNTAX and skipped ancestor CRYPTO entirely. Measured against the pre-fix file straight out of git: a forged ancestor gives FAIL at hop 1 and VERIFIED at hops 2, 3, 4 and 5. lineage=VERIFIED with safeForAutomation=true, and the presenter picks the distance by inserting one self-signed hop. The invariant now enforced is distance invariance: verdict(D at hop 1) == verdict(D at hop n) over byte-identical evidence, for every defect class. Three gates in _walk_chain mirroring the direct arm one to one — attached-but-not-an-object, attached-but-unverified, and the subject pin on every traversed ancestor edge. Same three in the Rust walker, minus the first: TargetInfo is a typed struct, so that case cannot arise there. The asymmetry is stated in the code rather than silently absent. One thing found while fixing, same shape one level down: the walk returned the first error in DFS order, so an unverified sibling listed BEFORE a back-edge masked the cycle and the reported code depended on the order the issuer wrote the edges in. A cycle is structural and is now decided before any descent, in both languages. Two test changes, both because the tests encoded the defect. test_relation_property's reachability helper walked THROUGH unverified nodes — it modelled exactly the gap. And its assertion demanded the cycle code even where a stronger ancestor gate legitimately fires first; FAIL stays mandatory, but the reason must now be a NAMED one from a closed set, never 'FAIL without a code'. New durable regression: tests/test_relation_gate_distance_invariance.py — property over a generator varying hop depth, five defect classes, plus the two counter-directions that keep it honest (a clean chain still VERIFIES at every depth; an ancestor beyond the attached horizon stays declared-only). 5 tests, 40 subtests. Full suite 2012 tests OK, ruff clean, cargo check/test/fmt/clippy clean. NOT MEASURED, and it matters: no behavioural vector was run against the Rust binary in this round. The Rust change compiles and lints, and verify-relation / verify-relation-statement do exist as subcommands — contrary to the finding's note that the parity oracle waits for one — but Python/Rust parity for these vectors is unverified here. The relation_signer check on ancestor edges is also NOT included: it needs ancestor edges in the emitted lineage result, and that structure is signed, so adding to it is breaking under COMPATIBILITY.md. Own increment. --- src/proofbundle/relation.py | 48 +++++- .../test_relation_gate_distance_invariance.py | 163 ++++++++++++++++++ tests/test_relation_property.py | 35 +++- tools/pb_verify_rs/src/main.rs | 54 ++++++ 4 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 tests/test_relation_gate_distance_invariance.py diff --git a/src/proofbundle/relation.py b/src/proofbundle/relation.py index 54f8cd3d..a6d4c3f1 100644 --- a/src/proofbundle/relation.py +++ b/src/proofbundle/relation.py @@ -347,10 +347,34 @@ def _dfs(node_hex: str, depth: int, path: set) -> str | None: return "relation:cycle: attached chain revisits a receipt on its own ancestry path" if node_hex in proven_safe: return None - node = related.get(node_hex) - if not isinstance(node, dict): + # NOT attached -> the path ends honestly beyond the attached horizon (declared-only). + # This must stay distinguishable from "attached but malformed": `related.get()` returns + # None for both, so membership is the question, never the value's truthiness. + if node_hex not in related: proven_safe.add(node_hex) return None + node = related[node_hex] + # ── DISTANCE INVARIANCE OF RELATION GATES (deep-gate finding L4-01, P1) ────────────── + # + # Every gate the direct-edge arm of verify_relationship_edges applies to the receipt's + # OWN edge must also apply to every ancestor edge whose target is ATTACHED. Before this, + # the walk adjudicated ancestor SYNTAX (malformed_ancestor below) and skipped ancestor + # CRYPTO entirely: a cryptographically forged receipt placed at hop >= 2 yielded + # lineage=VERIFIED and safeForAutomation=true. The gate was distance-scoped, and the + # distance is attacker-chosen — the presenter simply inserts one self-signed hop. + # + # Measured by the gate on d3401a7 in Python AND in the Rust verifier. No existing chain + # test caught it because every one of them hardcodes "verified": True on every ancestor, + # so the property was untested in both languages. + # + # Three gates, mirroring lines 279-306 one to one: + if not isinstance(node, dict): + return ("relation:ancestor_attached_target_malformed: an ATTACHED ancestor is not a " + "well-formed target object (present-and-wrong is a hard FAIL at any hop)") + if node.get("verified") is not True: + return ("relation:ancestor_verification_failed: an ATTACHED ancestor does not verify " + "standalone (present-and-wrong is a hard FAIL at any hop, exactly as for the " + "receipt's own edge)") nested = node.get("relationships") if nested is None: proven_safe.add(node_hex) @@ -358,10 +382,30 @@ def _dfs(node_hex: str, depth: int, path: set) -> str | None: if validate_relationships(nested): return "relation:malformed_ancestor: attached target carries a malformed relationships block" path = path | {node_hex} + # A CYCLE IS ORDER-INDEPENDENT, so it is decided before any descent. + # + # The loop below returns on the FIRST error it meets while walking the sibling edges in + # list order. Once the ancestor gates above exist, an unverified sibling listed BEFORE a + # back-edge would mask the cycle — the run would still FAIL, but with a code that depends + # on the order the issuer happened to write the edges in. That is the same + # "verdict depends on position" shape as the finding this fix answers, one level down. + # Detecting the back-edge first makes the cycle code independent of sibling order. + for edge in nested: + nxt = _edge_target_hex(edge) + if nxt is not None and nxt in path: + return "relation:cycle: attached chain revisits a receipt on its own ancestry path" for edge in nested: nxt = _edge_target_hex(edge) if nxt is None: continue + # The subject pin binds at EVERY hop, not only on the receipt's own edge: a declared + # targetSubjectDigest against an absent/ambiguous/malformed/unequal actual subject is + # the same false-accept one hop further out. The wire code is preserved verbatim so + # Python/Rust parity vectors keep their Sollwert; only the position is named. + if nxt in related and isinstance(related[nxt], dict): + _pin = _target_subject_pin_error(edge, related[nxt]) + if _pin is not None: + return f"relation:ancestor_edge: {_pin}" # Traverse attached targets; an edge back onto the ancestry path (even to a # node that is not itself attached, e.g. the receipt under verification) is # a cycle and must be caught, so path members are always followed. diff --git a/tests/test_relation_gate_distance_invariance.py b/tests/test_relation_gate_distance_invariance.py new file mode 100644 index 00000000..c9a26ab1 --- /dev/null +++ b/tests/test_relation_gate_distance_invariance.py @@ -0,0 +1,163 @@ +"""Ein Relations-Gate darf nicht davon abhaengen, WIE WEIT weg der Defekt sitzt. + +DIE KLASSE (deep gate wf_1c023644-953, Fund L4-01, P1, jury-bestaetigt): `verify_relationship_edges` +prueft am EIGENEN Rand des Belegs, ob das Ziel standalone verifiziert und ob der erklaerte +`targetSubjectDigest` bindet. `_walk_chain` tat davon nichts — es adjudizierte Vorfahren-SYNTAX +(`malformed_ancestor`) und uebersprang Vorfahren-KRYPTO vollstaendig. + +Folge, gemessen: ein kryptographisch GEFAELSCHTER Beleg im angehaengten Evidenz-Satz ergab +`lineage=VERIFIED` und `safeForAutomation=true`, sobald der Vorlegende einen selbst-signierten +Zwischenschritt einfuegte. Der Riegel war distanz-abhaengig, und die Distanz waehlt der Angreifer. + +WARUM KEIN BESTEHENDER TEST DAS FING: jeder Ketten-Test setzt `"verified": True` auf JEDEM +Vorfahren. Die Eigenschaft war in beiden Sprachen ungetestet — die Regression der Klasse blieb +gruen, weil sie den Fall nie erzeugte. + +Diese Datei prueft die Eigenschaft, nicht den Einzelfall: fuer jede Defektklasse und jede Hop-Tiefe +muss dasselbe Verdikt herauskommen. Der Generator variiert die Tiefe, nichts wird gepinnt. +""" +from __future__ import annotations + +import unittest + +from proofbundle.relation import ( + LINEAGE_FAIL, + LINEAGE_VERIFIED, + verify_relationship_edges, +) + +MAX_HOP = 5 +SUBJECT = f"{0xABC:064x}" + + +def _hex(i: int) -> str: + return f"{i:064x}" + + +def _edge(target_hex: str, subject_pin: str | None = None) -> dict: + e = {"relation": "supersedes", + "targetReceiptDigest": {"digestAlgorithm": "jcs-sha256-v1", "digest": target_hex}} + if subject_pin is not None: + e["targetSubjectDigest"] = {"digestAlgorithm": "jcs-sha256-v1", "digest": subject_pin} + return e + + +def _kette(tiefe: int, defekt_bei: int | None, defekt: str | None) -> dict: + """Generator G(defect D, depth n): eine gerade Kette SUBJECT -> h(0) -> … -> h(tiefe-1). + + Alles ist byte-identisch bis auf den einen Knoten, an dem der Defekt sitzt — genau das macht + den Vergleich ueber die Hop-Distanz zu einer Aussage und nicht zu einem Zufall. + """ + related: dict[str, object] = {} + for i in range(tiefe): + weiter = _edge(_hex(i + 1)) if i + 1 < tiefe else None + node: object = {"verified": True, + "subject_digest": _hex(1000 + i), + "subject_digest_state": "present", + "relationships": [weiter] if weiter else None} + if i == defekt_bei: + if defekt == "forged_sig": + node["verified"] = False # type: ignore[index] + elif defekt == "attached_malformed": + node = "kein Zielobjekt" + elif defekt == "subject_absent": + node["subject_digest"] = None # type: ignore[index] + node["subject_digest_state"] = "absent" # type: ignore[index] + elif defekt == "subject_ambiguous": + node["subject_digest_state"] = "ambiguous" # type: ignore[index] + elif defekt == "subject_malformed": + node["subject_digest"] = "kein-hex" # type: ignore[index] + node["subject_digest_state"] = "malformed" # type: ignore[index] + related[_hex(i)] = node + # Der Subject-Pin wird auf der KANTE erklaert, die auf den defekten Knoten zeigt. + if defekt in ("subject_absent", "subject_ambiguous", "subject_malformed") and defekt_bei is not None: + if defekt_bei == 0: + pass # die Wurzelkante traegt den Pin, siehe _wurzelkante + else: + vor = related[_hex(defekt_bei - 1)] + if isinstance(vor, dict) and vor.get("relationships"): + vor["relationships"][0] = _edge(_hex(defekt_bei), subject_pin=_hex(1000 + defekt_bei)) + return related + + +def _wurzelkante(defekt: str | None, defekt_bei: int | None) -> dict: + pin = _hex(1000) if (defekt_bei == 0 and defekt and defekt.startswith("subject_")) else None + return _edge(_hex(0), subject_pin=pin) + + +DEFEKTE = ("forged_sig", "attached_malformed", "subject_absent", + "subject_ambiguous", "subject_malformed") + + +class DistanzInvarianz(unittest.TestCase): + + def test_jeder_defekt_ergibt_dasselbe_verdikt_an_jeder_hop_distanz(self): + """verdict(D at hop 1) == verdict(D at hop n) fuer alle n, ueber sonst gleiche Evidenz.""" + for defekt in DEFEKTE: + with self.subTest(defekt=defekt): + verdikte = {} + for hop in range(MAX_HOP): + related = _kette(MAX_HOP, hop, defekt) + res = verify_relationship_edges([_wurzelkante(defekt, hop)], related, + subject_hex=SUBJECT) + verdikte[hop] = res["lineage"] + self.assertEqual( + set(verdikte.values()), {LINEAGE_FAIL}, + f"Defekt {defekt!r} ergibt je nach Hop-Distanz ein anderes Verdikt: {verdikte}. " + "Genau das war L4-01 — die Distanz waehlt der Angreifer.") + + def test_ein_gefaelschter_vorfahre_wird_nie_VERIFIED(self): + """Die schaerfste Einzelaussage, in eigener Zeile: kein Hop macht eine Faelschung gueltig.""" + for hop in range(MAX_HOP): + with self.subTest(hop=hop + 1): + res = verify_relationship_edges([_wurzelkante(None, None)], + _kette(MAX_HOP, hop, "forged_sig"), + subject_hex=SUBJECT) + self.assertNotEqual( + res["lineage"], LINEAGE_VERIFIED, + f"ein gefaelschter Beleg an Hop {hop + 1} wurde VERIFIED — " + "safeForAutomation haenge daran") + + def test_negativkontrolle_eine_saubere_kette_bleibt_VERIFIED(self): + """Die Gegenrichtung, ohne die die Property wertlos waere: kein Ueberblocken. + + Ein Riegel, der alles ablehnt, haelt jede Invarianz — und taugt nichts. + """ + for tiefe in range(1, MAX_HOP + 1): + with self.subTest(tiefe=tiefe): + res = verify_relationship_edges([_wurzelkante(None, None)], + _kette(tiefe, None, None), subject_hex=SUBJECT) + self.assertEqual(res["lineage"], LINEAGE_VERIFIED, + f"saubere Kette der Tiefe {tiefe} wurde blockiert: {res['errors']}") + + def test_jeder_fehlschlag_traegt_einen_benannten_grund(self): + """FAIL ohne Code waere die naechste Luecke: niemand koennte sagen, WORAN es lag.""" + for defekt in DEFEKTE: + for hop in range(MAX_HOP): + with self.subTest(defekt=defekt, hop=hop + 1): + res = verify_relationship_edges([_wurzelkante(defekt, hop)], + _kette(MAX_HOP, hop, defekt), + subject_hex=SUBJECT) + self.assertTrue(res["errors"], + f"{defekt!r} an Hop {hop + 1}: FAIL ohne jeden Grund") + self.assertTrue( + any(e.startswith("relationships[") for e in res["errors"]), + f"{defekt!r} an Hop {hop + 1}: Grund ohne Kanten-Zuordnung: {res['errors']}") + + def test_ein_unerreichbarer_defekt_blockiert_nicht(self): + """Ein nicht angehaengter Vorfahr beendet den Pfad ehrlich — declared-only, kein FAIL. + + Diese Zeile trennt 'streng' von 'kaputt': die Eigenschaft gilt fuer ANGEHAENGTE Vorfahren, + nicht fuer alles jenseits des Horizonts. + """ + related = {_hex(0): {"verified": True, "subject_digest": _hex(1000), + "subject_digest_state": "present", + "relationships": [_edge(_hex(77))]}} # h(77) ist NICHT beigelegt + res = verify_relationship_edges([_edge(_hex(0))], related, subject_hex=SUBJECT) + self.assertEqual(res["lineage"], LINEAGE_VERIFIED, + f"ein Vorfahr jenseits des angehaengten Horizonts wurde als Fehler " + f"gewertet: {res['errors']}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_relation_property.py b/tests/test_relation_property.py index f1a281d0..2c9ad4c6 100644 --- a/tests/test_relation_property.py +++ b/tests/test_relation_property.py @@ -83,13 +83,35 @@ def test_injected_back_edge_onto_path_is_caught_or_unreachable(self, case, victi # Wurzel erreichbar ist, muss FAIL mit Zyklus-Code kommen. reachable = self._reachable_via_verified(edges, related, v) if reachable: + # FAIL bleibt PFLICHT — das ist die Sicherheitseigenschaft und sie wird nicht weicher. self.assertEqual(res["lineage"], LINEAGE_FAIL) - self.assertTrue(any("relation:cycle" in err for err in res["errors"])) + # DER CODE darf seit dem L4-01-Fix ein staerkerer sein. Der Walker meldet den ERSTEN + # Fehler in DFS-Reihenfolge; seit Vorfahren auch kryptographisch geprueft werden, kann + # ein unverifizierter Geschwister-Zweig den Pfad beenden, BEVOR die eingespritzte + # Rueck-Kante ueberhaupt erreicht wird. Der Lauf faellt dann aus einem staerkeren Grund. + # + # Verlangt wird deshalb ein BENANNTER Grund aus der geschlossenen Menge — nie "FAIL + # ohne Code", denn das waere die Luecke, die dieser Test bewacht. + codes = " ".join(res["errors"]) + self.assertTrue( + any(c in codes for c in ("relation:cycle", + "relation:ancestor_verification_failed", + "relation:ancestor_attached_target_malformed")), + f"FAIL ohne benannten Grund — weder Zyklus noch Vorfahren-Gate: {res['errors']}") def _reachable_via_verified(self, edges, related, target_hex): - # Erreichbarkeit entlang VERIFIZIERTER beigelegter Knoten (der Walker steigt in einen - # Knoten nur ein, wenn die Wurzel-Kante VERIFIED aufloest; danach folgt er allen - # beigelegten Kanten). + # Erreichbarkeit entlang VERIFIZIERTER beigelegter Knoten. + # + # KORRIGIERT 2026-08-08 (deep-gate L4-01): die Vorfassung stieg in die Wurzel nur bei + # VERIFIED ein und folgte "danach allen beigelegten Kanten" — ohne `verified` der + # DURCHLAUFENEN Knoten zu pruefen. Damit modellierte dieses Hilfsmittel exakt den Defekt, + # den das Gate gefunden hat: der Walker adjudizierte Vorfahren-Syntax und ueberging + # Vorfahren-Krypto, sodass ein gefaelschter Beleg ab Hop 2 als VERIFIED durchging. + # + # Der Walker bricht jetzt an einem unverifizierten Vorfahren ab (hard FAIL, wie am eigenen + # Rand). Ein Opferknoten HINTER einem solchen Vorfahren ist damit nicht mehr erreichbar, + # und das Verdikt bleibt FAIL — nur mit dem staerkeren Code statt mit dem Zyklus. Das + # Modell zieht hier nach; die Zusicherung des Tests bleibt unveraendert scharf. seen = set() stack = [] for e in edges: @@ -105,7 +127,10 @@ def _reachable_via_verified(self, edges, related, target_hex): return True for e2 in (related.get(n, {}).get("relationships") or []): t2 = e2["targetReceiptDigest"]["digest"] - if t2 in related: + # Der Walker steigt in einen ATTACHED Knoten nur ein, wenn er selbst verifiziert + # ist; ein unverifizierter beendet den Pfad mit FAIL, bevor ein dahinterliegender + # Zyklus ueberhaupt sichtbar wird. + if t2 in related and isinstance(related[t2], dict) and related[t2].get("verified") is True: stack.append(t2) return target_hex in seen diff --git a/tools/pb_verify_rs/src/main.rs b/tools/pb_verify_rs/src/main.rs index 225b1771..58bc0ec5 100644 --- a/tools/pb_verify_rs/src/main.rs +++ b/tools/pb_verify_rs/src/main.rs @@ -877,10 +877,31 @@ fn walk_chain( if proven_safe.contains(node_hex) { return None; } + // NOT attached -> the path ends honestly beyond the attached horizon (declared-only). let Some(node) = related.get(node_hex) else { proven_safe.insert(node_hex.to_string()); return None; }; + // ── DISTANCE INVARIANCE OF RELATION GATES (deep-gate finding L4-01, P1) ────────────── + // + // Mirrors the Python fix one to one. Every gate the direct-edge arm applies to the + // receipt's own edge must also apply to every ATTACHED ancestor. Before this, the walk + // adjudicated ancestor SYNTAX (malformed_ancestor below) and skipped ancestor CRYPTO: + // a forged receipt placed at hop >= 2 yielded lineage=VERIFIED and safeForAutomation=true, + // and the distance is attacker-chosen. Measured in BOTH languages on d3401a7; untested in + // both because every chain test hardcodes verified=true on every ancestor. + // + // ASYMMETRY vs Python, stated rather than silently absent: TargetInfo is a typed struct, + // so the "attached target is not a well-formed object" case cannot arise here — the + // loader rejects it earlier. Python needs that third gate, Rust does not. + if !node.verified { + return Some( + "relation:ancestor_verification_failed: an ATTACHED ancestor does not verify \ + standalone (present-and-wrong is a hard FAIL at any hop, exactly as for the \ + receipt's own edge)" + .into(), + ); + } let Some(nested) = &node.relationships else { proven_safe.insert(node_hex.to_string()); return None; @@ -891,8 +912,41 @@ fn walk_chain( let mut next_path = path.clone(); next_path.insert(node_hex.to_string()); if let Some(arr) = nested.as_array() { + // A CYCLE IS ORDER-INDEPENDENT, so it is decided before any descent: once the ancestor + // gates above exist, an unverified sibling listed BEFORE a back-edge would otherwise + // mask the cycle, making the reported code depend on the order the issuer wrote the + // edges in — the same "verdict depends on position" shape, one level down. for edge in arr { if let Some(nxt) = edge_target_hex(edge) { + if next_path.contains(&nxt) { + return Some( + "relation:cycle: attached chain revisits a receipt on its own ancestry path" + .into(), + ); + } + } + } + for edge in arr { + if let Some(nxt) = edge_target_hex(edge) { + // The subject pin binds at EVERY hop, not only on the receipt's own edge. + // Same accept path as the direct arm: no declared pin -> optional; declared -> + // the resolved target must expose a present, EQUAL actual subject. + if let Some(anc) = related.get(&nxt) { + if let Some(d) = edge_subject_hex(edge) { + match &anc.subject_digest { + Some(a) if &d == a => {} + _ => { + return Some( + "relation:ancestor_edge: relation:target_subject_mismatch \ + (RELATION_TARGET_SUBJECT_MISMATCH): a declared \ + targetSubjectDigest on an ancestor edge does not bind a \ + present, equal subject on the resolved target" + .into(), + ); + } + } + } + } if related.contains_key(&nxt) || next_path.contains(&nxt) { if let Some(err) = dfs(&nxt, depth + 1, &next_path, related, proven_safe, max_depth) From 4bb09c0209c4bff8d0bbbddd7aa8d12e0e867559 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 09:55:18 +0200 Subject: [PATCH 15/28] fix(register): Identitaet wird auf derselben Achse entschieden wie Severity und Status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-gate Fund L5-01, P0, jury-bestaetigt (wf_cfe249d0-ee8). _norm() — NFKC plus Entfernen der Kategorien Cc/Cf — lief in _resolve_current ueber 'severity' und 'status' und NICHT ueber 'id' und 'superseded_by'. Nachbar-Felder in derselben Funktion, ungleich behandelt. Der Angriff, gemessen gegen die Vor-Fix-Fassung aus git: ein offener P0 mit superseded_by = "PB-X" plus ein geschlossener Koeder mit id = "PB-X". Roh sind die Zeichenketten verschieden, der Verweis liest sich als legitime Supersession auf einen vorhandenen ANDEREN Eintrag, der P0 faellt aus der Zaehlung, und das signierte Register meldet 0 offene P0/P1. Fuer einen menschlichen Pruefer sehen beide Kennungen gleich aus. ALLE SECHS geprueften unsichtbaren Zeichen kamen durch (U+200B U+200C U+200D U+FEFF U+00AD U+2060); nach dem Fix wird jedes gefangen. KEINE SPERRLISTE. Der Fund sagt es ausdruecklich: eine Liste verbotener Zeichen ist die Bauart, die im Nachbarbefund L5-02 versagt hat. Der Kennungsraum wird EINMAL beim Eintritt normalisiert; 'id', 'superseded_by' und die Praesenzmenge laufen durch dasselbe _norm() wie alles andere, was diese Funktion adjudiziert. Eine normalisierte Kennungs-Kollision ist fail-closed, und der Kanal haengt an den Status, damit keiner der beiden typisierten Gruende zur Dekoration wird: verschiedene Status sind ein WIDERSPRUCH (der bestehende Kanal behaelt seine Bedeutung), gleiche eine ANOMALIE. Zusaetzlich: eine Kennung, die zu nichts normalisiert, ist selbst eine Anomalie — sonst kollabierten mehrere still auf denselben leeren Schluessel. KEIN UEBERBLOCKEN, gemessen: das echte Register traegt 17 Findings, deren Kennungen roh UND normalisiert eindeutig sind und sich beim Normalisieren nicht aendern; C12.2 meldet weiter PASS mit 17 ausgewerteten. Neue Regression tests/test_findings_register_identity_axis.py: 8 Tests, 24 Subtests, ueber einen Korpus statt ueber ein Beispiel, mit dem vom Fund verlangten META-Test auf der Severity-Achse (eine Suite, die nur die Kennungs-Achse faengt, hat die Klasse neu aufgezaehlt statt sie zu schliessen) und zwei Gegenrichtungen. Gegen die Vor-Fix-Fassung aus git schlagen 13 Tests fehl, gegen die neue keiner. Eine eigene Schwaeche beim Bauen gefunden und entfernt: die erste Fassung fuehrte ein Kollisions-Set, das befuellt und nie gelesen wurde — eine Variable, die wie ein Riegel aussieht und keiner ist. Das ist die Form des Nachbarbefunds L1-03, und sie faellt bei einem Fix gegen genau diese Klasse doppelt auf. Volle Suite 2020 Tests OK, ruff sauber. NICHT GEMESSEN: der Rust-Verifier kennt diesen Pfad nicht; das Register ist ein Python-seitiges Gate-Artefakt. Die uebrigen Achsen des Fund-Korpus (ASCII-Homoglyphen wie kyrillisches 'a') sind NICHT abgedeckt — NFKC vereinheitlicht sie nicht, und eine Homoglyphen-Tabelle waere wieder die Sperrlisten-Form. Als offener Rest benannt statt still gelassen. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/findings_register.py | 80 ++++++++-- tests/test_findings_register_identity_axis.py | 148 ++++++++++++++++++ 2 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 tests/test_findings_register_identity_axis.py diff --git a/scripts/findings_register.py b/scripts/findings_register.py index cc2b3f23..b1ce3683 100644 --- a/scripts/findings_register.py +++ b/scripts/findings_register.py @@ -91,20 +91,66 @@ def _resolve_current(findings: list) -> tuple[dict, list, list, set]: (a fail-open the adversarial deep-gate reproduced). Now a finding is legitimately superseded ONLY by a PRESENT, DIFFERENT id; a dangling/self supersession, a non-string/empty id, or a non-dict entry is an ANOMALY that is NEVER dropped (the caller fails closed on any anomaly), so no finding can vanish from the count.""" - ids_present = {f["id"] for f in findings - if isinstance(f, dict) and isinstance(f.get("id"), str) and f.get("id")} + # ── THE ID SPACE IS NORMALISED ONCE, HERE (deep-gate finding L5-01, P0) ────────────────── + # + # _norm() (NFKC + Cc/Cf strip) ran over `severity` and `status` and NOT over `id` and + # `superseded_by` — neighbouring fields in this very function, treated unequally. A validly + # SIGNED register could therefore hide an open P0: give the P0 `superseded_by = "X​"` and + # add a closed decoy with `id = "X​"`. Raw, the two strings differ, so the link looks like a + # legitimate supersession to a present, DIFFERENT id and the P0 drops out of the count. To a + # human reviewer both render as "X". The gate returned PASS. + # + # The fix is not to reject U+200B. A blocklist of invisible characters is the shape that failed + # in the neighbouring finding L5-02: it can only name what someone already thought of. Identity + # is decided on the SAME axis as everything else this function adjudicates. + # + # Normalised-id COLLISION is fail-closed, and which channel it takes depends on the statuses, so + # that neither typed reason becomes decoration: differing statuses are a CONTRADICTION (the + # existing channel keeps its meaning), identical ones an ANOMALY. Measured on the live register + # 2026-08-08: 17 findings, ids unique raw AND normalised, none altered by normalisation — the + # tightening blocks nothing that exists. + _nid: dict[int, str] = {} # index -> normalised id + _raw_by_nid: dict[str, list[str]] = {} + for idx, f in enumerate(findings): + if isinstance(f, dict) and isinstance(f.get("id"), str) and f.get("id"): + n = _norm(f["id"]) + _nid[idx] = n + _raw_by_nid.setdefault(n, []).append(f["id"]) + ids_present = set(_raw_by_nid) effective: dict[str, dict] = {} contradictions: list[str] = [] anomalies: list[str] = [] legit_superseded: set[str] = set() sby_map: dict[str, str] = {} + # KEINE Sammel-Menge fuer die Kollisionen. Die erste Fassung fuehrte hier ein `_kollision`-Set, + # das befuellt und nie gelesen wurde — eine Variable, die wie ein Riegel aussieht und keiner ist. + # Das ist die Form des Nachbarbefunds L1-03 ("die Klasse ist per Konstruktion geschlossen"), und + # sie faellt bei einem Fix gegen genau diese Klasse doppelt auf. Die Kollision wirkt ueber die + # beiden typisierten Kanaele darunter, und beide sind beim Aufrufer fail-closed. + for n, rohe in _raw_by_nid.items(): + if len(rohe) < 2: + continue + stati = {(_norm(str(f.get("status", ""))).lower()) + for i, f in enumerate(findings) + if isinstance(f, dict) and _nid.get(i) == n} + if len(stati) > 1: + contradictions.append(n) + else: + anomalies.append(f"{n}:normalised-id-collision={sorted(set(rohe))!r}") for idx, f in enumerate(findings): if not isinstance(f, dict): anomalies.append(f"index{idx}:non-dict-entry") continue - fid = f.get("id") - if not isinstance(fid, str) or not fid: - anomalies.append(f"index{idx}:bad-id={fid!r}") + fid_raw = f.get("id") + if not isinstance(fid_raw, str) or not fid_raw: + anomalies.append(f"index{idx}:bad-id={fid_raw!r}") + continue + # Ab hier IMMER die normalisierte Kennung. Ein leerer Rest nach der Normalisierung (eine + # Kennung, die NUR aus unsichtbaren Zeichen besteht) ist selbst eine Anomalie — sonst + # kollabierten mehrere solcher Kennungen still auf denselben leeren Schluessel. + fid = _nid[idx] + if not fid: + anomalies.append(f"index{idx}:id-normalises-to-empty={fid_raw!r}") continue # RT10-REG severity/status TYPE-confusion fail-open (6-lens gate): a non-string severity (e.g. the # LIST ["P0"]) or status would slip past the {P0,P1}/"closed" comparisons below and HIDE an open P0 @@ -120,10 +166,13 @@ def _resolve_current(findings: list) -> tuple[dict, list, list, set]: if _norm(f["severity"]).upper() not in _KNOWN_SEVERITIES: anomalies.append(f"{fid}:unknown-severity={f['severity']!r}") continue - sby = f.get("superseded_by") - if isinstance(sby, str) and sby: - if sby == fid or sby not in ids_present: - anomalies.append(f"{fid}:dangling-or-self-supersede={sby!r}") # do NOT drop, fail-closed + sby_raw = f.get("superseded_by") + if isinstance(sby_raw, str) and sby_raw: + # AUF DERSELBEN ACHSE vergleichen wie die Kennung: sonst ist "X" != "X" und ein + # Selbstverweis liest sich als Verweis auf einen anderen, vorhandenen Eintrag. + sby = _norm(sby_raw) + if not sby or sby == fid or sby not in ids_present: + anomalies.append(f"{fid}:dangling-or-self-supersede={sby_raw!r}") # do NOT drop, fail-closed else: legit_superseded.add(fid) sby_map[fid] = sby @@ -142,14 +191,17 @@ def _resolve_current(findings: list) -> tuple[dict, list, list, set]: break seen_chain.add(cur) cur = sby_map[cur] - for f in findings: + for idx, f in enumerate(findings): if not isinstance(f, dict): continue - fid = f.get("id") - if not isinstance(fid, str) or not fid or fid in legit_superseded: + fid = _nid.get(idx) # normalisiert, wie ueberall sonst in dieser Funktion + if not fid or fid in legit_superseded: continue - if fid in effective and effective[fid].get("status") != f.get("status"): - contradictions.append(fid) + if fid in effective and _norm(str(effective[fid].get("status", ""))).lower() \ + != _norm(str(f.get("status", ""))).lower(): + # Bereits oben ueber die Kollision erfasst; hier nicht doppelt melden. + if fid not in contradictions: + contradictions.append(fid) effective[fid] = f return effective, contradictions, anomalies, legit_superseded diff --git a/tests/test_findings_register_identity_axis.py b/tests/test_findings_register_identity_axis.py new file mode 100644 index 00000000..dd67809e --- /dev/null +++ b/tests/test_findings_register_identity_axis.py @@ -0,0 +1,148 @@ +"""Identität wird auf derselben Achse entschieden wie alles andere, was diese Funktion adjudiziert. + +DIE KLASSE (deep gate wf_cfe249d0-ee8, Fund L5-01, **P0**, jury-bestätigt): `_norm()` — NFKC plus +Entfernen der Kategorien Cc/Cf — lief in `_resolve_current` über `severity` und `status`, aber +**nicht** über `id` und `superseded_by`. Nachbar-Felder in derselben Funktion, ungleich behandelt. + +Der Angriff, gemessen gegen die Vor-Fix-Fassung aus git: ein offener P0 mit +`superseded_by = "PB-X"` plus ein geschlossener Köder mit `id = "PB-X"`. Roh +sind die beiden Zeichenketten verschieden, der Verweis liest sich als legitime Supersession auf einen +vorhandenen, ANDEREN Eintrag, der P0 fällt aus der Zählung — und das Gate meldet PASS. Für einen +menschlichen Prüfer sehen beide Kennungen gleich aus. Alle sechs geprüften unsichtbaren Zeichen +kamen durch. + +KEINE SPERRLISTE. Der Fund sagt es ausdrücklich: eine Liste verbotener Zeichen ist die Bauart, die im +Nachbarbefund L5-02 versagt hat — sie kann nur benennen, woran jemand schon gedacht hat. Geprüft wird +deshalb die Eigenschaft, nicht das Zeichen. + +DER META-TEST ist hier kein Beiwerk. Der Fund verlangt: nimmt man `_norm()` von der Severity, muss +DIESELBE Prüfung dort ebenfalls feuern — eine Suite, die nur die Kennungs-Achse fängt, hat die Klasse +neu aufgezählt statt sie zu schliessen. +""" +from __future__ import annotations + +import importlib.util +import unicodedata +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +_SPEC = importlib.util.spec_from_file_location("_fr", REPO / "scripts" / "findings_register.py") +fr = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(fr) + +# Korpus statt Sperrliste: unsichtbare Zeichen (Cf/Cc), ein weiches Trennzeichen und +# NFKC-zerlegbare Formen. Die Prüfung darf an KEINEM davon hängen. +UNSICHTBAR = ("​", "‌", "‍", "", "­", "⁠") +NFKC_PAARE = (("P0", "P0"), ("①", "1")) # Vollbreite / eingekreist -> NFKC + + +def _offene_p0(effective: dict) -> list[str]: + return [i for i, f in effective.items() + if fr._norm(str(f.get("severity", ""))).upper() in fr._GATING_SEVERITIES + and fr._norm(str(f.get("status", ""))).lower() != "closed"] + + +def _kommt_durch(findings: list) -> bool: + """True heisst: der offene P0 ist unsichtbar geworden — kein Fund, keine Anomalie, kein Widerspruch.""" + eff, contra, anom, _ = fr._resolve_current(findings) + return not anom and not contra and not _offene_p0(eff) + + +class IdentitaetAufDerselbenAchse(unittest.TestCase): + + def test_kein_unsichtbares_zeichen_versteckt_einen_offenen_p0(self): + """Der Angriff selbst, über den ganzen Korpus — nicht über ein Beispiel.""" + for c in UNSICHTBAR: + with self.subTest(zeichen=f"U+{ord(c):04X}"): + findings = [ + {"id": "PB-X", "severity": "P0", "status": "open", "superseded_by": f"PB-X{c}"}, + {"id": f"PB-X{c}", "severity": "P0", "status": "closed"}, + ] + self.assertFalse( + _kommt_durch(findings), + f"U+{ord(c):04X} versteckt einen offenen P0 hinter einer Schein-Supersession") + + def test_kollidierende_kennungen_sind_fail_closed(self): + """Zwei Kennungen, die normalisiert zusammenfallen, sind nie eine stille Supersession.""" + for c in UNSICHTBAR: + with self.subTest(zeichen=f"U+{ord(c):04X}"): + eff, contra, anom, _ = fr._resolve_current([ + {"id": "PB-Y", "severity": "P0", "status": "open"}, + {"id": f"PB-Y{c}", "severity": "P0", "status": "closed"}, + ]) + self.assertTrue(anom or contra, + "kollidierende Kennungen ohne Anomalie und ohne Widerspruch") + + def test_eine_kennung_nur_aus_unsichtbaren_zeichen_ist_eine_anomalie(self): + """Sonst kollabierten mehrere solcher Kennungen still auf denselben leeren Schluessel.""" + _, _, anom, _ = fr._resolve_current([ + {"id": "​‌", "severity": "P0", "status": "open"}, + {"id": "PB-Z", "severity": "P2", "status": "closed"}, + ]) + self.assertTrue(any("empty" in a for a in anom), f"leere Kennung nicht beanstandet: {anom}") + + def test_selbstverweis_ueber_ein_unsichtbares_zeichen(self): + """`superseded_by` zeigt normalisiert auf die eigene Kennung — ein Selbstverweis in Tarnung.""" + for c in UNSICHTBAR: + with self.subTest(zeichen=f"U+{ord(c):04X}"): + _, _, anom, sup = fr._resolve_current([ + {"id": "PB-S", "severity": "P0", "status": "open", "superseded_by": f"PB-S{c}"}, + ]) + self.assertTrue(anom, "getarnter Selbstverweis nicht beanstandet") + self.assertNotIn("PB-S", sup, "getarnter Selbstverweis liess den Fund fallen") + + def test_meta_die_severity_achse_traegt_dieselbe_pruefung(self): + """META-TEST, vom Fund verlangt: die Prüfung darf nicht NUR die Kennungs-Achse fangen. + + Nähme man `_norm()` von der Severity, müsste eine getarnte Severity dort genauso auffallen. + Geprüft wird das ohne Codeänderung, indem der Angriff auf die Severity-Achse gefahren wird: + eine Severity, die als P0 rendert, aber ein unsichtbares Zeichen trägt, darf NIE als + nicht-gatend durchgehen. + """ + for c in UNSICHTBAR: + with self.subTest(zeichen=f"U+{ord(c):04X}"): + eff, _, anom, _ = fr._resolve_current([ + {"id": "PB-M", "severity": f"P{c}0", "status": "open"}, + ]) + versteckt = not anom and not _offene_p0(eff) + self.assertFalse(versteckt, + f"getarnte Severity 'P{{U+{ord(c):04X}}}0' wurde nicht-gatend") + + def test_meta_nfkc_zerlegbare_formen_auf_beiden_achsen(self): + """Die zweite Hälfte des Korpus: nicht nur unsichtbar, auch aequivalent zerlegbar.""" + voll, schmal = NFKC_PAARE[0] + self.assertEqual(unicodedata.normalize("NFKC", voll), schmal, + "die Testannahme über NFKC stimmt nicht mehr — dann prüft der Fall nichts") + eff, _, anom, _ = fr._resolve_current([{"id": "PB-N", "severity": voll, "status": "open"}]) + self.assertTrue(anom or _offene_p0(eff), + "eine vollbreite Severity ging als nicht-gatend durch") + + def test_gegenrichtung_ein_sauberes_register_bleibt_unbeanstandet(self): + """Ohne diese Zeile wäre jede Verschärfung 'erfolgreich': ein Riegel, der alles ablehnt. + + Gemessen am 2026-08-08: das echte Register trägt 17 Findings, deren Kennungen roh UND + normalisiert eindeutig sind und sich beim Normalisieren nicht ändern. + """ + eff, contra, anom, sup = fr._resolve_current([ + {"id": "PB-1", "severity": "P0", "status": "closed"}, + {"id": "PB-2", "severity": "P1", "status": "open", "superseded_by": "PB-3"}, + {"id": "PB-3", "severity": "P1", "status": "closed"}, + ]) + self.assertEqual(anom, [], f"sauberes Register beanstandet: {anom}") + self.assertEqual(contra, [], f"sauberes Register als widerspruechlich gewertet: {contra}") + self.assertEqual(sup, {"PB-2"}, "die legitime Supersession wurde nicht erkannt") + self.assertEqual(_offene_p0(eff), [], "sauberes Register meldet offene P0/P1") + + def test_gegenrichtung_ein_echter_offener_p0_wird_weiterhin_gemeldet(self): + """Die andere Gegenrichtung: der Riegel darf den Normalfall nicht verschlucken.""" + eff, _, anom, _ = fr._resolve_current([ + {"id": "PB-OPEN", "severity": "P0", "status": "open"}, + {"id": "PB-OTHER", "severity": "P3", "status": "closed"}, + ]) + self.assertEqual(anom, [], f"unerwartete Anomalie: {anom}") + self.assertEqual(_offene_p0(eff), ["PB-OPEN"], "ein echter offener P0 wurde nicht gemeldet") + + +if __name__ == "__main__": + unittest.main() From 97c929a7e9348ba980c45612d6e5cca4db219fe5 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 10:45:09 +0200 Subject: [PATCH 16/28] fix(budget): direct-dict surfaces reach the structural budget (L2-01, P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loads_strict owns the input_bytes cap, but that cap is a FILE proxy: on the direct-dict path there are no bytes to measure, so it is inert. Every public surface that takes an already-parsed structure and then decodes or expands something proportional to its size was therefore unbounded. Measured on verify_evidence_pack against the pre-fix file, with a payload ONE unit over the string_len limit: 2.6 MB peak in 0.095 s before, 0.1 MB in 0.004 s after. (The first measurement used "A"*1000001, which is not valid base64 — the old path bailed for an unrelated reason and the number said nothing. Only valid base64 measures the amplification.) THE CLASS, not the instance. The finding is explicit that wiring a 6th, 7th, ... call site re-opens on the next added surface, so the population is DERIVED in tests/test_structural_budget_reachability.py and the property is REACHABILITY in the static call graph. I had measured the family three times and got three numbers (8/6, the finding's 10/8, 24/22 wide) — that is the symptom of an enumerated population. The most important finding of this round is against my own gate. After wiring anchors.verify_anchor, renewal.verify_sequence dropped off the list without being touched: renewal.py binds a LOCAL variable named verify_anchor, and my name-based call graph merged it with anchors.verify_anchor — FALSE COVERAGE. I had even defended the wide graph in a comment ("a too-narrow edge produces a false finding"). True, but the other error is the expensive one. The graph is now module-qualified and resolves conservatively: own module first, else only a repo-unique definition, never a locally bound name, and no edge at all when ambiguous. Six surfaces wired, each mapped to ITS OWN documented failure form (the precedent both covered members already set: anchors -> BundleFormatError, policy -> PolicyError): evidence_pack.verify_evidence_pack result dict over_budget b64decode of proof dsse._payload_bytes (2 members) BundleFormatError signatures[i].sig per entry persample.verify_sample_opening BundleFormatError whole proof list decoded, then capped anchors_chia.verify_offline_merkle result dict bytes.fromhex over unbounded key/value anchors.verify_anchor BundleFormatError canonicalRoot + proof base64 relation.verify_relationship_edges fail-closed result list walked before its own cap reports Three times the same shape: ONE dimension was bounded and the surface looked covered. dsse bounded the signature COUNT, not its SIZE. persample bounded the disclosure SEGMENT, not the container. anchors bounded the FIELD SET, not the value sizes. relation bounds the edge count, but only after walking the list, and `reason` is unbounded free text. renewal.verify_sequence is excluded WITH PROOF, not wired: it takes list[list[ArchiveTimeStamp]] — typed objects, not parsed JSON —, the walker would step over them and measure only list length, which renewal_ats_chain already bounds more sharply. Wiring it would have turned this gate green without protecting anything. The exclusion requires evidence: a test asserts the named budget dimension exists and is actually used in that module. 2099 tests pass, 7 skipped, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/proofbundle/anchors.py | 16 ++ src/proofbundle/anchors_chia.py | 18 ++ src/proofbundle/dsse.py | 17 ++ src/proofbundle/evidence_pack.py | 24 +- src/proofbundle/persample.py | 18 ++ src/proofbundle/relation.py | 21 ++ tests/test_structural_budget_reachability.py | 235 +++++++++++++++++++ 7 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 tests/test_structural_budget_reachability.py diff --git a/src/proofbundle/anchors.py b/src/proofbundle/anchors.py index 29c14e07..3b1c43af 100644 --- a/src/proofbundle/anchors.py +++ b/src/proofbundle/anchors.py @@ -195,6 +195,22 @@ def verify_anchor(anchor: dict, *, target_roots: dict, now: Optional[int] = None _ensure_builtin_types() if not isinstance(anchor, dict): raise BundleFormatError("each anchor must be a JSON object") + # Structural budget (deep gate wf_cfe249d0-ee8, finding L2-01, P1). A DIRECT-DICT surface — and a + # narrow-looking one: ``_ANCHOR_KEYS`` bounds which FIELDS may appear, which is why this read as + # already-guarded. It bounds the key set, not the value sizes. ``canonicalRoot`` and ``proof`` are + # base64-decoded below with no length cap of their own, so a single 100 MB ``proof`` string is expanded + # before any verifier is even chosen. + # + # The peer primitive in this same module (``receipt_canonical_root``) already applies this bound; the + # entry point that actually receives third-party input did not. Raising matches this function's + # convention: a malformed STRUCTURE raises BundleFormatError (see the guard above and ``_b64d``), + # while a failed verification is reported in the ``out`` dict. + from ._strict_json import enforce_structural_budget # noqa: PLC0415 - local import avoids an import cycle + from .errors import ProofBundleError # noqa: PLC0415 + try: + enforce_structural_budget(anchor) + except ProofBundleError as exc: + raise BundleFormatError(f"anchor exceeds the verification budget (fail-closed): {exc}") from exc unknown = set(anchor) - _ANCHOR_KEYS if unknown: raise BundleFormatError(f"anchor has unknown field(s) {sorted(unknown)}") diff --git a/src/proofbundle/anchors_chia.py b/src/proofbundle/anchors_chia.py index cc9b1fb9..1c4cb3e9 100644 --- a/src/proofbundle/anchors_chia.py +++ b/src/proofbundle/anchors_chia.py @@ -126,6 +126,24 @@ def verify_offline_merkle(proof_obj: dict, canonical_root: bytes) -> dict: # the function's own {ok, detail} contract — never a raw AttributeError on `.get`. if not isinstance(proof_obj, dict): return {"ok": False, "detail": "malformed chia-datalayer proof: expected a JSON object"} + # Structural budget (deep gate wf_cfe249d0-ee8, finding L2-01, P1). Reached through + # ``verify_chia_datalayer`` this is already bounded twice — _MAX_PROOF_BYTES on the raw bytes and + # loads_strict on the parse. But this function is a PUBLIC export and takes an already-parsed dict, so a + # direct caller gets neither: ``_hexatom`` below runs ``bytes.fromhex`` over an arbitrarily long ``key`` + # or ``value`` string, and _MAX_LAYERS bounds only the ascent, never the atoms. + # + # "It is covered on the path I had in mind" is precisely the shape this finding is about — the bound has + # to sit on the surface that accepts the structure, not on one of its callers. + # + # Reported in this module's own contract (docstring: fail-closed, "Never raises for an ordinary bad + # proof"), so the budget verdict is a result dict, not an exception. + from ._strict_json import enforce_structural_budget # noqa: PLC0415 - local import avoids a cycle + from .errors import ProofBundleError # noqa: PLC0415 + try: + enforce_structural_budget(proof_obj) + except ProofBundleError as exc: + return {"ok": False, + "detail": f"chia-datalayer proof exceeds the verification budget (fail-closed): {exc}"} try: key_clvm = _hexbytes(proof_obj.get("key_clvm_hash"), "key_clvm_hash") value_clvm = _hexbytes(proof_obj.get("value_clvm_hash"), "value_clvm_hash") diff --git a/src/proofbundle/dsse.py b/src/proofbundle/dsse.py index 6f5739f7..5f1c795b 100644 --- a/src/proofbundle/dsse.py +++ b/src/proofbundle/dsse.py @@ -66,6 +66,23 @@ def sign_envelope(body: bytes, signer, *, payload_type: str, keyid: Optional[str def _payload_bytes(envelope: dict) -> bytes: if not isinstance(envelope, dict): raise BundleFormatError("DSSE envelope must be a JSON object") + # Structural budget (deep gate wf_cfe249d0-ee8, finding L2-01, P1). This module already bounded TWO + # dimensions — the base64 payload against input_bytes below, and the signatures COUNT before the verify + # loop — which is exactly why the gap was easy to miss: the surface looked bounded. It was not. The + # remaining dimensions were inert on this DIRECT-DICT path, and `signatures[i].sig` in particular is an + # unbounded attacker-controlled string that reaches `_b64decode_any` in the loop, once per entry up to + # the signatures cap. A COUNT bound and a SIZE bound are different bounds; having one is not having both. + # + # The check sits here rather than in verify_envelope so `load_payload` — the other member of the family + # — is covered by the same statement instead of by a second call site that can drift out of step. + from ._strict_json import enforce_structural_budget # noqa: PLC0415 - local import avoids a cycle + from .budget import BudgetExceeded # noqa: PLC0415 + try: + enforce_structural_budget(envelope) + except BudgetExceeded as exc: + # Same mapping this module already applies twice: the docstrings of the public surfaces name only + # BundleFormatError, so a direct third-party caller never sees a raw sibling exception. + raise BundleFormatError(f"DSSE envelope exceeds the verification budget (fail-closed): {exc}") from exc p = envelope.get("payload") if not isinstance(p, str): raise BundleFormatError("DSSE envelope.payload must be a base64 string") diff --git a/src/proofbundle/evidence_pack.py b/src/proofbundle/evidence_pack.py index 05c9833a..4aee907d 100644 --- a/src/proofbundle/evidence_pack.py +++ b/src/proofbundle/evidence_pack.py @@ -132,7 +132,29 @@ def verify_evidence_pack(pack: dict, *, rp_trust: Optional[dict] = None, happens only against a relying-party header (``rp_trust``), which may be an offline checkpoint. Returns the OTS verifier's result dict ({ok, detail, warn, status, …}). Fail-closed on a malformed - pack (missing/!b64 proof or root).""" + pack (missing/!b64 proof or root) and on an OVER-BUDGET pack (``status: over_budget``). + + Structural budget (deep gate wf_cfe249d0-ee8, finding L2-01, P1). This is a DIRECT-DICT surface: the + caller hands over an already-parsed ``dict``, so the ``input_bytes`` cap that ``loads_strict`` applies + on the str/file path is inert here — there are no bytes to measure. Without the compensating bound a + ~13 MB ``proof`` string reached ``base64.b64decode`` below and was expanded uncapped, and the + resulting ``MemoryError`` escaped RAW from a surface documented as fail-closed. The bound therefore + runs BEFORE the decode, not after. + + The typed budget error is mapped to THIS surface's own failure convention — a result dict — rather + than being re-raised. That follows the two covered members: ``anchors.receipt_canonical_root`` maps it + to ``BundleFormatError`` and ``policy`` maps it to ``PolicyError``, each to the failure form its own + contract documents. The finding's oracle is worded as "raises"; raising here would change the contract + of a public surface whose every other failure is a dict, so the invariant kept is the one both + precedents actually implement: the bound is applied before any size-proportional allocation, and the + outcome is typed and fail-closed instead of a raw exception.""" + from ._strict_json import enforce_structural_budget # noqa: PLC0415 - local import avoids an import cycle + from .errors import ProofBundleError # noqa: PLC0415 + try: + enforce_structural_budget(pack) + except ProofBundleError as exc: + return {"ok": False, "warn": False, "status": "over_budget", + "detail": f"evidence pack exceeds the verification budget (fail-closed): {exc}"} try: proof = base64.b64decode(pack["proof"], validate=True) canonical_root = base64.b64decode(pack["canonicalRoot"], validate=True) diff --git a/src/proofbundle/persample.py b/src/proofbundle/persample.py index 61ac695c..86027e60 100644 --- a/src/proofbundle/persample.py +++ b/src/proofbundle/persample.py @@ -181,6 +181,24 @@ def verify_sample_opening(opening: dict, root_b64: str, n: int) -> dict: result = {"ok": False, "record": None, "salt_b64": None, "detail": ""} if not isinstance(opening, dict): raise BundleFormatError("opening must be a JSON object") + # Structural budget (deep gate wf_cfe249d0-ee8, finding L2-01, P1). A DIRECT-DICT surface: the caller + # hands over a parsed ``opening``, so loads_strict's input_bytes cap never runs and every other bound + # below is inert against sheer size. Concretely, the proof list is base64-decoded IN FULL further down + # before any per-element cap fires — an ``proof_b64`` of a million long strings is decoded first and + # bounded afterwards, which is the wrong order. + # + # _b64url_decode already guards the DISCLOSURE segment (round 7), and that is exactly why this looked + # covered: one segment was bounded, the container around it was not. The bound therefore goes on the + # whole ``opening`` and it goes FIRST. + # + # Raising matches this function's own convention: a malformed STRUCTURE raises BundleFormatError here + # (see the two guards around this one), while a failed VERIFICATION returns ok=False with a detail. + # Over-budget is a structural refusal, not a verification outcome. + from ._strict_json import enforce_structural_budget # noqa: PLC0415 - local import avoids a cycle + try: + enforce_structural_budget(opening) + except ProofBundleError as exc: + raise BundleFormatError(f"opening exceeds the verification budget (fail-closed): {exc}") from exc index = opening.get("index") disclosure = opening.get("disclosure") proof_list = opening.get("proof_b64") diff --git a/src/proofbundle/relation.py b/src/proofbundle/relation.py index a6d4c3f1..aaf302c4 100644 --- a/src/proofbundle/relation.py +++ b/src/proofbundle/relation.py @@ -255,6 +255,27 @@ def verify_relationship_edges( if relationships is None: return {"lineage": LINEAGE_NOT_EVALUATED, "edges": [], "errors": []} + # Structural budget (deep gate wf_cfe249d0-ee8, finding L2-01, P1). A DIRECT-DICT surface — the caller + # hands over an already-parsed structure, so loads_strict's input_bytes cap never runs here. + # + # This module looked bounded and is the clearest case of why "a bound" is not "the bounds": + # MAX_EDGES_PER_RECEIPT caps the edge COUNT and _SHA256_HEX pins every digest to 64 chars — but the cap + # is reported by validate_relationships only AFTER it has walked the entire list, and ``reason`` is an + # unbounded free-text string on every edge. A ten-million-element list is therefore fully iterated + # before its own cap is reported, and 64 edges each carrying a 100 MB reason pass the count cap + # entirely. Size and count are different dimensions. + # + # The bound runs BEFORE validate_relationships for exactly that reason. It is reported as a fail-closed + # RESULT, never raised: this function's contract is "never raises on malformed input — fail-closed + # result instead", and a budget refusal is malformed input like any other. + from ._strict_json import enforce_structural_budget # noqa: PLC0415 - local import avoids an import cycle + try: + enforce_structural_budget(relationships) + except ProofBundleError as exc: + return {"lineage": LINEAGE_FAIL, "edges": [], + "errors": [f"relation:over_budget: relationships exceed the verification budget " + f"(fail-closed): {exc}"]} + structural = validate_relationships(relationships) if structural: return {"lineage": LINEAGE_FAIL, "edges": [], diff --git a/tests/test_structural_budget_reachability.py b/tests/test_structural_budget_reachability.py new file mode 100644 index 00000000..f35bee63 --- /dev/null +++ b/tests/test_structural_budget_reachability.py @@ -0,0 +1,235 @@ +"""Every public surface that takes an ALREADY-PARSED structure must reach the structural budget. + +THE CLASS (deep gate wf_cfe249d0-ee8, finding L2-01, P1). ``loads_strict`` owns the ``input_bytes`` cap, +but that cap is a FILE proxy: on the DIRECT-DICT path there are no bytes to measure, so it is inert. A +surface that accepts a parsed ``dict`` and then decodes, expands or copies something proportional to its +size is therefore unbounded — measured on ``verify_evidence_pack``, a payload ONE unit over the +``string_len`` limit still allocated 2.6 MB in 0.095 s before failing for an unrelated reason. + +WHY THIS FILE IS A DERIVATION AND NOT A LIST. The finding is explicit that wiring a 6th, 7th, ... call +site is the INSTANCE fix and re-opens on the next added surface. So the population is DERIVED here — from +the signatures in ``src/proofbundle`` — and the property is REACHABILITY in the static call graph, not +"the call appears in this function's own body". A surface that delegates to ``verify_bundle`` is covered +by that delegation, and demanding a literal call in every body would produce false findings that push +people to weaken the check. + +THE THIRD STATE IS NOT GREEN. A first parameter with no annotation makes membership UNDECIDABLE. Such a +surface is neither passed nor silently dropped: it is reported, because "we could not tell" has been the +shape of every fail-open in this ledger. It is listed explicitly so the list can only shrink by ANNOTATING +a surface, never by forgetting one. +""" +from __future__ import annotations + +import ast +import importlib +import inspect +import pathlib +import unittest + +QUELLE = pathlib.Path(__file__).resolve().parents[1] / "src" / "proofbundle" +ZIEL = "enforce_structural_budget" +_STRUKTUR = ("dict", "Mapping", "list", "Sequence", "Any") + +# Surfaces whose first parameter is unannotated. Each entry states WHY it is not a direct-dict surface. +# This is an explicit-exclusion list with a reason per entry, never a way to make a finding disappear. +_UNANNOTIERT_ERWARTET = { + "anchors.verify_anchors": "nimmt eine Liste von Anker-Absichten, keine geparste Fremdstruktur", + "evalcard.verify_evaluation_card": "nimmt einen PFAD — parst selbst ueber loads_strict", + "prereg.verify_prereg": "nimmt einen PFAD — parst selbst ueber loads_strict", +} + +# Flaechen, die eine EIGENE fachliche Schranke tragen statt der generischen. Der Wert ist die +# Budget-Dimension, und sie wird NACHGEWIESEN (siehe test_eine_eigene_schranke_wird_belegt) — ein +# Ausschluss ohne Beleg waere genau der Weg, auf dem eine ungeschuetzte Flaeche hier verschwindet. +_EIGENE_SCHRANKE = { + "renewal.verify_sequence": ( + "renewal_ats_chain", + "nimmt list[list[ArchiveTimeStamp]] — TYPISIERTE Objekte, kein rohes geparstes JSON. Die " + "Elemente sind per Shape-Guard ArchiveTimeStamp-Instanzen; enforce_structural_budget wuerde " + "ueber sie hinweglaufen (weder str noch dict noch list) und nur die Listenlaenge messen, die " + "renewal_ats_chain bereits fachlich und schaerfer begrenzt. Der Einbau haette diesen Riegel " + "gruen gemacht, ohne irgendetwas zu schuetzen."), +} + + +def _lokal_gebunden(fn: ast.AST) -> set[str]: + """Namen, die INNERHALB dieser Funktion an etwas gebunden werden (Zuweisung, with-as, Parameter, ...). + + Ein so gebundener Name ist NICHT die gleichnamige Funktion eines anderen Moduls. + """ + gebunden: set[str] = set() + for arg in getattr(getattr(fn, "args", None), "args", []) or []: + gebunden.add(arg.arg) + for x in ast.walk(fn): + if isinstance(x, ast.Name) and isinstance(x.ctx, ast.Store): + gebunden.add(x.id) + elif isinstance(x, ast.AnnAssign) and isinstance(x.target, ast.Name): + gebunden.add(x.target.id) + elif isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef)) and x is not fn: + gebunden.add(x.name) + return gebunden + + +def _aufrufgraph() -> dict[str, set[str]]: + """(modul.funktion) -> aufgerufene (modul.funktion). MODULQUALIFIZIERT, und das ist der Punkt. + + Die erste Fassung war namensbasiert und "bewusst weit". Weit war der falsche Fehler: eine unqualifizierte + Kante erzeugt keine Fehlmeldung, sondern FALSCHE DECKUNG. Live gemessen am 2026-08-08 — ``renewal.py`` + bindet eine LOKALE Variable ``verify_anchor`` (Z. 547/550/553) an einen Callable-Parameter; sobald + ``anchors.verify_anchor`` den Riegel bekam, galt ``renewal.verify_sequence`` schlagartig als gedeckt, + ohne dass sich an ihr irgendetwas geaendert hatte. Der Riegel gegen fake-green war selbst fake-green. + + Aufloesung, konservativ in die richtige Richtung: erst im EIGENEN Modul, sonst nur bei einer repo-weit + EINDEUTIGEN Definition, und niemals fuer einen Namen, der in der Funktion lokal gebunden wird. Bleibt + ein Aufruf unaufloesbar, entsteht KEINE Kante — dann meldet der Riegel im Zweifel einen Fund statt eine + Deckung. Ein Fehlbefund kostet eine Minute Nachsehen; eine falsche Deckung kostet den Riegel. + """ + roh: dict[str, tuple[str, set[str], set[str]]] = {} + definiert_in: dict[str, list[str]] = {} + for p in sorted(QUELLE.glob("*.py")): + for node in ast.parse(p.read_text(encoding="utf-8")).body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + definiert_in.setdefault(node.name, []).append(p.stem) + namen = set() + for x in ast.walk(node): + if isinstance(x, ast.Call): + if isinstance(x.func, ast.Name): + namen.add(x.func.id) + elif isinstance(x.func, ast.Attribute): + namen.add(x.func.attr) + roh[f"{p.stem}.{node.name}"] = (p.stem, namen, _lokal_gebunden(node)) + + graph: dict[str, set[str]] = {} + for schluessel, (modul, namen, lokal) in roh.items(): + kanten = set() + for n in namen: + if n in lokal: + continue # lokal gebunden -> nicht die fremde Funktion + if f"{modul}.{n}" in roh: + kanten.add(f"{modul}.{n}") # eigenes Modul gewinnt + else: + orte = definiert_in.get(n, []) + if len(orte) == 1: + kanten.add(f"{orte[0]}.{n}") # repo-weit eindeutig + # mehrdeutig -> keine Kante (lieber ein Fund zu viel als eine Deckung zu viel) + graph[schluessel] = kanten + return graph + + +def _erreicht(graph: dict[str, set[str]], start: str, ziel: str = ZIEL, tiefe: int = 12) -> bool: + """Der Graph ist modulqualifiziert (``modul.funktion``); das Ziel wird an seinem NAMENSTEIL erkannt, + weil ``enforce_structural_budget`` ueber lokale Importe aus mehreren Modulen erreicht wird.""" + gesehen, rand = set(), {start} + for _ in range(tiefe): + neu: set[str] = set() + for n in rand: + if n in gesehen: + continue + gesehen.add(n) + for m in graph.get(n, ()): + if m.rsplit(".", 1)[-1] == ziel: + return True + neu.add(m) + rand = neu - gesehen + if not rand: + break + return False + + +def _population() -> tuple[dict[str, bool], dict[str, str]]: + graph = _aufrufgraph() + familie: dict[str, bool] = {} + unentscheidbar: dict[str, str] = {} + for p in sorted(QUELLE.glob("*.py")): + if p.name.startswith("_"): + continue + try: + mod = importlib.import_module(f"proofbundle.{p.stem}") + except Exception: # optionales Extra fehlt + continue + for n, f in vars(mod).items(): + if not n.startswith(("verify_", "recompute_")) or not callable(f): + continue + if getattr(f, "__module__", "") != mod.__name__: + continue + try: + ps = list(inspect.signature(f).parameters.values()) + except (TypeError, ValueError): + continue + if not ps: + continue + ann = str(ps[0].annotation) + schluessel = f"{p.stem}.{n}" + if "_empty" in ann: + unentscheidbar[schluessel] = ps[0].name + elif any(t in ann for t in _STRUKTUR): + familie[schluessel] = _erreicht(graph, schluessel) + return familie, unentscheidbar + + +class StrukturBudgetErreichbarkeit(unittest.TestCase): + + def test_die_population_ist_nicht_leer(self): + """Ohne das koennte jede Verschaerfung durch eine leere Menge 'bestehen'.""" + familie, _ = _population() + self.assertGreaterEqual(len(familie), 15, + f"die Ableitung findet nur {len(familie)} Flaechen — sie misst nicht mehr, " + "was sie zu messen behauptet") + + def test_jede_direct_dict_flaeche_erreicht_die_schranke(self): + familie, _ = _population() + offen = sorted(k for k, v in familie.items() if not v and k not in _EIGENE_SCHRANKE) + self.assertEqual( + offen, [], + "diese Flaechen nehmen eine geparste Struktur entgegen und erreichen " + f"{ZIEL} in ihrem Aufrufgraphen NICHT — auf ihnen ist die input_bytes-Schranke inert:\n " + + "\n ".join(offen)) + + def test_unentscheidbare_flaechen_sind_benannt_statt_uebergangen(self): + """DER DRITTE ZUSTAND. Nicht entscheidbar ist keine Freigabe. + + Waechst die Menge, ist eine neue Flaeche ohne Annotation dazugekommen und niemand kann sagen, ob + sie zur Familie gehoert — das faellt hier auf, statt still zu passieren. + """ + _, unentscheidbar = _population() + neu = sorted(set(unentscheidbar) - set(_UNANNOTIERT_ERWARTET)) + self.assertEqual(neu, [], + "neue Flaeche mit unannotiertem ersten Parameter — annotieren oder mit " + f"Begruendung in _UNANNOTIERT_ERWARTET aufnehmen: {neu}") + + def test_eine_eigene_schranke_wird_belegt_statt_behauptet(self): + """Ein Ausschluss gilt nur gegen Beleg — sonst waere _EIGENE_SCHRANKE die Tuer, durch die eine + ungeschuetzte Flaeche verschwindet. Geprueft wird EFFEKTBASIERT: die genannte Budget-Dimension + existiert wirklich und wird im Modul der Flaeche wirklich benutzt.""" + from proofbundle.budget import DEFAULT_BUDGET + for schluessel, (dimension, _grund) in _EIGENE_SCHRANKE.items(): + modul = schluessel.split(".", 1)[0] + with self.subTest(flaeche=schluessel): + self.assertTrue(hasattr(DEFAULT_BUDGET, dimension), + f"Budget-Dimension {dimension!r} existiert nicht") + quelle = (QUELLE / f"{modul}.py").read_text(encoding="utf-8") + self.assertIn(dimension, quelle, + f"{modul}.py benutzt {dimension!r} nicht — der Ausschluss ist unbelegt") + + def test_ein_ausschluss_ohne_eintrag_verschwindet_nicht(self): + """Die Gegenrichtung dazu: was NICHT in _EIGENE_SCHRANKE steht, muss den Riegel erreichen.""" + familie, _ = _population() + ausgeschlossen = set(_EIGENE_SCHRANKE) + self.assertTrue(ausgeschlossen <= set(familie), + f"_EIGENE_SCHRANKE nennt Flaechen, die es nicht (mehr) gibt: " + f"{sorted(ausgeschlossen - set(familie))}") + + def test_gegenrichtung_der_erreichbarkeits_test_kann_rot_werden(self): + """Anti-Tautologie: ein Graph ohne die Zielkante MUSS als nicht-erreichbar gelten. + + Ohne diese Zeile waere ein immer-True-Erreichbarkeitstest von einem echten nicht zu unterscheiden. + """ + self.assertFalse(_erreicht({"m.a": {"m.b"}, "m.b": {"m.c"}}, "m.a"), + "Erreichbarkeit meldet ein Ziel, das im Graphen nicht vorkommt") + self.assertTrue(_erreicht({"m.a": {"m.b"}, "m.b": {f"x.{ZIEL}"}}, "m.a"), + "Erreichbarkeit findet das Ziel ueber zwei Kanten nicht") + + +if __name__ == "__main__": + unittest.main() From 264fcd32411938463cac55034ed3158d2cbb7c4e Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 10:56:06 +0200 Subject: [PATCH 17/28] fix(pre-tag): the audit verdict comes from a record, not from prose (L5-02, P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate granted PASS from a discipline MARKER minus a NEGATION blocklist. That shape cannot terminate against natural language (Ranum; CWE-183 inverted), and measured against the old file it does not even come close: 32 phrasings of "the audit did not happen" -> 20 passed --strict 6 unrelated mentions of the vocabulary -> 3 passed --strict "the adversarial audit was dropped for this release" passed. So did "refactored the adversarial fixture loader into its own module". Every fix of that shape is one more word in the list. This is the SAME shape as L5-01 one file over — there a blocklist of invisible characters, here a blocklist of negation words. Both are replaced by establishing the property instead of forbidding the form. THE INVERSION, as the finding requires: one canonical attesting line, matched as a WHOLE line, carrying the version it attests: pre-tag-adversarial-audit: RUN | version=3.7.0 A negation cannot live inside a closed full-line form, so no vocabulary has to be enumerated. Two things change: * PROSE CANNOT MOVE THE VERDICT IN EITHER DIRECTION. The CHANGELOG is presentational; it neither grants a pass nor withholds one. Measured first: for 3.7.0 the CHANGELOG already granted nothing (changelog_records_audit was False), so removing that path blocks nothing real. * THE RECORD MUST SAY WHICH VERSION IT ATTESTS. Until now any marker-carrying file under audit_artifacts// granted the pass, so a record copied over from an earlier release attested the new one by sitting in the right folder. audit_records_for stays marker-based for its existing consumers (the candidate matrix, the C12.2 scan); only the new attesting_records_for feeds the verdict. A MISSING verdict now names the marker-carrying record that failed to attest — that is the likeliest cause of a surprising refusal, and staying mute about it is how a correct gate gets called broken and then loosened. Two existing tests encoded the old behaviour and are updated, not deleted: test_positive_marker_still_passes asserted that PROSE grants a pass — exactly the defect — so its INTENT (the gate must not blanket-reject) is kept and its input becomes a real attestation, plus a new counterpart asserting that the same prose alone no longer passes. HONEST LIMIT, in the gate's own comment too: this is provenance-SHAPED, not provenance. The finding's end state is a runner-signed record whose subject digest equals the artifact being tagged; this repo has no signing path for that yet. What is closed is that prose no longer decides. MIGRATION, declared: I added the canonical line to the existing 3.6.0 and 3.7.0 records. Both already state in prose that the audit ran; the line transcribes that. It is a transcription by me, not a new audit. 2109 tests pass, 7 skipped, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../360/pre_tag_adversarial_audit_360.md | 3 + .../370/pre_tag_adversarial_audit_370.md | 3 + scripts/pre_tag_audit_gate.py | 73 +++++++- tests/test_pre_tag_audit_provenance.py | 174 ++++++++++++++++++ tests/test_roadmap_frontload_foundations.py | 27 ++- 5 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 tests/test_pre_tag_audit_provenance.py diff --git a/audit_artifacts/360/pre_tag_adversarial_audit_360.md b/audit_artifacts/360/pre_tag_adversarial_audit_360.md index 32f46e6c..40bb1297 100644 --- a/audit_artifacts/360/pre_tag_adversarial_audit_360.md +++ b/audit_artifacts/360/pre_tag_adversarial_audit_360.md @@ -1,5 +1,8 @@ # Pre-tag adversarial audit — proofbundle 3.6.0 audit-candidate +pre-tag-adversarial-audit: RUN | version=3.6.0 + + Internal six-lens / master-prompt-v2 adversarial audit run before the 3.6.0 tag (Front-Load §7 discipline). **This internal audit is explicitly NOT a substitute for the external human crypto / protocol audit** — it is the precondition the external reviewer starts from, not a replacement for diff --git a/audit_artifacts/370/pre_tag_adversarial_audit_370.md b/audit_artifacts/370/pre_tag_adversarial_audit_370.md index c79a514d..8d148b8a 100644 --- a/audit_artifacts/370/pre_tag_adversarial_audit_370.md +++ b/audit_artifacts/370/pre_tag_adversarial_audit_370.md @@ -1,5 +1,8 @@ # Pre-tag adversarial audit — proofbundle 3.7.0 audit-candidate +pre-tag-adversarial-audit: RUN | version=3.7.0 + + Internal six-lens / master-prompt adversarial audit run on the 3.7.0 release candidate (commit 02509ca3, version bump + changelog PR head) before any tag. **This internal audit is explicitly NOT a substitute for the external human crypto / protocol audit** — it is the diff --git a/scripts/pre_tag_audit_gate.py b/scripts/pre_tag_audit_gate.py index 3ecaf0bf..8facac0e 100644 --- a/scripts/pre_tag_audit_gate.py +++ b/scripts/pre_tag_audit_gate.py @@ -46,6 +46,53 @@ re.IGNORECASE) +# ── The verdict source: an ALLOWLIST of one exact attesting line (deep gate finding L5-02, P1) ────── +# +# The gate used to grant PASS from a discipline MARKER minus a NEGATION blocklist. That shape cannot +# terminate against natural language (Ranum; CWE-183 "permissive list of allowed inputs" inverted), and +# the gate proved it: a CHANGELOG line stating the audit had been DROPPED passed ``--strict``, because +# "dropped" was not among the ~30 enumerated negations. Every fix of that shape is one more word. +# +# So the polarity is inverted, exactly as the finding requires. There is ONE canonical attesting form, +# it is matched as a WHOLE line, and it carries the version it attests: +# +# pre-tag-adversarial-audit: RUN | version=3.7.0 +# +# A negation cannot live inside a closed full-line form, so no vocabulary has to be enumerated. And the +# embedded version closes a second hole the blocklist never touched: until now ANY marker-carrying file +# under ``audit_artifacts//`` granted the pass, so a record copied over from an earlier release +# attested the new one by sitting in the right folder. The record must now SAY which version it attests. +# +# HONEST LIMIT: this is provenance-SHAPED, not provenance. The finding's end state is a runner-signed +# record whose subject digest equals the artifact being tagged; that needs a signing path this repo does +# not have yet. What is closed here is that PROSE can no longer move the verdict — in either direction. +_ATTESTATION = re.compile( + r"(?mi)^[ \t]*pre-tag-adversarial-audit:[ \t]*RUN[ \t]*\|[ \t]*version=(?P[0-9]+\.[0-9]+\.[0-9]+[0-9A-Za-z.+-]*)[ \t]*$") + + +def attests_version(text: str, version: str) -> bool: + """True iff ``text`` carries the canonical attesting line for EXACTLY ``version``.""" + return any(m.group("v") == version for m in _ATTESTATION.finditer(text)) + + +def attesting_records_for(repo: Path, version: str) -> list[str]: + """Records under ``audit_artifacts//`` that ATTEST this exact version, deterministically ordered. + + Distinct from :func:`audit_records_for`, which stays marker-based for its existing consumers (the + audit-candidate matrix and C12.2 scan the full candidate list). Only THIS function feeds the verdict. + """ + scoped = repo / "audit_artifacts" / _version_token(version) + if not scoped.is_dir(): + return [] + out: list[str] = [] + for f in sorted(scoped.rglob("*.md")): + if not f.is_file(): + continue + if attests_version(f.read_text(encoding="utf-8", errors="ignore"), version): + out.append(str(f.relative_to(repo))) + return out + + def _positive_audit_marker(text: str) -> bool: """True iff some line ASSERTS an adversarial/N-lens audit was run — a discipline marker on a line that is NOT negated. Line-scoped so a real positive note survives an unrelated negation elsewhere in the file, @@ -134,19 +181,33 @@ def evaluate(repo: Path, version: str | None = None) -> dict: return {"ok": False, "version": None, "reason": "could not read the release version from pyproject.toml"} section = changelog_section(repo, version) - changelog_ok = bool(section and _positive_audit_marker(section)) # RT10-PRETAG-02 negation guard - artifact = audit_artifact_for(repo, version) - ok = changelog_ok or bool(artifact) + # PRESENTATIONAL ONLY (L5-02). Reported so a reader sees the state, but it can no longer move the + # verdict in EITHER direction — neither granting a PASS from a marker nor withholding one. That is + # the whole point: the attestation is the record's job, the CHANGELOG renders it. + changelog_ok = bool(section and _positive_audit_marker(section)) + attesting = attesting_records_for(repo, version) + artifact = attesting[0] if attesting else None + ok = bool(attesting) + # Kept for the operator: a record that carries the old discipline marker but NOT the canonical + # attestation is the likeliest reason for a surprising MISSING, so name it instead of staying mute. + marker_only = [r for r in audit_records_for(repo, version) if r not in attesting] return { "ok": ok, "version": version, "changelog_section_found": section is not None, "changelog_records_audit": changelog_ok, + "changelog_is_presentational": True, "audit_artifact": artifact, + "attesting_records": attesting, + "marker_only_records": marker_only, "reason": None if ok else ( - f"no adversarial/N-lens audit recorded for {version}: the CHANGELOG [{version}] section " - "carries no lens/adversarial note and no audit_artifacts file names it — run the pre-tag " - "adversarial audit (master-prompt-v2) and record it before tagging (Front-Load §7)"), + f"no attesting pre-tag audit record for {version}: no file under audit_artifacts/" + f"{_version_token(version)}/ carries the canonical line " + f"'pre-tag-adversarial-audit: RUN | version={version}'" + + (f" (found {len(marker_only)} record(s) with a discipline marker but no attestation: " + f"{marker_only})" if marker_only else "") + + ". The CHANGELOG text is presentational and cannot grant this — run the pre-tag " + "adversarial audit and record it before tagging (Front-Load §7)"), } diff --git a/tests/test_pre_tag_audit_provenance.py b/tests/test_pre_tag_audit_provenance.py new file mode 100644 index 00000000..7af88ac3 --- /dev/null +++ b/tests/test_pre_tag_audit_provenance.py @@ -0,0 +1,174 @@ +"""The pre-tag gate derives its verdict from a RECORD, never from prose. + +THE CLASS (deep gate wf_cfe249d0-ee8, finding L5-02, P1). The gate granted PASS from a discipline +MARKER minus a NEGATION blocklist. Measured attack: a CHANGELOG line saying the audit had been DROPPED +passed ``--strict`` — "dropped" was not among the ~30 enumerated negations. Enumerating negations does +not terminate against natural language (Ranum; CWE-183 inverted), so every fix of that shape is one more +word and the next phrasing walks through. + +This is the SAME shape as L5-01 one file over: there a blocklist of invisible characters, here a +blocklist of negation words. Both were replaced by establishing the property instead of forbidding the +form. + +THE INVERSION: one canonical attesting line, matched as a WHOLE line, carrying the version it attests. +Prose cannot move the verdict in EITHER direction — it neither grants a pass nor withholds one. + +HONEST LIMIT, stated in the gate too: this is provenance-SHAPED, not provenance. The finding's end state +is a runner-signed record bound to the tag candidate's digest; this repo has no signing path for that +yet. What IS closed is that prose no longer decides. +""" +from __future__ import annotations + +import importlib.util +import pathlib +import shutil +import unittest + +REPO = pathlib.Path(__file__).resolve().parents[1] +_SPEC = importlib.util.spec_from_file_location("_ptag", REPO / "scripts" / "pre_tag_audit_gate.py") +g = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(g) + +VERSION = "9.9.9" +TOKEN = "999" +ATTEST = f"pre-tag-adversarial-audit: RUN | version={VERSION}" + +# >= 30 ways to say "the audit did not happen", deliberately using vocabulary the old blocklist did NOT +# enumerate. Each MUST fail. The point is not that these 30 are caught — it is that no list was needed. +NEGATIONEN = [ + "the adversarial audit was dropped for this release", + "six-lens audit: waived by agreement", + "adversarial review disregarded this cycle", + "master-prompt audit shelved until after the tag", + "N-lens pass: outstanding", + "the adversarial audit remains inconclusive", + "adversarial audit: superseded by the external review", + "six lenses: rolled forward to the next release", + "master-prompt audit was descoped", + "the adversarial pass lapsed", + "N-lens audit: moot", + "adversarial audit forgone", + "six-lens review: bypassed", + "the adversarial audit is on hold", + "master-prompt audit: parked", + "adversarial audit relegated to post-tag", + "the six-lens pass was curtailed", + "N-lens audit: suspended", + "adversarial review: benched", + "master-prompt audit sidelined", + "the adversarial audit lapsed into the next quarter", + "six-lens coverage: partial at best", + "adversarial audit: substituted with a smoke test", + "the master-prompt audit is nominal only", + "N-lens audit: rubber-stamped", + "adversarial review happened for a DIFFERENT release", + "six-lens audit ran against an older tree", + "master-prompt audit: results discarded", + "the adversarial audit was rolled back", + "N-lens pass: void", + "adversarial audit: assumed", + "six-lens audit: to be revisited", +] + +# Lines that MENTION the vocabulary for unrelated reasons. None attests anything. +IRRELEVANTE_ERWAEHNUNGEN = [ + "refactored the adversarial fixture loader into its own module", + "renamed six-lens.md to sechs_linsen.md", + "the master-prompt template gained a new placeholder", + "docs: explain what an N-lens audit is", + "test helper `make_adversarial_payload` moved to conftest", + "CI: cache the adversarial corpus between jobs", +] + + +def _baum(tmp: pathlib.Path, *, record_text: str | None, changelog_text: str) -> pathlib.Path: + (tmp / "audit_artifacts" / TOKEN).mkdir(parents=True, exist_ok=True) + (tmp / "pyproject.toml").write_text(f'version = "{VERSION}"\n', encoding="utf-8") + (tmp / "CHANGELOG.md").write_text( + f"## [{VERSION}] - 2026-08-08\n\n{changelog_text}\n", encoding="utf-8") + if record_text is not None: + (tmp / "audit_artifacts" / TOKEN / "audit.md").write_text(record_text, encoding="utf-8") + return tmp + + +class ProsaEntscheidetNicht(unittest.TestCase): + + def setUp(self): + self.tmp = pathlib.Path(__import__("tempfile").mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + + # ── Korpus 1: die Verneinungen ────────────────────────────────────────────────────────── + def test_keine_verneinung_erteilt_einen_pass(self): + """Der Angriff des Fundes, ueber 32 Formulierungen statt ueber eine.""" + for satz in NEGATIONEN: + with self.subTest(satz=satz[:48]): + # Der Satz steht im CHANGELOG **und** im Beleg — beide Wege muessen scheitern. + r = g.evaluate(_baum(self.tmp, record_text=f"# audit\n\n{satz}\n", changelog_text=satz), + VERSION) + self.assertFalse(r["ok"], f"{satz!r} hat einen PASS erteilt") + + def test_die_korpusgroesse_ist_nicht_stillschweigend_geschrumpft(self): + """Der Fund verlangt >= 30 Formulierungen. Ein Korpus, der schrumpft, misst weniger und sagt nichts.""" + self.assertGreaterEqual(len(NEGATIONEN), 30, "der Verneinungs-Korpus ist unter die Vorgabe gefallen") + + # ── Korpus 2: die unbeteiligten Erwaehnungen ──────────────────────────────────────────── + def test_keine_unbeteiligte_erwaehnung_erteilt_einen_pass(self): + for satz in IRRELEVANTE_ERWAEHNUNGEN: + with self.subTest(satz=satz[:48]): + r = g.evaluate(_baum(self.tmp, record_text=f"# notes\n\n{satz}\n", changelog_text=satz), + VERSION) + self.assertFalse(r["ok"], f"{satz!r} hat einen PASS erteilt") + + # ── Der Provenienz-Arm ────────────────────────────────────────────────────────────────── + def test_ohne_beleg_kein_pass_egal_was_das_changelog_sagt(self): + """Selbst ein CHANGELOG, das die Attestierung woertlich fuehrt, erteilt nichts.""" + r = g.evaluate(_baum(self.tmp, record_text=None, + changelog_text=f"six-lens adversarial audit run.\n{ATTEST}"), VERSION) + self.assertFalse(r["ok"], "das CHANGELOG hat einen PASS erteilt — es ist praesentational") + self.assertTrue(r["changelog_is_presentational"]) + + def test_ein_beleg_fuer_eine_ANDERE_version_attestiert_diese_nicht(self): + """Bis hierher genuegte ein markertragender Beleg IM richtigen Ordner. Ein aus einem frueheren + Release herueberkopierter Beleg attestierte damit das neue, indem er am richtigen Platz lag.""" + r = g.evaluate(_baum(self.tmp, + record_text="# audit\n\npre-tag-adversarial-audit: RUN | version=1.2.3\n", + changelog_text="six-lens adversarial audit run."), VERSION) + self.assertFalse(r["ok"], "ein Beleg fuer 1.2.3 hat 9.9.9 attestiert") + + def test_ein_markertragender_beleg_ohne_attestierung_wird_benannt(self): + """Kein stummes MISSING: der wahrscheinlichste Grund fuer eine ueberraschende Ablehnung steht drin.""" + r = g.evaluate(_baum(self.tmp, record_text="# audit\n\nsix-lens adversarial audit run.\n", + changelog_text="nothing here"), VERSION) + self.assertFalse(r["ok"]) + self.assertTrue(r["marker_only_records"], "der Marker-Beleg wurde nicht benannt") + + # ── Gegenrichtung: der Riegel darf nicht ALLES ablehnen ───────────────────────────────── + def test_gegenrichtung_ein_echter_beleg_erteilt_den_pass(self): + r = g.evaluate(_baum(self.tmp, record_text=f"# audit\n\n{ATTEST}\n", + changelog_text="nothing about audits here at all"), VERSION) + self.assertTrue(r["ok"], f"ein echter Beleg wurde abgelehnt: {r['reason']}") + self.assertEqual(len(r["attesting_records"]), 1) + + def test_gegenrichtung_das_echte_repo_besteht_weiterhin(self): + """Ohne diese Zeile waere jede Verschaerfung 'erfolgreich' — ein Riegel, der alles ablehnt. + + Gemessen am 2026-08-08: fuer 3.7.0 erteilte die CHANGELOG-Prosa ohnehin keinen Pass + (changelog_records_audit war bereits False), der Beleg tat es. Die Umkehr blockt also nichts Echtes. + """ + r = g.evaluate(REPO) + self.assertTrue(r["ok"], f"das echte Repo wurde ueberblockt: {r['reason']}") + self.assertTrue(r["attesting_records"], "kein attestierender Beleg im echten Repo") + + def test_die_attestierung_ist_eine_ganze_zeile_kein_teilstring(self): + """Sonst waere die Allowlist nur eine weitere Substring-Suche und beliebig einbettbar.""" + for bosartig in (f"NOT {ATTEST}", + f"we will write '{ATTEST}' once the audit runs", + f" (placeholder, audit still open)"): + with self.subTest(form=bosartig[:44]): + self.assertFalse( + g.attests_version(bosartig, VERSION), + f"{bosartig!r} wurde als Attestierung gelesen") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_roadmap_frontload_foundations.py b/tests/test_roadmap_frontload_foundations.py index 693eb81a..b90641c5 100644 --- a/tests/test_roadmap_frontload_foundations.py +++ b/tests/test_roadmap_frontload_foundations.py @@ -189,16 +189,35 @@ def test_negation_covers_never_deferred_postponed(self): (rec / "note.md").write_text(f"# 7.7.0\n\n{concession}\n") self.assertFalse(self.gate.evaluate(Path(td), version="7.7.0")["ok"], concession) - def test_positive_marker_still_passes(self): - # counterpart: a genuine positive audit note IS accepted (discriminates the negation guard from a - # blanket reject). + def test_eine_echte_attestierung_erteilt_den_pass(self): + # counterpart: a genuine record IS accepted (discriminates the gate from a blanket reject). + # + # Bis 2026-08-08 hiess dieser Test test_positive_marker_still_passes und legte eine PROSA-Notiz an + # ("Ran a 6-lens adversarial audit"). Genau das ist der Defekt, den deep-gate-Fund L5-02 beschreibt: + # ein Marker-Substring ist kein Beleg, und die Gegenprobe darauf zementierte ihn. Die ABSICHT des + # Tests — der Riegel darf nicht alles ablehnen — bleibt; die Eingabe ist jetzt die kanonische + # Attestierung, die der Beleg selbst fuehren muss. import tempfile with tempfile.TemporaryDirectory() as td: rec = Path(td) / "audit_artifacts" / "770" rec.mkdir(parents=True) - (rec / "note.md").write_text("# 7.7.0\n\nRan a 6-lens adversarial audit; all findings fixed.\n") + (rec / "note.md").write_text( + "# 7.7.0\n\npre-tag-adversarial-audit: RUN | version=7.7.0\n\n" + "Ran a 6-lens adversarial audit; all findings fixed.\n") self.assertTrue(self.gate.evaluate(Path(td), version="7.7.0")["ok"]) + def test_prosa_allein_erteilt_keinen_pass_mehr(self): + # Die Kehrseite, neu: dieselbe Prosa OHNE Attestierung darf nichts mehr erteilen. Ohne diese Zeile + # bliebe unbelegt, dass die Polaritaets-Umkehr wirklich stattgefunden hat. + import tempfile + with tempfile.TemporaryDirectory() as td: + rec = Path(td) / "audit_artifacts" / "770" + rec.mkdir(parents=True) + (rec / "note.md").write_text("# 7.7.0\n\nRan a 6-lens adversarial audit; all findings fixed.\n") + r = self.gate.evaluate(Path(td), version="7.7.0") + self.assertFalse(r["ok"], "eine Prosa-Notiz erteilt weiterhin einen PASS") + self.assertTrue(r["marker_only_records"], "der Marker-Beleg wurde nicht benannt") + if __name__ == "__main__": unittest.main() From f112710573832b06e16ca7077fa753127fec1a8c Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 11:18:00 +0200 Subject: [PATCH 18/28] fix(sdist): the from-sdist skip set is derived, not enumerated (L6-01, P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/conftest.py carried a frozenset of 39 test ids that skip outside a git checkout. That list IS the defect: commit 2c5e7a5 had already appended ids to it once, and the gate still measured six MORE tests failing from an extracted sdist at HEAD. A list cannot know about the method somebody adds tomorrow to a module already on it. Measured against the REAL sdist (python -m build --sdist, extracted, run outside the checkout) rather than against an approximation: old conftest (enumerated): 7 failed, 2060 passed, 49 skipped new conftest (derived): 0 failed, 2057 passed, 66 skipped Six of the seven are the ones the finding names (test_intoto_spec_diff). The seventh came from a test file written in this same session — nobody had added it to any list, and the derivation covered it anyway. The question is now answered by measurement: does this module read a ROOT-relative path that does not exist here? Then we are outside a checkout, its assertions are about pruned material, and it SKIPs honestly. TWO DEFECTS IN MY OWN FIX, both found by measuring instead of trusting green: 1. The first version decomposed path CHAINS, so `parents[1] / "src" / "proofbundle"` was read as a root-level `proofbundle`. It flagged 23 modules even in a complete checkout. I then narrowed the rule to the FIRST SEGMENT, which removed the false positives — and also stopped catching the six tests the finding is about, because the sdist prunes LEAVES under shipped directories (docs/ is grafted, docs/IN_TOTO_PROFILE.md is not). The narrowing would have traded a real defect for a comfortable green. The actual bug was the decomposition; with chains joined, the full-path rule is precise. 2. I trimmed the fallback list to three modules because I BELIEVED the rest redundant. Seven tests failed. The set is measurable — empty the list in the extracted sdist, run, and what falls belongs in it. It is six modules, 15 ids, each with a documented reason. _REPO_CONTEXT_TESTS is now what the finding allows it to be: a documented explicit-exclusion list for modules whose repo dependency is not visible as a path literal (import from scripts/, dynamic loading, a glob over .github). Two tests of my own from this session are marked at the point of use with skipUnless rather than growing the central list. OPEN QUESTION, not explained away: published-artifact-gate.yml ALREADY runs the extracted-sdist suite and fails the job on a failure. So the invariant being false at HEAD means that job was either red or not triggered on the measured tree. I did not determine which. 2116 tests pass in the checkout, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- tests/conftest.py | 154 ++++++++++++++++++------ tests/test_pre_tag_audit_provenance.py | 7 ++ tests/test_sdist_selftest_derivation.py | 124 +++++++++++++++++++ 3 files changed, 246 insertions(+), 39 deletions(-) create mode 100644 tests/test_sdist_selftest_derivation.py diff --git a/tests/conftest.py b/tests/conftest.py index 894c076c..255b82f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,55 +27,36 @@ # NOTE: test_renewal_policy::test_shipped_example_policy_loads_and_evaluates is NOT here — its example # (docs/adr/renewal_policy.example.json) is a genuinely shipped artifact, fixed by `graft docs/adr`. _REPO_CONTEXT_TESTS = frozenset({ - "test_anchors_chia_claims::test_markovian_absent_on_chia_surface", - "test_anchors_chia_claims::test_no_uncaveated_overclaim_on_the_chia_surface", + # DER RUECKFALL, GEMESSEN statt angenommen. Diese Tests erreichen das Repo NICHT ueber ein + # Pfad-Literal im eigenen Modul (Import aus scripts/, dynamisches Laden, glob auf .github), also kann + # die Ableitung sie nicht sehen. Ermittelt, indem die Liste im entpackten sdist GELEERT und die Suite + # gefahren wurde: was dann faellt, gehoert hierher — und nur das. + # + # Eine erste Fassung dieses Fixes strich auf drei Module zusammen, weil ich die uebrigen fuer + # redundant HIELT. Sieben Tests fielen daraufhin. Die Menge ist messbar; sie zu schaetzen war der + # Fehler. + # + # test_audit_candidate_360 / test_roadmap_frontload_foundations: importieren scripts/*.py und laufen + # ueber DEREN Repo-Zugriffe. "test_audit_candidate_360::test_matrix_is_ready_and_has_33_checks", "test_audit_candidate_360::test_c12_2_green_on_real_repo", "test_audit_candidate_360::test_c1_1_green_on_real_repo", - # L6-02: the c1_1 CI-gate discrimination tests build temp workflow YAML and parse it via - # audit_candidate_matrix._ci_workflow_facts -> `import yaml`. PyYAML is a [test]-extra dep, so from a bare - # `[eval]` sdist install c1_1 honestly returns DATA_BLOCKED and these asserts fail -> skip them outside a - # git checkout (they run in the normal `test` CI job which has the dev deps). - "test_audit_candidate_360::test_c1_1_fails_when_second_gate_missing", - "test_audit_candidate_360::test_c1_1_fails_when_second_gate_is_not_a_test_gate", - "test_audit_candidate_360::test_c1_1_which_pytest_is_not_a_test_run", - "test_audit_candidate_360::test_c1_1_collect_only_is_not_a_test_run", - "test_audit_candidate_360::test_c1_1_real_unittest_discover_passes", - "test_audit_candidate_360::test_variant3_pytest_only_in_comment_echo_or_disabled_job_fails_c1_1", - "test_audit_candidate_360::test_variant3b_real_executing_run_step_passes_c1_1", - # PKG-01: these read REPO/audit_artifacts/findings_register_361.json, which `prune audit_artifacts` in - # MANIFEST.in deliberately drops from the sdist — skip them outside a git checkout (never in CI). "test_audit_candidate_360::test_c12_2_fails_on_tampered_register", "test_audit_candidate_360::test_c12_2_fails_on_foreign_key_register", - "test_findings_register_rt10::test_control_real_register_verifies", - "test_findings_register_rt10::test_tampered_status_fails", - "test_findings_register_rt10::test_foreign_key_fails", - "test_findings_register_rt10::test_emptied_findings_fails", - # 6-lens gate L6-01: reads REPO/audit_artifacts/findings_register_361.json (pruned from the sdist) to reuse - # the real body before overwriting findings — skip outside a git checkout like its 4 siblings above. The - # security property (a hidden open-P0 cannot report 0-open) is ALSO covered from-sdist by the inline - # TestResolveCurrent tests, which build findings in-memory and read no pruned file. - "test_findings_register_rt10::test_invisible_or_confusable_severity_cannot_hide_open_p0", + "test_roadmap_frontload_foundations::test_pack_is_grounded_in_real_artifacts", + "test_roadmap_frontload_foundations::test_released_version_has_audit_record", + # test_claims_hygiene: scannt die Doku ueber scripts/claims_hygiene_check, das seine Pfadmenge selbst + # fuehrt. "test_claims_hygiene::test_real_docs_are_clean", "test_claims_hygiene::test_every_default_doc_exists_and_scan_covers_all", "test_claims_hygiene::test_injected_overclaim_in_every_listed_doc_fails", "test_claims_hygiene::test_main_default_run_includes_cli_surface", "test_claims_hygiene::test_new_priority_docs_are_in_scan_set_and_clean", - "test_docs_truth::test_citation_version_matches_pyproject", - "test_docs_truth::test_docs_references_are_current", - "test_docs_truth::test_non_claims_covers_decision_authorization_boundary", - "test_docs_truth::test_readme_carries_no_hardcoded_test_count", - "test_docs_truth::test_spec_revision_matches_spec_md", - "test_fork_pr_secret_isolation::test_repo_workflows_are_isolation_safe", - "test_intoto_claims_hygiene::test_intoto_status_is_labelled_proposed", - "test_intoto_claims_hygiene::test_no_overclaim_phrase_on_the_intoto_surface", - "test_intoto_spec_diff::test_implementation_doc_matches_code", - "test_intoto_spec_diff::test_upstream_draft_uses_the_intoto_namespace_and_notes_the_vendor_alias", - "test_relation_statement_rust_parity::test_relation_surface_is_covered_and_integrity_ok", - "test_roadmap_frontload_foundations::test_pack_is_grounded_in_real_artifacts", - "test_roadmap_frontload_foundations::test_released_version_has_audit_record", + # test_rust_parity_gate: prueft den Rust-Baum ueber scripts/rust_parity_gate. "test_rust_parity_gate::test_real_repo_main_rs_has_the_expected_subcommands", "test_rust_parity_gate::test_real_repo_registry_is_honest_strict_mode_exits_0", + # test_fork_pr_secret_isolation: glob ueber .github/workflows, kein benanntes Literal. + "test_fork_pr_secret_isolation::test_repo_workflows_are_isolation_safe", }) @@ -84,13 +65,108 @@ def running_in_repo_checkout() -> bool: return any((_REPO_ROOT / m).exists() for m in _REPO_ONLY_MARKERS) +# ── The skip set is DERIVED, not enumerated (deep gate finding L6-01, P1) ──────────────────────────── +# +# The frozenset above IS the defect. Commit 2c5e7a5 already appended ids to it once, and the gate found six +# MORE tests failing from an extracted sdist at HEAD — because a list of ids cannot know about the method +# somebody adds tomorrow to a module that is already on it. The finding is explicit: appending the six is +# the instance fix and it re-opens. +# +# So the question is answered by MEASUREMENT instead: does this test module read a ROOT-relative path that +# does not exist here? If it does, we are outside a checkout and the module's assertions are about material +# the sdist deliberately prunes — an honest SKIP, never a FAIL. A method added to such a module tomorrow is +# covered the moment it is written, because nothing has to be remembered. +# +# GRANULARITY, deliberately the module. A single item's file reads cannot be attributed statically without +# guessing, and guessing here means either a false FAIL (loud, and the pressure is then to loosen the guard) +# or a false PASS. Skipping the module is the honest, conservative direction: from the sdist it announces +# N/A instead of running less than it claims. In a checkout every path exists and this whole path is a no-op. +_ROOT_NAMEN = {"REPO", "ROOT", "REPO_ROOT", "_REPO_ROOT", "PROJECT_ROOT"} + + +def _wurzel_relative_pfade(quelle: str) -> set[str]: + """String literals used as `` / "literal"`` in this module's source.""" + import ast # noqa: PLC0415 - only needed on the from-sdist path + + try: + baum = ast.parse(quelle) + except SyntaxError: + return set() + + def _ist_wurzel(knoten) -> bool: + # REPO / "x" · _REPO_ROOT / "x" · (Path(__file__).resolve().parents[1]) / "x" · REPO / "a" / "b" + if isinstance(knoten, ast.Name): + return knoten.id in _ROOT_NAMEN + if isinstance(knoten, ast.Subscript): + return _ist_wurzel(knoten.value) + if isinstance(knoten, ast.Attribute): + return knoten.attr == "parents" or _ist_wurzel(knoten.value) + if isinstance(knoten, ast.Call): + return _ist_wurzel(knoten.func) + if isinstance(knoten, ast.BinOp) and isinstance(knoten.op, ast.Div): + return _ist_wurzel(knoten.left) + return False + + def _kette(knoten): + """(ist_wurzelrelativ, segmente) — die GANZE Kette, nicht ihre Teile. + + Die erste Fassung sammelte jedes Segment einzeln, sodass aus + ``parents[1] / "src" / "proofbundle"`` auch ``proofbundle`` als wurzelrelativ galt. Das liegt + aber unter ``src/``, existiert an der Wurzel nicht, und so meldete die Ableitung 23 Module + selbst in einem vollstaendigen Checkout. Ein zerlegter Pfad ist ein anderer Pfad. + """ + if isinstance(knoten, ast.BinOp) and isinstance(knoten.op, ast.Div): + links_ok, teile = _kette(knoten.left) + if not links_ok: + return (False, []) + if isinstance(knoten.right, ast.Constant) and isinstance(knoten.right.value, str): + return (True, teile + [knoten.right.value]) + return (False, []) # ein variables Segment macht den Rest unbestimmbar + return (_ist_wurzel(knoten), []) + + gefunden: set[str] = set() + for x in ast.walk(baum): + if not (isinstance(x, ast.BinOp) and isinstance(x.op, ast.Div)): + continue + ok, teile = _kette(x) + if ok and teile: + gefunden.add("/".join(teile)) + return gefunden + + +def modul_ist_repo_kontext(pfad: pathlib.Path, wurzel: pathlib.Path = _REPO_ROOT) -> bool: + """True iff this test module reads a root-relative path that is ABSENT here. + + Absence is the whole signal, so an unreadable module is NOT silently treated as fine: it cannot be + shown to be package-only, and outside a checkout the safe answer is to skip it. + """ + try: + quelle = pfad.read_text(encoding="utf-8", errors="ignore") + except OSError: + return True + # THE FULL PATH, and it has to be the full path: the sdist prunes LEAVES under shipped directories + # too (``docs/`` is grafted but ``docs/IN_TOTO_PROFILE.md`` is pruned), so a first-segment rule misses + # exactly the six tests this finding is about. An intermediate attempt used the first segment because + # the full-path form flagged 23 modules even in a complete checkout — but that was never the rule's + # fault: the path CHAINS were being decomposed (see _kette), so ``src`` / ``proofbundle`` was read as + # a root-level ``proofbundle``. With the chain joined correctly the full-path rule is precise, and the + # narrowing would have traded a real defect for a comfortable green. + return any(not (wurzel / rel).exists() for rel in _wurzel_relative_pfade(quelle)) + + def pytest_collection_modifyitems(config, items): if running_in_repo_checkout(): return # a real checkout: run everything (the CI path — coverage unchanged, pure no-op) skip = pytest.mark.skip(reason="repo-context test: asserts repo/CI/Rust/docs layout not shipped in the " "sdist — N/A outside a git checkout (PKG-2026-0718-01)") + entschieden: dict[str, bool] = {} for item in items: - stem = pathlib.Path(str(getattr(item, "fspath", ""))).stem + datei = pathlib.Path(str(getattr(item, "fspath", ""))) + stem = datei.stem method = getattr(item, "originalname", None) or item.name - if f"{stem}::{method}" in _REPO_CONTEXT_TESTS: + if stem not in entschieden: + entschieden[stem] = modul_ist_repo_kontext(datei) + # DERIVED first; the explicit list stays as a documented fallback for modules whose repo + # dependency is not visible as a path literal (an env probe, a subprocess into the tree). + if entschieden[stem] or f"{stem}::{method}" in _REPO_CONTEXT_TESTS: item.add_marker(skip) diff --git a/tests/test_pre_tag_audit_provenance.py b/tests/test_pre_tag_audit_provenance.py index 7af88ac3..1a19774a 100644 --- a/tests/test_pre_tag_audit_provenance.py +++ b/tests/test_pre_tag_audit_provenance.py @@ -29,6 +29,10 @@ g = importlib.util.module_from_spec(_SPEC) _SPEC.loader.exec_module(g) +_CSPEC = importlib.util.spec_from_file_location("_cf_ptag", REPO / "tests" / "conftest.py") +_conftest = importlib.util.module_from_spec(_CSPEC) +_CSPEC.loader.exec_module(_conftest) + VERSION = "9.9.9" TOKEN = "999" ATTEST = f"pre-tag-adversarial-audit: RUN | version={VERSION}" @@ -149,6 +153,9 @@ def test_gegenrichtung_ein_echter_beleg_erteilt_den_pass(self): self.assertTrue(r["ok"], f"ein echter Beleg wurde abgelehnt: {r['reason']}") self.assertEqual(len(r["attesting_records"]), 1) + @unittest.skipUnless(_conftest.running_in_repo_checkout(), + "liest das ECHTE Repo indirekt ueber das Gate (kein Pfad-Literal im Modul, also " + "von der conftest-Ableitung nicht erkennbar) — N/A ausserhalb eines Checkouts") def test_gegenrichtung_das_echte_repo_besteht_weiterhin(self): """Ohne diese Zeile waere jede Verschaerfung 'erfolgreich' — ein Riegel, der alles ablehnt. diff --git a/tests/test_sdist_selftest_derivation.py b/tests/test_sdist_selftest_derivation.py new file mode 100644 index 00000000..d61da476 --- /dev/null +++ b/tests/test_sdist_selftest_derivation.py @@ -0,0 +1,124 @@ +"""The from-sdist skip set is DERIVED, so a test added tomorrow is covered without being remembered. + +THE CLASS (deep gate wf_cfe249d0-ee8, finding L6-01, P1). ``tests/conftest.py`` carried a frozenset of +44 test ids that SKIP outside a git checkout. That list IS the defect: commit 2c5e7a5 had already +appended ids to it once, and the gate still measured six MORE tests failing from an extracted sdist at +HEAD — because a list cannot know about the method somebody adds tomorrow to a module already on it. + +Measured against the real sdist (built with ``python -m build --sdist``, extracted, run with the repo +interpreter): + + old conftest (enumerated list): 7 failed, 2060 passed, 49 skipped + new conftest (derived): 0 failed, 1827 passed, 289 skipped + +Six of those seven are the ones the finding names (test_intoto_spec_diff). The seventh came from a test +file written in the SAME session as this fix — nobody had added it to any list, and the derivation +covered it anyway. That is the difference between the two designs, in one data point. + +THE META-TEST the finding demands is below: a method planted inside an ALREADY-covered module must be +covered too. A guard that merely re-lists module names does not survive it. +""" +from __future__ import annotations + +import importlib.util +import pathlib +import shutil +import tempfile +import unittest + +REPO = pathlib.Path(__file__).resolve().parents[1] +_SPEC = importlib.util.spec_from_file_location("_cf", REPO / "tests" / "conftest.py") +cf = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(cf) + + +class AbgeleiteteSkipMenge(unittest.TestCase): + + def setUp(self): + self.tmp = pathlib.Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + (self.tmp / "tests").mkdir() + (self.tmp / "vorhanden.md").write_text("da", encoding="utf-8") + + def _modul(self, name: str, quelle: str) -> pathlib.Path: + p = self.tmp / "tests" / name + p.write_text(quelle, encoding="utf-8") + return p + + def test_ein_modul_das_einen_fehlenden_wurzelpfad_liest_ist_repo_kontext(self): + for form in ( + 'REPO = Path(__file__).parents[1]\nX = REPO / "SPEC.md"\n', + '_REPO_ROOT = Path(__file__).resolve().parent.parent\nX = _REPO_ROOT / ".github"\n', + 'ROOT = Path(__file__).parents[1]\nX = ROOT / "tools" / "pb_verify_rs"\n', + 'X = Path(__file__).resolve().parents[1] / "audit_artifacts"\n', + ): + with self.subTest(form=form.splitlines()[-1][:44]): + p = self._modul("test_x.py", "from pathlib import Path\n" + form) + self.assertTrue(cf.modul_ist_repo_kontext(p, wurzel=self.tmp), + "ein fehlender Wurzelpfad wurde nicht erkannt") + + def test_META_eine_neu_gepflanzte_methode_im_selben_modul_ist_mitgedeckt(self): + """DER META-TEST (vom Fund verlangt). + + Der alte Riegel haette hier NICHTS getan: die neue Methode steht auf keiner Liste. Weil die + Entscheidung am MODUL haengt und aus seinen Pfaden abgeleitet wird, ist sie ab der ersten Zeile + gedeckt — es muss sich niemand an sie erinnern. + """ + quelle = ('from pathlib import Path\nREPO = Path(__file__).parents[1]\n\n' + 'class T:\n def test_alt(self):\n assert (REPO / "SPEC.md").is_file()\n') + p = self._modul("test_gepflanzt.py", quelle) + self.assertTrue(cf.modul_ist_repo_kontext(p, wurzel=self.tmp)) + # jetzt eine NEUE Methode anhaengen, die eine weitere geprunte Datei liest + p.write_text(quelle + '\n def test_neu(self):\n assert (REPO / "docs/PRUNED.md").is_file()\n', + encoding="utf-8") + self.assertTrue(cf.modul_ist_repo_kontext(p, wurzel=self.tmp), + "die gepflanzte Methode ist nicht gedeckt — der Riegel zaehlt wieder auf") + + def test_gegenrichtung_ein_modul_mit_nur_vorhandenen_pfaden_ist_kein_repo_kontext(self): + """Ohne das waere ein Riegel, der ALLES ueberspringt, von einem richtigen nicht zu unterscheiden — + und aus dem sdist liefe dann gar nichts mehr, was wie 'gruen' aussaehe.""" + p = self._modul("test_ok.py", + 'from pathlib import Path\nREPO = Path(__file__).parents[1]\n' + 'X = REPO / "vorhanden.md"\n') + self.assertFalse(cf.modul_ist_repo_kontext(p, wurzel=self.tmp)) + + def test_gegenrichtung_ein_modul_ganz_ohne_wurzelpfade_ist_kein_repo_kontext(self): + p = self._modul("test_rein.py", "import json\n\ndef test_x():\n assert json.dumps({}) == '{}'\n") + self.assertFalse(cf.modul_ist_repo_kontext(p, wurzel=self.tmp)) + + def test_ein_unlesbares_modul_gilt_als_repo_kontext(self): + """Nicht bestimmbar ist keine Freigabe: wer nicht zeigen kann, dass er paketrein ist, wird + ausserhalb des Checkouts uebersprungen statt blind ausgefuehrt.""" + self.assertTrue(cf.modul_ist_repo_kontext(self.tmp / "tests" / "gibt_es_nicht.py", wurzel=self.tmp)) + + @unittest.skipUnless(cf.running_in_repo_checkout(), + "prueft eine Eigenschaft DES CHECKOUTS — ausserhalb eines Checkouts hat sie " + "keinen Gegenstand") + def test_im_echten_checkout_ist_die_ableitung_ein_no_op(self): + """In einem echten Checkout existiert jeder Wurzelpfad — nichts darf uebersprungen werden.""" + self.assertTrue(cf.running_in_repo_checkout(), "die Vorrichtung laeuft nicht in einem Checkout") + uebersprungen = [p.stem for p in sorted((REPO / "tests").glob("test_*.py")) + if cf.modul_ist_repo_kontext(p, wurzel=REPO)] + self.assertEqual(uebersprungen, [], + f"im Checkout wuerden Module uebersprungen: {uebersprungen}") + + def test_die_restliche_liste_ist_ein_dokumentierter_rueckfall(self): + """Der Fund erlaubt _REPO_CONTEXT_TESTS ausdruecklich NUR noch als begruendete Ausnahmeliste. + + Die Menge ist GEMESSEN, nicht geschaetzt: Liste im entpackten sdist leeren, Suite fahren, und was + faellt, gehoert hinein. Mein erster Versuch schaetzte drei Module — sieben Tests fielen daraufhin. + Es sind sechs. + + Die Gleichheit steht hier bewusst in BEIDE Richtungen: waechst die Liste, hat jemand wieder + aufgezaehlt statt abzuleiten; schrumpft sie, faellt aus dem sdist wieder etwas durch. + """ + rueckfall = {"test_audit_candidate_360", "test_claims_hygiene", "test_fork_pr_secret_isolation", + "test_roadmap_frontload_foundations", "test_rust_parity_gate"} + gelistet = {e.split("::")[0] for e in cf._REPO_CONTEXT_TESTS} + self.assertEqual(gelistet, rueckfall, + "die Rueckfall-Liste weicht von der gemessenen Menge ab — sie darf weder " + "wachsen (Aufzaehlung kehrt zurueck) noch schrumpfen (aus dem sdist faellt etwas durch)") + + +if __name__ == "__main__": + unittest.main() From 2c52596bcc21d3c6a1dd2e745ba73566bac33a7b Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 11:30:09 +0200 Subject: [PATCH 19/28] fix(persample): the merkle_path cap runs before the work it bounds (L2-02, P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merkle_path (256) WAS enforced — in merkle.verify_inclusion, i.e. AFTER the line that base64-decodes the entire proof list. Wrong order: for a proof that breaks the cap and therefore can never be valid, the full work was done first and the rejection came second. The budget module names "cap before work" as the pattern in its own docstring. The L2-01 structural bound added earlier already closed the unbounded case — 8,000,000 entries now fail at json_nodes instead of after ~10 s. But a window remained between the cap and json_nodes: up to 200,000 entries were still decoded in full. Two bounds, two different quantities. Measured (order oracle from the finding): n= 256 0.001 s ok=False (proof does not bind) n= 257 0.000 s ok=False "refused before decoding" n>= 200000 fail-closed at the structural budget 2116 tests pass, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/proofbundle/persample.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/proofbundle/persample.py b/src/proofbundle/persample.py index 86027e60..4dce6e43 100644 --- a/src/proofbundle/persample.py +++ b/src/proofbundle/persample.py @@ -208,6 +208,23 @@ def verify_sample_opening(opening: dict, root_b64: str, n: int) -> dict: if isinstance(n, bool) or not isinstance(n, int) or not 0 <= index < n: result["detail"] = "index out of range for the committed tree size" return result + # DIE KAPPE VOR DER ARBEIT, DIE SIE BEGRENZT (deep gate wf_cfe249d0-ee8, Fund L2-02, P2). + # + # merkle_path (256) wird durchgesetzt — aber in merkle.verify_inclusion, also NACH der Zeile + # darunter, die die GANZE proof-Liste base64-dekodiert. Das ist die falsche Reihenfolge: fuer einen + # Beweis, der die Kappe reisst und darum niemals gueltig sein kann, wird erst die volle Arbeit + # geleistet und danach abgelehnt. Das Budget-Modul nennt "erst die Kappe, dann die Arbeit" in seiner + # eigenen Beschreibung als das Muster. + # + # Die strukturelle Schranke oben (L2-01) hat den unbegrenzten Fall bereits geschlossen — 8 Mio. + # Eintraege fallen jetzt bei json_nodes statt nach zehn Sekunden. Dazwischen blieb aber ein Fenster: + # bis 200000 Eintraege wurde weiter alles dekodiert. Zwei Schranken, zwei verschiedene Groessen. + from .budget import DEFAULT_BUDGET # noqa: PLC0415 + if len(proof_list) > DEFAULT_BUDGET.merkle_path: + result["detail"] = (f"audit path has {len(proof_list)} steps (> merkle_path=" + f"{DEFAULT_BUDGET.merkle_path}) — refused before decoding") + return result + try: proof = [base64.b64decode(p, validate=True) for p in proof_list] root = base64.b64decode(root_b64, validate=True) From 239f5aaafa8872f6f349c21b3ff2fa88e24ebe6c Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 11:38:32 +0200 Subject: [PATCH 20/28] fix(paths): a type floor before the os boundary, not a wider except-tuple (L1-01, P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evaluation_card_hash and prereg_hash leaked raw exceptions on a non-path argument — measured across the corpus: OverflowError, TypeError, FileNotFoundError, 7 raw types per surface. The int case is worse than a wrong type: os.stat(1) reads a FILE DESCRIPTOR, not a path, so an integer argument does not merely fail, it inspects stdout. Widening the except-tuple to catch OverflowError closes exactly one member of an open set — the next ArithmeticError sibling walks through, and the fd side effect happens either way because the os call still runs. The floor rejects before the boundary. The invariant already existed here: load_bundle implements it verbatim ("bundle path must be a path string, got int (fail-closed)"). It was never applied to its two siblings. THREE FIXTURE ERRORS IN MY OWN REGRESSION, all the same class — a number that spoke about the wrong object: 1. The family test called every surface with ONE argument; verify_prereg takes two. The resulting "missing 1 required positional argument" counted as a raw escape: 24 reported failures, none of them about the type floor. 2. Fixed by filling the remaining required args with {} — which made verify_prereg return "carries no prereg_sha256" BEFORE touching the path. The test then passed against the PRE-FIX file too, i.e. proved nothing. 3. Only with a claim that actually carries a digest does the corpus reach the surface. Now: red against the pre-fix file, green against the new one. 2119 tests pass, 162 subtests, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/proofbundle/evalcard.py | 14 ++++ src/proofbundle/prereg.py | 14 ++++ tests/test_path_argument_type_floor.py | 109 +++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 tests/test_path_argument_type_floor.py diff --git a/src/proofbundle/evalcard.py b/src/proofbundle/evalcard.py index 0016ee32..878c89e2 100644 --- a/src/proofbundle/evalcard.py +++ b/src/proofbundle/evalcard.py @@ -43,6 +43,20 @@ def evaluation_card_hash(card_path) -> str: import os # noqa: PLC0415 import stat as _stat # noqa: PLC0415 + # TYPE FLOOR (deep gate wf_cfe249d0-ee8, finding L1-01, P2). A public never-raise surface that takes a + # filesystem path must reject a non-path argument with a TYPED error BEFORE the os boundary. Measured: + # seven raw exception types escaped here (OverflowError, TypeError, FileNotFoundError) — and the int case + # is worse than a wrong type, because os.stat(1) reads a FILE DESCRIPTOR, not a path. + # + # Widening the except-tuple to catch OverflowError would close only half: the next ArithmeticError + # sibling walks straight through, and the fd side effect happens either way. The floor is the fix. + # + # The invariant already existed in this repo — load_bundle does exactly this ("bundle path must be a + # path string, got int (fail-closed)"). It was simply never applied here. + if not isinstance(card_path, (str, bytes, os.PathLike)): + from .errors import BundleFormatError as _BFE # noqa: PLC0415 + raise _BFE(f"eval card path must be a path string, got {type(card_path).__name__} (fail-closed)") + from .budget import DEFAULT_BUDGET # noqa: PLC0415 from .errors import BundleFormatError # noqa: PLC0415 cap = DEFAULT_BUDGET.input_bytes diff --git a/src/proofbundle/prereg.py b/src/proofbundle/prereg.py index 5ef704e7..da9461ef 100644 --- a/src/proofbundle/prereg.py +++ b/src/proofbundle/prereg.py @@ -40,6 +40,20 @@ def prereg_hash(protocol_path) -> str: import os # noqa: PLC0415 import stat as _stat # noqa: PLC0415 + # TYPE FLOOR (deep gate wf_cfe249d0-ee8, finding L1-01, P2). A public never-raise surface that takes a + # filesystem path must reject a non-path argument with a TYPED error BEFORE the os boundary. Measured: + # seven raw exception types escaped here (OverflowError, TypeError, FileNotFoundError) — and the int case + # is worse than a wrong type, because os.stat(1) reads a FILE DESCRIPTOR, not a path. + # + # Widening the except-tuple to catch OverflowError would close only half: the next ArithmeticError + # sibling walks straight through, and the fd side effect happens either way. The floor is the fix. + # + # The invariant already existed in this repo — load_bundle does exactly this ("bundle path must be a + # path string, got int (fail-closed)"). It was simply never applied here. + if not isinstance(protocol_path, (str, bytes, os.PathLike)): + from .errors import BundleFormatError as _BFE # noqa: PLC0415 + raise _BFE(f"prereg protocol path must be a path string, got {type(protocol_path).__name__} (fail-closed)") + from .budget import DEFAULT_BUDGET # noqa: PLC0415 - local import avoids an import cycle from .errors import BundleFormatError # noqa: PLC0415 cap = DEFAULT_BUDGET.input_bytes diff --git a/tests/test_path_argument_type_floor.py b/tests/test_path_argument_type_floor.py new file mode 100644 index 00000000..d881d217 --- /dev/null +++ b/tests/test_path_argument_type_floor.py @@ -0,0 +1,109 @@ +"""A public surface that takes a filesystem path rejects a non-path with a TYPED error, before the os call. + +THE CLASS (deep gate wf_cfe249d0-ee8, finding L1-01, P2). ``evaluation_card_hash`` and ``prereg_hash`` +leaked raw exceptions on a non-path argument — measured: OverflowError, TypeError and FileNotFoundError +across the corpus. The int case is worse than a wrong type: ``os.stat(1)`` reads a FILE DESCRIPTOR, not +a path, so an integer argument does not merely fail, it inspects stdout. + +WHY A TYPE FLOOR AND NOT A WIDER except-TUPLE. Catching OverflowError closes exactly one member of an +open set; the next ArithmeticError sibling walks through, and the fd side effect happens either way +because the os call still runs. The floor rejects before the boundary. + +THE INVARIANT ALREADY EXISTED HERE — ``load_bundle`` implements it verbatim ("bundle path must be a path +string, got int (fail-closed)"). It was never applied to its two siblings. That is why this file is a +FAMILY test: it derives the members from the signatures instead of asserting the two we happen to know. +""" +from __future__ import annotations + +import inspect +import unittest + +import proofbundle +from proofbundle.errors import ProofBundleError + +# Everything that is not a path. The int values matter most: they are the fd-confusion arm. +KORPUS = (2**31, 2**53, 2**63, 10**400, -10**400, 1.5, True, bytearray(b"x"), None, [], {}, object()) + + +def _restargumente(fn): + """Die uebrigen PFLICHT-Argumente, harmlos befuellt. + + Die erste Fassung rief jede Flaeche mit genau EINEM Argument auf. ``verify_prereg(protocol_path, + claim)`` nimmt aber zwei, und der resultierende "missing 1 required positional argument"-TypeError + zaehlte als roh entkommene Ausnahme: 24 gemeldete Fehler, die alle nur ueber meine eigene + Aufrufform sprachen und nichts ueber den Typboden. Eine Zahl ohne ihren Gegenstand. + """ + rest = [] + ps = list(inspect.signature(fn).parameters.values())[1:] + for x in ps: + if x.default is not inspect.Parameter.empty or x.kind in ( + inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY): + continue + # DER INHALT ENTSCHEIDET, OB DER PFAD UEBERHAUPT ANGEFASST WIRD. Ein leeres {} laesst + # verify_prereg sofort mit "carries no prereg_sha256 (not pre-registered)" zurueckkehren — der + # Test lief dann gruen, OHNE die Flaeche je zu betreten, und bestand deshalb auch gegen die + # Vor-Fix-Fassung. Ein Korpus, der den Gegenstand nicht erreicht, misst nichts. + rest.append({"prereg_sha256": "00" * 32, "evaluation_card_sha256": "00" * 32}) + return rest + + +def _familie(): + """Public verify_*/load_* surfaces whose FIRST parameter is a path, derived from the signatures.""" + out = [] + for name in dir(proofbundle): + if not name.startswith(("verify_", "load_")): + continue + fn = getattr(proofbundle, name) + if not callable(fn): + continue + try: + ps = list(inspect.signature(fn).parameters.values()) + except (TypeError, ValueError): + continue + if ps and ps[0].name.endswith("_path"): + out.append((name, fn, ps[0].name)) + return out + + +class PfadTypBoden(unittest.TestCase): + + def test_die_familie_ist_nicht_leer(self): + """Ohne das koennte die Ableitung stillschweigend nichts finden und der Test 'bestuende'.""" + familie = _familie() + self.assertGreaterEqual(len(familie), 2, f"die Familie ist auf {len(familie)} geschrumpft") + + def test_kein_nicht_pfad_entkommt_ungetypt(self): + for name, fn, param in _familie(): + for wert in KORPUS: + with self.subTest(flaeche=name, typ=type(wert).__name__): + try: + fn(wert, *_restargumente(fn)) + except ProofBundleError: + pass # der einzig erlaubte Fehlerpfad + except BaseException as exc: # noqa: BLE001 - genau das ist der Fund + self.fail(f"{name}({param}={type(wert).__name__}) liess {type(exc).__name__} " + "roh entkommen") + + def test_gegenrichtung_ein_echter_pfad_wird_nicht_abgelehnt(self): + """Ohne diese Zeile waere ein Boden, der ALLES ablehnt, von einem richtigen nicht zu unterscheiden. + + Geprueft wird die TYP-Ebene: ein nicht existierender, aber typrichtiger Pfad darf NICHT am + Typboden scheitern — er darf nur an dem scheitern, was danach kommt. + """ + import pathlib + import tempfile + with tempfile.TemporaryDirectory() as td: + fehlt = pathlib.Path(td) / "gibt_es_nicht.json" + for name, fn, _ in _familie(): + for typrichtig in (str(fehlt), fehlt): + with self.subTest(flaeche=name, form=type(typrichtig).__name__): + try: + fn(typrichtig, *_restargumente(fn)) + except BaseException as exc: # noqa: BLE001 + self.assertNotIn("must be a path string", str(exc), + f"{name} wies einen typrichtigen Pfad am Typboden ab") + + +if __name__ == "__main__": + unittest.main() From 673baa6e9fb57b72858f0758dfc6d03498a5a93c Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 11:42:10 +0200 Subject: [PATCH 21/28] fix(gate): a deferral is resolved against the tree, not asserted (L1-03, part) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit type_confusion_gate classified every non-JSON primary as NON_JSON with the note "covered by tests/test_fuzz_parsers.py". Measured: that file mentions neither evalcard nor prereg. It named a coverage that does not exist — and a named coverage reads exactly like a real one. The note is now RESOLVED: only a test that actually references the surface may be cited, and an entry with no such test says so (deferral_backed: false) instead of citing a file that does not cover it. MY FIRST VERSION OF THE RESOLUTION WAS ITSELF VACUOUS: it accepted `"proofbundle" in source` as a fallback, which matches nearly every test file, so all 26 surfaces came back "backed". A resolution that always finds something is not a resolution — it is the assertion wearing a checker's clothes. Narrowed to require BOTH the function name and the module name. The count did not change (still 0 unbacked), which now means the coverage genuinely exists rather than that the check cannot fail: an invented function name returns [], and prereg.verify_prereg resolves to five real files including the type-floor test written today. HONEST SCOPE: this is the proofbundle half of L1-03. The other half — domain- aware stubs and reachability as a first-class signal in office/governance/berkeley_gate/v4/never_raise_sweep.py — lives in 2bedone, whose push is currently parked on an owner decision. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/type_confusion_gate.py | 43 ++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/scripts/type_confusion_gate.py b/scripts/type_confusion_gate.py index e2402bc2..8adb73b5 100644 --- a/scripts/type_confusion_gate.py +++ b/scripts/type_confusion_gate.py @@ -105,6 +105,33 @@ def _benign_fixtures() -> dict[str, object]: } +def _deferral_targets(module: str, fname: str) -> list[str]: + """Tests under ``tests/`` that actually REFERENCE this surface — the only valid basis for a deferral. + + Resolved against the tree instead of asserted, because the asserted form was wrong: the note cited + tests/test_fuzz_parsers.py for every non-JSON primary, and that file mentions neither evalcard nor + prereg. A citation nobody resolves is indistinguishable from real coverage. + """ + import pathlib as _pl # noqa: PLC0415 + + tests = _pl.Path(__file__).resolve().parents[1] / "tests" + if not tests.is_dir(): + return [] + treffer = [] + for f in sorted(tests.glob("test_*.py")): + try: + quelle = f.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + # BEIDES muss vorkommen: der Funktionsname UND das Modul. Die erste Fassung liess + # `"proofbundle" in quelle` als Ersatz zu — das trifft fast jede Testdatei, und damit meldete + # die Pruefung fuer alle 26 Flaechen eine belegte Deckung. Eine Aufloesung, die immer faendig + # wird, ist keine Aufloesung; sie ist die Behauptung in Prueferform. + if fname in quelle and module in quelle: + treffer.append(f"tests/{f.name}") + return treffer + + def _is_json_primary(param: inspect.Parameter) -> bool: """Is the primary (attacker-controlled parsed) argument a JSON OBJECT/array, i.e. in scope for the JSON type-confusion matrix? bytes / compact-str / path / int primaries are a different @@ -137,9 +164,21 @@ def _classify(qname: str) -> dict: return {"python_ref": qname, "status": "NON_JSON", "notes": "no positional input"} first = params[0] if not _is_json_primary(first): + # A DEFERRAL IS ONLY WORTH THE TEST IT NAMES (deep gate wf_cfe249d0-ee8, finding L1-03, P2). + # + # This note said "covered by tests/test_fuzz_parsers.py" for every non-JSON primary. Measured: + # that file mentions neither evalcard nor prereg — it names a coverage that does not exist, and a + # named coverage reads exactly like a real one. So the deferral is now RESOLVED against the tree: + # only a test that actually references the surface may be cited; otherwise the entry says so. + gedeckt = _deferral_targets(module, fname) + if gedeckt: + hinweis = f"byte/string/path parser class — covered by {', '.join(gedeckt)}" + else: + hinweis = ("byte/string/path parser class — NO test in tests/ references this surface; " + "the deferral is unbacked and this surface is UNCOVERED here") return {"python_ref": qname, "status": "NON_JSON", - "notes": f"primary {first.name!r} is not a JSON object (byte/string/path parser " - "class — covered by tests/test_fuzz_parsers.py)"} + "notes": f"primary {first.name!r} is not a JSON object ({hinweis})", + "deferral_backed": bool(gedeckt)} fixtures = _benign_fixtures() extra_kwargs: dict[str, object] = {} unsatisfiable: list[str] = [] From addcaee6e639db803b2bab9be1e8cb24d363af49 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 12:34:11 +0200 Subject: [PATCH 22/28] fix: was die Pflicht-Review-Lane an meiner eigenen Arbeit fand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuenf von sechs Linsen der Pflicht-Review-Lane meldeten REJECT. Drei Punkte sind hier behoben, der Rest ist im Register benannt statt still gelassen. 1. CHANGELOG [Unreleased] behauptete "Nothing under src/ changes, no public interface gains or loses a field". Gemessen gegen origin/main: acht Dateien, 204 Zeilen unter src/proofbundle/. Der Satz war wahr, als er geschrieben wurde, und wurde nicht nachgezogen, als der Baum sich bewegte — eine Aussage, die niemand nachgemessen hat. Sie wird korrigiert statt still ersetzt, mit der Offenlegung je Aenderungsart, die COMPATIBILITY.md fuer eine Verschaerfung zuvor akzeptierter Eingaben ausdruecklich verlangt. Praezedenz dafuer ist 3.2.3 (Finding 15b), das dieselbe Klasse als PATCH mit ausdruecklicher Offenlegung auslieferte. 2. dsse.py fing als EINZIGE der sechs Flaechen nur BudgetExceeded, waehrend enforce_structural_budget zwei Geschwister wirft: BudgetExceeded bei Ueberbreite, BundleFormatError bei Uebertiefe. Heute folgenlos, weil der Tiefen-Zweig zufaellig genau den Typ wirft, den die Funktion ohnehin dokumentiert — aber der Kommentar daneben verspricht eine STRUKTURELLE Eigenschaft, und die haengt dann am Zufall. Die schmale Form stammt aus Zeile 134, wo sie richtig ist, und wurde auf einen Aufruf mit breiterer Fehlerflaeche uebertragen. Beide Zweige jetzt gemessen: BundleFormatError. 3. test_path_argument_type_floor.py leitete die Familie ueber endswith("_path") ab und schloss damit ausgerechnet load_bundle aus, dessen Parameter schlicht "path" heisst — das Referenzbeispiel aus dem eigenen Docstring. Zwei Linsen fanden das unabhaengig voneinander. Die Ableitung ist verbreitert, und ein neuer Test haelt fest, dass das eigene Referenzbeispiel in der Familie sein muss. Familie 2 -> 3 Mitglieder, 28 -> 42 Subtests. Die Vorrichtungs-Linse hat MUTATIONSTESTS gefahren und drei der vier neuen Testdateien als scharf belegt: Riegel entfernt -> Test rot, jeweils mit der richtigen Meldung. Das ist der Beleg, den ich selbst nicht erbracht hatte. 2120 Tests, 176 Subtests, ruff sauber. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 36 +++++++++++++++++++++++--- src/proofbundle/dsse.py | 13 ++++++++-- tests/test_path_argument_type_floor.py | 24 ++++++++++++++++- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c476c1..684ce75d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,38 @@ _Editorial 2026-07-20: internal gate codename replaced by its external name thro ## [Unreleased] -**Semantics: unchanged.** Everything below is CI, checks and documentation. Nothing under `src/` -changes, no public interface gains or loses a field, and no consumer has to do anything differently. -This is the explicit statement the release gate in [RELEASE.md](RELEASE.md) asks for; the planned -scope for the next patch is written down in [docs/release_scope/3.7.1.md](docs/release_scope/3.7.1.md). +**Semantics: unchanged. Resource ceilings: one deliberate tightening, disclosed below.** + +This banner said "Nothing under `src/` changes" until 2026-08-08. That was **false** by then: eight +files under `src/proofbundle/` changed, 204 lines. The sentence was written when it was true and was +not pulled when the tree moved past it — a statement nobody re-measured. It is corrected here rather +than quietly replaced, because the release gate in [RELEASE.md](RELEASE.md) rests on this paragraph +being measured, not remembered. Found by the mandatory review lane, not by a check. + +What actually changed, and why each is patch-safe: + +* **No public interface gains or loses a field**, and no verdict flips from fail to pass. Every change + below is fail-**closed**: input that was accepted and is over a generous ceiling is now refused + before the work it would cost. +* **Structural budget on the direct-dict path.** Six public surfaces that accept an already-parsed + structure now apply the same `VerificationBudget` ceilings the string/file path has always applied + (`string_len` 1 000 000, `json_nodes` 200 000). On that path the `input_bytes` cap is inert — there + are no bytes to measure — so those surfaces were unbounded. This is the same deliberate exception + the project shipped in 3.2.3 (Finding 15b) and is disclosed here for the same reason: + [COMPATIBILITY.md](COMPATIBILITY.md) requires that a tightening of a previously accepted input say + so explicitly. Each surface reports it in **its own** documented failure form — a result dict where + the surface returns dicts, `BundleFormatError` where it raises — so no new exception type appears + anywhere. +* **One cap moved earlier, same verdict.** `verify_sample_opening` enforced `merkle_path` (256) after + base64-decoding the whole proof list. For every input the outcome is unchanged; only the cost and + the `detail` text differ. +* **Typed errors on two path arguments.** `evaluation_card_hash` and `prereg_hash` raise + `BundleFormatError` on a non-path argument instead of leaking `OverflowError` / `TypeError` / + `FileNotFoundError`. The CLI always passes a `str`, no test or doc pinned the old types, and the + surrounding failure form in both functions was already `BundleFormatError`. + +The planned scope for the next patch is written down in +[docs/release_scope/3.7.1.md](docs/release_scope/3.7.1.md). ### Fixed diff --git a/src/proofbundle/dsse.py b/src/proofbundle/dsse.py index 5f1c795b..9567a562 100644 --- a/src/proofbundle/dsse.py +++ b/src/proofbundle/dsse.py @@ -76,10 +76,19 @@ def _payload_bytes(envelope: dict) -> bytes: # The check sits here rather than in verify_envelope so `load_payload` — the other member of the family # — is covered by the same statement instead of by a second call site that can drift out of step. from ._strict_json import enforce_structural_budget # noqa: PLC0415 - local import avoids a cycle - from .budget import BudgetExceeded # noqa: PLC0415 + from .errors import ProofBundleError # noqa: PLC0415 try: enforce_structural_budget(envelope) - except BudgetExceeded as exc: + # ProofBundleError, NICHT nur BudgetExceeded — und der Unterschied ist keine Kosmetik. + # enforce_structural_budget wirft ZWEI Geschwister: BudgetExceeded bei Ueberbreite, aber + # BundleFormatError ("JSON nesting is too deep") bei Uebertiefe. Ein schmaler catch faengt nur den + # ersten. Das ist HEUTE folgenlos, weil der Tiefen-Zweig zufaellig genau den Typ wirft, den diese + # Funktion ohnehin dokumentiert — aber der Kommentar unten verspricht eine STRUKTURELLE Eigenschaft + # ("a direct third-party caller never sees a raw sibling exception"), und die haengt dann am Zufall. + # Die schmale Form stammt aus Zeile 134, wo sie richtig ist: DEFAULT_BUDGET.check wirft nur + # BudgetExceeded. Sie wurde auf einen Aufruf mit breiterer Fehlerflaeche uebertragen. + # Die fuenf Geschwister-Flaechen desselben Fixes fangen alle ProofBundleError. + except ProofBundleError as exc: # Same mapping this module already applies twice: the docstrings of the public surfaces name only # BundleFormatError, so a direct third-party caller never sees a raw sibling exception. raise BundleFormatError(f"DSSE envelope exceeds the verification budget (fail-closed): {exc}") from exc diff --git a/tests/test_path_argument_type_floor.py b/tests/test_path_argument_type_floor.py index d881d217..23ac15a1 100644 --- a/tests/test_path_argument_type_floor.py +++ b/tests/test_path_argument_type_floor.py @@ -61,11 +61,25 @@ def _familie(): ps = list(inspect.signature(fn).parameters.values()) except (TypeError, ValueError): continue - if ps and ps[0].name.endswith("_path"): + if ps and _ist_pfadname(ps[0].name): out.append((name, fn, ps[0].name)) return out +# DER DRITTE ZUSTAND, und warum er hier fehlte. Die erste Fassung leitete die Familie ueber +# `endswith("_path")` ab — und schloss damit ausgerechnet ``load_bundle`` aus, dessen Parameter schlicht +# ``path`` heisst und den der Docstring dieser Datei als REFERENZ zitiert. Zwei Gegenlese-Linsen fanden +# das unabhaengig voneinander. Die Schwesterdatei test_structural_budget_reachability.py fuehrt fuer +# nicht klassifizierbare Signaturen einen eigenen Zustand; hier fehlte er, und eine Namensheuristik ohne +# Rettungsnetz schliesst still aus, statt zu melden. +_PFADNAMEN = ("path", "file", "datei") + + +def _ist_pfadname(name: str) -> bool: + n = name.lower() + return any(n == w or n.endswith("_" + w) or n.startswith(w + "_") for w in _PFADNAMEN) + + class PfadTypBoden(unittest.TestCase): def test_die_familie_ist_nicht_leer(self): @@ -73,6 +87,14 @@ def test_die_familie_ist_nicht_leer(self): familie = _familie() self.assertGreaterEqual(len(familie), 2, f"die Familie ist auf {len(familie)} geschrumpft") + def test_das_eigene_referenzbeispiel_ist_in_der_familie(self): + """load_bundle ist der im Docstring zitierte Praezedenzfall — und fiel aus der Ableitung heraus, + weil sein Parameter ``path`` heisst und nicht ``xxx_path``. Eine Ableitung, die ihr eigenes + Referenzbeispiel nicht erfasst, misst weniger als sie behauptet.""" + namen = {n for n, _fn, _p in _familie()} + self.assertIn("load_bundle", namen, + f"das Referenzbeispiel fehlt in der Familie: {sorted(namen)}") + def test_kein_nicht_pfad_entkommt_ungetypt(self): for name, fn, param in _familie(): for wert in KORPUS: From c39111742ee01e817c55de256254e20059daa09b Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 15:13:08 +0200 Subject: [PATCH 23/28] Revert "fix(persample): the merkle_path cap runs before the work it bounds (L2-02, P2)" This reverts commit 2c52596. Owner decision 20260808T1810Z part 4: if no arrangement holds BOTH properties -- cap before the expensive work AND unchanged CLI exit codes -- then 2c52596 is reverted before the PR, as its own revert commit with a reason, not by dropping it from the scope list. 3.7.1 does not become 3.8.0. MEASURED (wf_4d457fb2-1c1, 5 agents, 0 errors, 51 min): none of the three arrangements holds both. p1 raise-instead-of-return: A yes, B no. p2 cheap regex form-check: A no, B no. p3: A no, B yes. MORE IMPORTANT, AND IT CHANGES THE PREMISE: property A does not hold in 2c52596 itself. persample.py:199 calls enforce_structural_budget(opening) one line ABOVE the cap, and _strict_json.py:87-92 walks the list and pushes every element onto a stack -- Omega(n) time AND allocation before the cap is reached. Measured at n=190000: 47.1 ms / 11867 KiB, against 0.087 ms / 2.2 KiB at n=257. A 739x longer list costs 542x time and 5394x memory: linear, not flat. What 2c52596 actually delivers is a constant factor (330 -> 47 ms, 7.0x), not a change of growth class. That Omega(n) path came from 97c929a, the commit immediately before. A and B are mutually exclusive, and that is a lower bound rather than an implementation problem: the old exit code above the cap is 2 exactly when some proof element OR root_b64 would be rejected by b64decode(validate=True), else 1. Deciding "does an invalid element exist" requires reading all n elements in the worst case. A demands O(1). MY OWN EARLIER NUMBER WAS TOO SMALL. I reported ONE divergent input class (invalid base64 above the cap). Measured against the real CLI it is at least TWELVE, including one that no form-check on proof elements can ever see: all elements valid but root_b64 invalid (before 2, after 1). root_b64 is not in the list the cap measures. The CHANGELOG currently states, verbatim, "For every input the outcome is unchanged; only the cost and the detail text differ." That is false for >=12 input classes, and it sits in exactly the paragraph the release gate in RELEASE.md rests on. Precedent already set by this project: docs/release_scope/3.7.1.md kept stash@{0} out for the same reason -- "New behaviour at a public interface is a MINOR, not a PATCH." COMPATIBILITY.md:16 lists "the meaning of exit codes" as one of the four public surfaces. Point 4 (the verdict) is NOT violated: ok stays False either way, nothing flips from fail to pass. Not chosen: p3, which is a revert carrying a new message text -- and that text reads "refused before decoding" after decoding has happened. A false statement to the departing party is worse than no change. L2-02 STAYS OPEN, with the measured numbers and the reason the fix does not fit a patch release. Why the suite never caught the break: no test pins this path. The merkle_path tests hit merkle.verify_inclusion, a different function. Co-Authored-By: Claude Opus 5 (1M context) --- src/proofbundle/persample.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/proofbundle/persample.py b/src/proofbundle/persample.py index 4dce6e43..86027e60 100644 --- a/src/proofbundle/persample.py +++ b/src/proofbundle/persample.py @@ -208,23 +208,6 @@ def verify_sample_opening(opening: dict, root_b64: str, n: int) -> dict: if isinstance(n, bool) or not isinstance(n, int) or not 0 <= index < n: result["detail"] = "index out of range for the committed tree size" return result - # DIE KAPPE VOR DER ARBEIT, DIE SIE BEGRENZT (deep gate wf_cfe249d0-ee8, Fund L2-02, P2). - # - # merkle_path (256) wird durchgesetzt — aber in merkle.verify_inclusion, also NACH der Zeile - # darunter, die die GANZE proof-Liste base64-dekodiert. Das ist die falsche Reihenfolge: fuer einen - # Beweis, der die Kappe reisst und darum niemals gueltig sein kann, wird erst die volle Arbeit - # geleistet und danach abgelehnt. Das Budget-Modul nennt "erst die Kappe, dann die Arbeit" in seiner - # eigenen Beschreibung als das Muster. - # - # Die strukturelle Schranke oben (L2-01) hat den unbegrenzten Fall bereits geschlossen — 8 Mio. - # Eintraege fallen jetzt bei json_nodes statt nach zehn Sekunden. Dazwischen blieb aber ein Fenster: - # bis 200000 Eintraege wurde weiter alles dekodiert. Zwei Schranken, zwei verschiedene Groessen. - from .budget import DEFAULT_BUDGET # noqa: PLC0415 - if len(proof_list) > DEFAULT_BUDGET.merkle_path: - result["detail"] = (f"audit path has {len(proof_list)} steps (> merkle_path=" - f"{DEFAULT_BUDGET.merkle_path}) — refused before decoding") - return result - try: proof = [base64.b64decode(p, validate=True) for p in proof_list] root = base64.b64decode(root_b64, validate=True) From bc3ae70b61c2be89026ca496edf27e4193c1c630 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 15:13:59 +0200 Subject: [PATCH 24/28] docs(changelog): withdraw the merkle_path cap entry, and say why The bullet described 2c52596, which c391117 reverted. Dropping it silently would have been the easier move and the wrong one: the entry carried a claim that was measured FALSE, and readers of 3.7.1 are better served by knowing a change was considered and withdrawn than by finding no trace of it. The false claim was "For every input the outcome is unchanged". Measured against the real CLI, at least twelve input classes above the cap changed exit code from 2 to 1, including one no form-check on proof elements could ever see: root_b64 invalid while every proof element is valid. root_b64 is not in the list the cap measures. This paragraph is the one the release gate in RELEASE.md rests on, which is why a false sentence here mattered more than its size suggests. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 684ce75d..0db691b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,18 @@ What actually changed, and why each is patch-safe: so explicitly. Each surface reports it in **its own** documented failure form — a result dict where the surface returns dicts, `BundleFormatError` where it raises — so no new exception type appears anywhere. -* **One cap moved earlier, same verdict.** `verify_sample_opening` enforced `merkle_path` (256) after - base64-decoding the whole proof list. For every input the outcome is unchanged; only the cost and - the `detail` text differ. +* **Withdrawn before release: moving the `merkle_path` cap earlier.** An earlier commit in this + cycle moved the `merkle_path` (256) check in `verify_sample_opening` ahead of the base64 decode. + It was reverted, and this entry records why rather than dropping it silently. The claim it + originally carried here — that the outcome is unchanged for every input — was **measured false** + for at least twelve input classes above the cap: the CLI exit code moved from 2 to 1 whenever any + proof element, or `root_b64`, would have been rejected by `b64decode(validate=True)`. The verdict + itself never flipped (`ok` stays `False`), but [COMPATIBILITY.md](COMPATIBILITY.md) lists the + meaning of exit codes as a public surface, and this project already kept `stash@{0}` out of 3.7.1 + for the same reason. The change also did not achieve what it was for: an `Omega(n)` structural + budget walk runs one line above the cap, so the cap cannot precede the work it bounds — measured + 47.1 ms and 11867 KiB at n=190000 against 0.087 ms and 2.2 KiB at n=257, a constant factor rather + than a change of growth class. The underlying finding stays open for a minor release. * **Typed errors on two path arguments.** `evaluation_card_hash` and `prereg_hash` raise `BundleFormatError` on a non-path argument instead of leaking `OverflowError` / `TypeError` / `FileNotFoundError`. The CLI always passes a `str`, no test or doc pinned the old types, and the From bc9b51ba638353612ff4ae8a2a4b1276ae6a7134 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sat, 8 Aug 2026 15:34:55 +0200 Subject: [PATCH 25/28] docs(changelog): die eigene Korrektur wiederholte den Fehler, den sie beschreibt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Gegenlesung ueber die Pflicht-Review-Lane hat meine eigene Korrektur von bc3ae70 widerlegt, an drei Stellen. 1. Das Banner nannte "204 lines". Gemessen an bc3ae70 sind es 8 Dateien, 196 Insertions, 3 Deletions. 204 war der Stand bei 239f5aa, also EIN Commit vor dem, der den Satz schrieb — die Zahl war beim Schreiben schon um 9 daneben, und c391117 hat weitere 17 entfernt. Ein Absatz, der sich woertlich mit "a statement nobody re-measured" begruendet, trug selbst eine nicht nachgemessene Zahl. Eine Zaehlung gegen einen wandernden Zweig ist nur an einem benannten Ref wahr; sie nennt jetzt Ref und Kommando. 2. "the release gate in RELEASE.md rests on this paragraph" war zu hoch gegriffen. Gemessen: check_version_and_changelog.py liest NUR Ueberschriften, und die release-scope-Checkbox liest ein Mensch. Es gibt keinen Riegel auf diesem Absatz — was die Sache schlimmer macht, nicht besser, und deshalb steht es jetzt so da. 3. Vier Zahlen standen ohne Quelle, gegen den Maßstab des Repos selbst ("EVERY NUMBER NAMES ITS OBJECT AND ITS SOURCE", check_version_and_changelog.py:23). Die Methode steht jetzt dabei. Die Speicherzahl wurde unabhaengig auf die KiB bestaetigt; die Zeitzahlen sind RAUS, weil zwei Messungen desselben Codes um 28% auseinanderlagen und eine hostabhaengige Zahl in einem Changelog nichts belegt. "Zwoelf Klassen" bleibt als untere Schranke, mit dem Vermerk, dass "input class" hier keine definierte Einheit ist und eine unabhaengige Zaehlung auf 22 kam. Riegel gefahren: check_version_and_changelog exit 0, claims_hygiene PASS. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db691b5..982b1257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,17 @@ _Editorial 2026-07-20: internal gate codename replaced by its external name thro **Semantics: unchanged. Resource ceilings: one deliberate tightening, disclosed below.** -This banner said "Nothing under `src/` changes" until 2026-08-08. That was **false** by then: eight -files under `src/proofbundle/` changed, 204 lines. The sentence was written when it was true and was -not pulled when the tree moved past it — a statement nobody re-measured. It is corrected here rather -than quietly replaced, because the release gate in [RELEASE.md](RELEASE.md) rests on this paragraph -being measured, not remembered. Found by the mandatory review lane, not by a check. +This banner said "Nothing under `src/` changes" until 2026-08-08. That was **false** by then. Measured +at `bc3ae70` with `git diff --numstat origin/main HEAD -- src/proofbundle/`: **8 files, 196 +insertions, 3 deletions**. The sentence was written when it was true and was not pulled when the tree +moved past it — a statement nobody re-measured. + +The first correction of this banner then repeated the fault it describes. It claimed "204 lines", a +figure already 9 off when it was written and 17 further off after the revert below landed. A count +against a moving branch is only true at a named ref, so this one names its ref and its command. +Found by the mandatory review lane, both times, and not by a check — `scripts/check_version_and_changelog.py` +reads only headings, and the release-scope checkbox in [RELEASE.md](RELEASE.md) is read by a human, +not by a gate. What actually changed, and why each is patch-safe: @@ -33,15 +39,20 @@ What actually changed, and why each is patch-safe: * **Withdrawn before release: moving the `merkle_path` cap earlier.** An earlier commit in this cycle moved the `merkle_path` (256) check in `verify_sample_opening` ahead of the base64 decode. It was reverted, and this entry records why rather than dropping it silently. The claim it - originally carried here — that the outcome is unchanged for every input — was **measured false** - for at least twelve input classes above the cap: the CLI exit code moved from 2 to 1 whenever any - proof element, or `root_b64`, would have been rejected by `b64decode(validate=True)`. The verdict - itself never flipped (`ok` stays `False`), but [COMPATIBILITY.md](COMPATIBILITY.md) lists the - meaning of exit codes as a public surface, and this project already kept `stash@{0}` out of 3.7.1 - for the same reason. The change also did not achieve what it was for: an `Omega(n)` structural - budget walk runs one line above the cap, so the cap cannot precede the work it bounds — measured - 47.1 ms and 11867 KiB at n=190000 against 0.087 ms and 2.2 KiB at n=257, a constant factor rather - than a change of growth class. The underlying finding stays open for a minor release. + originally carried here — that the outcome is unchanged for every input — was **measured false**. + Method, since the repo asks every number to name its object and its source: two worktrees at the + commit and its parent, the same `verify-opening` invocation against each, exit codes compared per + input class. The CLI exit code moved from 2 to 1 whenever any proof element, or `root_b64`, would + have been rejected by `b64decode(validate=True)`. Two independent partitions were counted — one + gave at least 12 diverging classes, an independent re-count gave 22; "input class" is not a defined + unit here, so the lower bound is what the claim rests on. The verdict itself never flipped (`ok` + stays `False`), but [COMPATIBILITY.md](COMPATIBILITY.md) lists the meaning of exit codes as a + public surface, and this project already kept `stash@{0}` out of 3.7.1 for the same reason. The + change also did not achieve what it was for: an `Omega(n)` structural budget walk runs one line + above the cap, so the cap cannot precede the work it bounds. Peak memory at n=190000 was 11867 KiB + against 2.2 KiB at n=257 — a linear path, not a flat one. Wall-clock figures for the same runs are + deliberately not quoted: they were host-dependent and differed by 28% between two measurements of + the same code. The underlying finding stays open for a minor release. * **Typed errors on two path arguments.** `evaluation_card_hash` and `prereg_hash` raise `BundleFormatError` on a non-path argument instead of leaking `OverflowError` / `TypeError` / `FileNotFoundError`. The CLI always passes a `str`, no test or doc pinned the old types, and the From a83f01e0049c39644adc434365c0fa4fdd1f497d Mon Sep 17 00:00:00 2001 From: kraxo Date: Wed, 12 Aug 2026 16:13:02 +0200 Subject: [PATCH 26/28] docs(readme): name the fourth unpursued check, and stop the provenance claim being early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QITEM-PB-ASSURANCE-SICHTBAR-01 requires all four unpursued checks to stand in the Scorecard section as named causes; Binary-Artifacts (9/10) was missing. Measured 2026-08-10 via the Scorecard API: the deducted point is the checked-in reproduction fixture dist_final/ (wheel+sdist) — the sentence names the measured object. Two claims corrected against measurement: - "Every release is attested" — the three corpus-review pre-releases in the check's five-release window are not; scoped to version releases (v*), whose workflow has carried the attest step since v0.1.0. - "The provenance bundle is now attached as a release asset too" — measured v3.7.0 assets: wheel, sdist, SHA256SUMS. The workflow change takes effect with the NEXT release; published releases were not modified. Re-measured 2026-08-10T07:35:59Z (api.scorecard.dev, commit 6a3011f): 6.5/10, every per-check value identical to 2026-08-07. Owner-GO: GO_OWNER_PB_ASSURANCE_20260807 Co-Authored-By: Claude Opus 4.8 --- README.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 67f07499..ae7018c9 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ Scorecard **6.5/10** — [what the four zeros mean, in one sentence each](#what- ## What the Scorecard badge says, including the parts that are low -The badge is live, so it will move. Measured 2026-08-07 (Scorecard v5.5.0): **6.5 / 10**. Ten checks +The badge is live, so it will move. Measured 2026-08-07, re-measured 2026-08-10 with identical +per-check values (Scorecard v5.5.0 both times): **6.5 / 10**. Ten checks score 10/10 — Security-Policy, Token-Permissions, SAST, Fuzzing, CI-Tests, Vulnerabilities, Dangerous-Workflow, Dependency-Update-Tool, Packaging, License. Four score 0, and rather than let you wonder, here is each cause in one sentence: @@ -42,14 +43,20 @@ wonder, here is each cause in one sentence: - **Contributors (0/10)** — it counts contributors from two or more organisations. This is a one-person project, and the zero is an accurate description of that. - **Signed-Releases (0/10)** — the check reads GitHub **release assets** looking for a signature file. - Every release is attested (SLSA build provenance over the exact built bytes, PyPI upload gated on a - sha256 match), but that attestation lived in GitHub's attestation store and on PyPI — not next to - the release, which is where the check looks. The provenance bundle is now attached as a release - asset too. Nothing was re-signed; an existing file was placed in a second location. - -Two further checks sit in between: **Code-Review 1/10** (most commits are not reviewed by a second -person — structural for a single maintainer) and **Pinned-Dependencies 3/10** / **Branch-Protection -3/10**, both measured and not yet addressed. + Every version release (`v*`) is attested (SLSA build provenance over the exact built bytes, PyPI + upload gated on a sha256 match), but that attestation lives in GitHub's attestation store and on + PyPI — not next to the release, which is where the check looks. The release workflow now also + places the provenance bundle next to the release assets; that takes effect with the next release, + and already-published releases were not modified after the fact. Nothing is re-signed — an + existing file is placed in a second location. Of the five releases the check reads, three are + corpus-review pre-releases that carry no such assets either, so the number will climb only as new + releases move through that window. + +Three further checks sit in between: **Code-Review 1/10** (most commits are not reviewed by a second +person — structural for a single maintainer), **Binary-Artifacts 9/10** (the deducted point is a +checked-in wheel+sdist pair kept as a reproduction fixture in `dist_final/`, and one point is not +worth rebuilding that fixture), and **Pinned-Dependencies 3/10** / **Branch-Protection 3/10**, both +measured and not yet addressed. Publishing a middling number with its causes is the point. A project that sells evidence cannot withhold its own. From f31862eb03c167f78c195420da0763777edc4e10 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sun, 16 Aug 2026 17:21:52 +0200 Subject: [PATCH 27/28] fix(sdist-ableitung): ein ungebautes Artefakt ist kein sdist-Signal Die acht roten Checks von #139 sind acht Laeufer derselben Suite mit EINEM Fehler: FAIL: test_im_echten_checkout_ist_die_ableitung_ein_no_op AssertionError: Lists differ: ['test_relation_statement_rust_parity'] != [] ZWEI ARTEN VON ABWESENHEIT, und die Ableitung hatte eine Regel fuer beide. `test_relation_statement_rust_parity` nennt `tools/pb_verify_rs/target/release/pb_verify_rs`. Diese Datei fehlt auch im VOLLSTAENDIGEN Checkout -- bis jemand `cargo build` laeuft. Ihre Abwesenheit sagt nichts darueber, ob wir in einem sdist sind, und das ist die einzige Frage, die diese Ableitung stellt. Gemessen sind fuenf der neun wurzel-relativen Pfade dieses Moduls Build-Ausgaben unter `target/`. DIE TRENNENDE EIGENSCHAFT IST DIE IGNORE-REGEL DES REPOS, nicht eine Liste von Verzeichnisnamen. Gemessen: tools/pb_verify_rs/target/** IGNORIERT (5 Pfade, alle Build-Ausgaben) tools/pb_verify_rs/crosscheck.py nicht (echte Quelldatei) docs/IN_TOTO_PROFILE.md nicht (der geprunte Blattfall, fuer den die Ableitung existiert) Verzeichnisnamen aufzuzaehlen (`target`, `build`, `dist`, ...) waere wieder Formen sammeln -- genau davor warnt der Kommentar in `modul_ist_repo_kontext` selbst. NUR IM CHECKOUT WIRKSAM: in einem entpackten sdist gibt es kein git, und dort IST Abwesenheit das richtige Signal. Jeder Fehlschlag (git fehlt, kein Repo, exit != 0) faellt fail-safe auf "kein Bauartefakt" zurueck, also auf das bisherige, strengere Verhalten. GEMESSEN, beide Richtungen: Skip-Menge im Checkout ['test_relation_statement_rust_parity'] -> [] sdist-artig (docs fehlt) weiterhin True <- die Ableitung misst noch vollstaendiger Baum False Ruecknahme-Probe ohne den Fix 4 rot, mit ihm 10 gruen volle Suite auf diesem Zweig 2042 passed, 117 skipped, 0 failed EIN EIGENER MESSFEHLER, festgehalten weil er heute zum zweiten Mal passiert ist: mein erster Suitenlauf meldete 31 Fehler, darunter "ein gefaelschter Beleg wurde VERIFIED". Das venv war fuer den Release-Kandidaten gebaut und zeigte per `pip install -e` dorthin -- ich habe #139er Tests gegen Kandidaten-Quelltext gefahren. Mit einer Umgebung, die auf DIESEN Baum zeigt: 0 Fehler. Aufgefallen ist es nur, weil CI und meine Messung sich widersprachen. Dieselbe Wurzel wie die Werkzeugkette, die heute frueh zweieinhalb Stunden lang main gemessen hat: eine Messung, die ihren Gegenstand nicht festhaelt, ist von einer richtigen nicht zu unterscheiden. Co-Authored-By: Claude Opus 5 (1M context) --- tests/conftest.py | 36 +++++++++++++++++++++- tests/test_sdist_selftest_derivation.py | 40 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 255b82f7..0250f99a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -134,11 +134,44 @@ def _kette(knoten): return gefunden +def _ist_bauartefakt(wurzel: pathlib.Path, rel: str) -> bool: + """Is this absent path a BUILD OUTPUT rather than a source path the sdist pruned? + + TWO KINDS OF ABSENCE, and the first version of the derivation had one rule for both. + `tests/test_relation_statement_rust_parity.py` names `tools/pb_verify_rs/target/release/pb_verify_rs`. + That file is absent in a COMPLETE checkout too — until someone runs `cargo build`. Its absence + says nothing about whether we are in an sdist, which is the only question this derivation asks. + + Measured on the branch head: five of the nine root-relative paths that module names are build + outputs under `target/`, and all five are gitignored. The two real source paths it names + (`tools/pb_verify_rs/crosscheck.py`, `scripts`) are not — and neither is `docs/IN_TOTO_PROFILE.md`, + the pruned-leaf case this derivation exists for. The repository's own ignore rules are therefore + exactly the discriminator, and they are the RIGHT one: enumerating build-output directory names + (`target`, `build`, `dist`, …) would be listing forms again, which is the mistake the comment + below already warns about. + + ONLY MEANINGFUL IN A CHECKOUT. In an unpacked sdist there is no git and no ignore file, and there + the old rule is what we want — an absent path there really does mean "not shipped". Any failure + (git missing, not a repo, non-zero exit) therefore falls back to "not a build artifact", which + keeps the previous, stricter behaviour. + """ + import subprocess # noqa: PLC0415 - only on this path + try: + r = subprocess.run(["git", "-C", str(wurzel), "check-ignore", "-q", rel], + capture_output=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return False + return r.returncode == 0 + + def modul_ist_repo_kontext(pfad: pathlib.Path, wurzel: pathlib.Path = _REPO_ROOT) -> bool: """True iff this test module reads a root-relative path that is ABSENT here. Absence is the whole signal, so an unreadable module is NOT silently treated as fine: it cannot be shown to be package-only, and outside a checkout the safe answer is to skip it. + + A path that is absent because it has not been BUILT is not the same signal (see + `_ist_bauartefakt`) and does not count. """ try: quelle = pfad.read_text(encoding="utf-8", errors="ignore") @@ -151,7 +184,8 @@ def modul_ist_repo_kontext(pfad: pathlib.Path, wurzel: pathlib.Path = _REPO_ROOT # fault: the path CHAINS were being decomposed (see _kette), so ``src`` / ``proofbundle`` was read as # a root-level ``proofbundle``. With the chain joined correctly the full-path rule is precise, and the # narrowing would have traded a real defect for a comfortable green. - return any(not (wurzel / rel).exists() for rel in _wurzel_relative_pfade(quelle)) + return any(not (wurzel / rel).exists() and not _ist_bauartefakt(wurzel, rel) + for rel in _wurzel_relative_pfade(quelle)) def pytest_collection_modifyitems(config, items): diff --git a/tests/test_sdist_selftest_derivation.py b/tests/test_sdist_selftest_derivation.py index d61da476..ff454065 100644 --- a/tests/test_sdist_selftest_derivation.py +++ b/tests/test_sdist_selftest_derivation.py @@ -122,3 +122,43 @@ def test_die_restliche_liste_ist_ein_dokumentierter_rueckfall(self): if __name__ == "__main__": unittest.main() + + +class BauartefakteZaehlenNicht(unittest.TestCase): + """ZWEI ARTEN VON ABWESENHEIT, und die erste Fassung hatte eine Regel fuer beide. + + `tests/test_relation_statement_rust_parity.py` nennt + `tools/pb_verify_rs/target/release/pb_verify_rs`. Diese Datei fehlt auch im VOLLSTAENDIGEN + Checkout — bis jemand `cargo build` laeuft. Ihre Abwesenheit sagt nichts darueber, ob wir in + einem sdist sind, und genau das ist die einzige Frage dieser Ableitung. Gemessen: die Ableitung + uebersprang deshalb dieses eine Modul, und `test_im_echten_checkout_ist_die_ableitung_ein_no_op` + fiel in allen fuenf Python-Matrix-Laeufen plus coverage, crypto-floor und mutation — acht rote + Checks fuer EINEN Test. + + Die trennende Eigenschaft ist die Ignore-Regel des Repos selbst, nicht eine Liste von + Verzeichnisnamen: `target/` ist ignoriert, `tools/pb_verify_rs/crosscheck.py` nicht, und + `docs/IN_TOTO_PROFILE.md` — der geprunte Blattfall, fuer den diese Ableitung existiert — auch + nicht. Verzeichnisnamen aufzuzaehlen waere wieder Formen sammeln, wovor der Kommentar in + `modul_ist_repo_kontext` selbst warnt. + """ + + def test_ein_ungebautes_artefakt_macht_kein_repo_kontext_modul(self): + self.assertFalse(cf._ist_bauartefakt(REPO, "tools/pb_verify_rs/crosscheck.py"), + "eine echte Quelldatei gilt als Bauartefakt — die Ableitung wuerde blind") + self.assertTrue(cf._ist_bauartefakt(REPO, "tools/pb_verify_rs/target/release/pb_verify_rs"), + "das Rust-Binary gilt nicht als Bauartefakt — der Fall kehrt zurueck") + + def test_der_geprunte_blattfall_bleibt_ein_signal(self): + """Die Gegenrichtung. Ohne sie waere ein Fix, der ALLES entschaerft, ebenfalls gruen — + und die Ableitung haette aufgehoert, den sdist zu erkennen.""" + self.assertFalse(cf._ist_bauartefakt(REPO, "docs/IN_TOTO_PROFILE.md"), + "der geprunte Blattfall gilt als Bauartefakt — dann misst die Ableitung nichts mehr") + + def test_ohne_git_bleibt_das_strengere_alte_verhalten(self): + """In einem entpackten sdist gibt es kein git. Dort IST Abwesenheit das richtige Signal, + also faellt die Pruefung fail-safe auf 'kein Bauartefakt' zurueck.""" + import tempfile + with tempfile.TemporaryDirectory() as d: + self.assertFalse(cf._ist_bauartefakt(pathlib.Path(d), "irgendwas/target/x"), + "ohne git wird etwas als Bauartefakt entschuldigt — das entschaerft " + "die Ableitung genau dort, wo sie gebraucht wird") From 9fe1a401814c0b5ebff00e93bfab3d5e20730d84 Mon Sep 17 00:00:00 2001 From: kraxo Date: Sun, 16 Aug 2026 18:10:29 +0200 Subject: [PATCH 28/28] fix(test): der Bauartefakt-Test misst im sdist die Umgebung, nicht die Eigenschaft Der hermetic-cleanroom-Job faehrt die Suite aus dem ENTPACKTEN sdist. Dort gibt es kein git, also gibt `_ist_bauartefakt` fail-safe `False` zurueck -- genau das Verhalten, das der DRITTE Test dieser Klasse ausdruecklich vorhersagt. Die erste Fassung des ersten Tests behauptete `True` unbedingt und fiel deshalb: AssertionError: das Rust-Binary gilt nicht als Bauartefakt -- der Fall kehrt zurueck 1 failed, 1975 passed, 183 skipped Der Test mass damit die UMGEBUNG statt der Eigenschaft. Dieselbe Klasse, gegen die diese ganze Datei steht, eine Ebene hoeher -- und ich hatte die Bedingung im ersten Test schlicht nicht gesetzt, obwohl sie im dritten steht. `_in_git_checkout()` fragt dasselbe, was `_ist_bauartefakt` intern fragt, damit die Bedingung des Tests und die des Codes nicht auseinanderlaufen koennen. Uebersprungen wird NICHT stillschweigend: der Skip sagt, dass die Ignore-Regel hier nicht messbar ist, und nicht messbar ist keine Freigabe. Die Eigenschaft selbst haelt der Cleanroom-Job weiter -- ueber `test_ohne_git_bleibt_das_strengere_alte_verhalten`, nur von der anderen Seite. GEMESSEN: im Checkout 10 passed (scharf), im git-losen `git archive`-Baum uebersprungen statt rot. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_sdist_selftest_derivation.py | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_sdist_selftest_derivation.py b/tests/test_sdist_selftest_derivation.py index ff454065..359786ec 100644 --- a/tests/test_sdist_selftest_derivation.py +++ b/tests/test_sdist_selftest_derivation.py @@ -124,6 +124,22 @@ def test_die_restliche_liste_ist_ein_dokumentierter_rueckfall(self): unittest.main() +def _in_git_checkout() -> bool: + """Laeuft das hier in einem git-Arbeitsbaum? Gemessen, nicht aus der Verzeichnisform geraten. + + Im entpackten sdist gibt es weder `.git` noch das Kommando — und genau dort ist die Ignore-Regel + nicht befragbar. Der Aufruf hier ist derselbe, den `_ist_bauartefakt` intern macht, damit die + Bedingung des Tests und die des Codes nicht auseinanderlaufen koennen. + """ + import subprocess + try: + r = subprocess.run(["git", "-C", str(REPO), "rev-parse", "--is-inside-work-tree"], + capture_output=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return False + return r.returncode == 0 + + class BauartefakteZaehlenNicht(unittest.TestCase): """ZWEI ARTEN VON ABWESENHEIT, und die erste Fassung hatte eine Regel fuer beide. @@ -143,6 +159,21 @@ class BauartefakteZaehlenNicht(unittest.TestCase): """ def test_ein_ungebautes_artefakt_macht_kein_repo_kontext_modul(self): + """NUR IM CHECKOUT AUSSAGEKRAEFTIG, und die erste Fassung hat das vergessen. + + Sie behauptete `True` unbedingt — und fiel im hermetic-cleanroom-Job, der die Suite aus dem + ENTPACKTEN sdist faehrt. Dort gibt es kein git, also gibt `_ist_bauartefakt` fail-safe `False` + zurueck. Das ist genau das Verhalten, das der dritte Test dieser Klasse VORHERSAGT; ich hatte + die Bedingung nur im ersten nicht gesetzt. Der Test mass damit die UMGEBUNG statt der + Eigenschaft — dieselbe Klasse, gegen die diese ganze Datei steht, eine Ebene hoeher. + + Ausgelassen wird hier NICHT stillschweigend: ohne git ist die Frage nicht messbar, und der + Skip sagt das. Die Eigenschaft selbst haelt der Cleanroom-Job ueber + `test_ohne_git_bleibt_das_strengere_alte_verhalten` weiter, nur von der anderen Seite. + """ + if not _in_git_checkout(): + self.skipTest("kein git-Checkout (entpacktes sdist) — die Ignore-Regel ist hier nicht " + "messbar, und nicht messbar ist keine Freigabe") self.assertFalse(cf._ist_bauartefakt(REPO, "tools/pb_verify_rs/crosscheck.py"), "eine echte Quelldatei gilt als Bauartefakt — die Ableitung wuerde blind") self.assertTrue(cf._ist_bauartefakt(REPO, "tools/pb_verify_rs/target/release/pb_verify_rs"),