From 9daf100bfd5b2611dae75536d1e5dead29daa6f2 Mon Sep 17 00:00:00 2001 From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:24:34 +0000 Subject: [PATCH] fix(update-tx): explicit null schema stamp reads corrupt, not legacy-unstamped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F3 adversarial fix-round 2, claim 1 (HIGH): read_update_tx_strict used a plain .get(), so a marker carrying an explicit _schema_version: null was indistinguishable from the accepted pre-7.0 UNSTAMPED form and read valid — rollback would then interpret and destructively act on a damaged stamp. A dict.get sentinel now distinguishes key ABSENCE (legacy, valid) from a stored null (corrupt, like every other non-integer stamp; no writer ever stamps null). Pin: null-stamped marker reads ("corrupt", {}) and the direct rollback entry point refuses typed with the marker byte-identical, HEAD unmoved and dirty owner work untouched; None joined the invalid-stamp loop. Protected surface supervisor/update_merge.py: delta sanctioned by this fix-round and minimal (one sentinel + docstring). --- supervisor/update_merge.py | 22 ++++++++++++------- tests/test_update_tx_nminus1_shim.py | 33 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/supervisor/update_merge.py b/supervisor/update_merge.py index cc00db320..ba007e5e5 100644 --- a/supervisor/update_merge.py +++ b/supervisor/update_merge.py @@ -53,6 +53,10 @@ UPDATE_TX_MARKER_NAME = "ouroboros-update-tx.json" # the stamp is one additive key its reader ignores. UPDATE_TX_SCHEMA_VERSION = 1 +# ``dict.get`` sentinel distinguishing a MISSING ``_schema_version`` key (the +# accepted pre-7.0 unstamped form) from an explicit stored ``null`` (corrupt). +_SCHEMA_STAMP_ABSENT = object() + def managed_update_constitution_present(ref: str = "HEAD") -> bool: """Whether *ref* keeps the non-empty regular BIBLE.md blob required by P4.""" @@ -217,12 +221,14 @@ def read_update_tx_strict() -> Tuple[str, Dict[str, Any]]: ``"future"``. A marker that exists but is unreadable/invalid is ``corrupt`` — callers MUST fail closed (block mutative update/commit ops) rather than treat it as ``absent``. - Schema admission (F14 N−1 shim): an UNSTAMPED marker is the accepted pre-7.0 - form and reads ``valid`` — the boot finalizer must drive a transaction the - N−1 updater recorded. An integer stamp above ``UPDATE_TX_SCHEMA_VERSION`` is - ``future`` (recorded by a newer release; the raw tx is returned as evidence - but no caller may interpret or overwrite it); a non-integer stamp is - ``corrupt``.""" + Schema admission (F14 N−1 shim): an UNSTAMPED marker — the key ABSENT — + is the accepted pre-7.0 form and reads ``valid``: the boot finalizer must + drive a transaction the N−1 updater recorded. An integer stamp above + ``UPDATE_TX_SCHEMA_VERSION`` is ``future`` (recorded by a newer release; + the raw tx is returned as evidence but no caller may interpret or + overwrite it); ANY present non-integer stamp — an explicit ``null`` + included — is ``corrupt`` (no writer ever stamps ``null``, so it is a + damaged stamp, not the legacy unstamped form).""" import json path = _update_tx_marker_path() @@ -236,8 +242,8 @@ def read_update_tx_strict() -> Tuple[str, Dict[str, Any]]: return "corrupt", {} from ouroboros.contracts.schema_versions import SCHEMA_VERSION_KEY - stamp = raw.get(SCHEMA_VERSION_KEY) - if stamp is not None: + stamp = raw.get(SCHEMA_VERSION_KEY, _SCHEMA_STAMP_ABSENT) + if stamp is not _SCHEMA_STAMP_ABSENT: if isinstance(stamp, bool) or not isinstance(stamp, int) or stamp < 1: return "corrupt", {} if stamp > UPDATE_TX_SCHEMA_VERSION: diff --git a/tests/test_update_tx_nminus1_shim.py b/tests/test_update_tx_nminus1_shim.py index 8b9284f70..fef386147 100644 --- a/tests/test_update_tx_nminus1_shim.py +++ b/tests/test_update_tx_nminus1_shim.py @@ -86,7 +86,7 @@ def test_invalid_stamps_are_corrupt_and_newer_stamps_are_future(tmp_path, monkey repo, head = _init_repo(tmp_path) _point_at(monkeypatch, tmp_path, repo, head) marker = update_merge._update_tx_marker_path() - for bad_stamp in ("1", True, 0, -3): + for bad_stamp in ("1", True, 0, -3, None): atomic_write_json(marker, {SCHEMA_VERSION_KEY: bad_stamp, "phase": "x"}) assert update_merge.read_update_tx_strict()[0] == "corrupt", bad_stamp atomic_write_json( @@ -97,6 +97,37 @@ def test_invalid_stamps_are_corrupt_and_newer_stamps_are_future(tmp_path, monkey assert status == "future" and tx["phase"] == "x" # raw evidence returned +def test_explicit_null_stamp_is_corrupt_and_rollback_refuses_untouched(tmp_path, monkeypatch): + """Adversarial fix-round 2, claim 1: an explicit ``_schema_version: null`` + is a DAMAGED stamp, not the legacy unstamped form (only key ABSENCE is) — + it reads ``corrupt`` and the direct rollback entry point refuses typed + BEFORE any marker write or destructive reset/checkout/clean.""" + from ouroboros.utils import atomic_write_json + + repo, head = _init_repo(tmp_path) + _point_at(monkeypatch, tmp_path, repo, head) + cur = _git(repo, "rev-parse", "HEAD").stdout.strip() + marker = update_merge._update_tx_marker_path() + atomic_write_json( + marker, + { + SCHEMA_VERSION_KEY: None, "phase": "rolling_back", + "pre_update_sha": cur, "pre_update_branch": head, + }, + trailing_newline=True, + ) + before = marker.read_bytes() + assert update_merge.read_update_tx_strict() == ("corrupt", {}) + + dirty = repo / "uncommitted.txt" + dirty.write_text("owner work the rollback must not clean away\n") + ok, msg = update_merge.rollback_managed_update("null_stamp_probe") + assert ok is False and "pre_update_sha" in msg # refuses on the empty corrupt tx + assert marker.read_bytes() == before # byte-identical, never re-phased + assert _git(repo, "rev-parse", "HEAD").stdout.strip() == cur + assert dirty.read_text() == "owner work the rollback must not clean away\n" + + # ------------------------------------ N−1 fixtures through the boot finalizer