Skip to content

test(hash): assert T2.8 parity at the canonical hash input, not just the digest - #32

Merged
oabolade merged 2 commits into
mainfrom
test/t28-canonical-input-parity
Aug 18, 2026
Merged

test(hash): assert T2.8 parity at the canonical hash input, not just the digest#32
oabolade merged 2 commits into
mainfrom
test/t28-canonical-input-parity

Conversation

@oabolade

Copy link
Copy Markdown
Contributor

Folds the T2.8 audit check into test_cross_backend_hash.py.

What was missing

The file's docstring already claimed both backends "feed the same canonical fields to the same frozen formula" — but nothing asserted it. The existing tests compare digests and verify verdicts, which mostly re-proves that SHA-256 is deterministic. What actually drifts is which fields are canonical, in what order, and how each value is normalized. None of that was covered.

1. Byte-identity of the SHA-256 pre-image

_CapturePreimages intercepts hashlib inside rootsign.hashing and records the exact byte string handed to sha256. The canonical form is never reconstructed in the test — a test-local copy would agree with itself while the store quietly diverged, which is the failure ADR-001's never re-implement rule exists to prevent.

Same scripted session through both backends; field sets compared first (a legible failure), then full byte-identity per record.

[1] IDENTICAL  (394B vs 394B)
[2] IDENTICAL  (456B vs 456B)
[3] IDENTICAL  (458B vs 458B)

The pre-image, identical on both sides:

{"action_id": "", "input_hash": "0e1c4bf0…", "output_hash": "bbbb…",
 "prev_action_hash": "02b1351e…", "sequence_number": 2, "session_id": "",
 "timestamp": "2026-08-18T19:57:33.473234+00:00", "tool_name": "query_db"}

2. Identity pinned, so the comparison isolates canonicalization

action_id is store-assigned (uuid4() in jsonl_client and crud.action), not a property of the logical action — so an unpinned run differs there, and via the chain in prev_action_hash too. The docstring already noted this as a caveat; _pin_action_ids holds it constant so any remaining difference is a genuine divergence. The caveat becomes an assertion.

Verified non-vacuous — with pinning disabled the test fails, printing both pre-images side by side:

jsonl: {"action_id": "288ea189-…", "input_hash": "eb775ee5…", …}
pg   : {"action_id": "b26856b8-…", "input_hash": "eb775ee5…", …}

action_id is the sole difference; every other field identical. That's both proof the assertion has teeth and independent confirmation of the caveat.

3. Cross-backend tamper-detection parity

input_redacted sits outside self_hash (ADR-001) but is re-bound to input_hash, which is canonical. That binding is implemented twicecrud.action._payload_binding_error and sdk.chain — so the two can drift. A store where the DB path catches a rewritten payload and the offline path does not would be a silent hole in the audit story.

Now asserted on both, same verdict and same sequence:

JSONL: valid=False seq=2  payload_hash mismatch: input_redacted does not match input_hash
PG   : valid=False        payload_hash mismatch at sequence_number=2: …

The Postgres row is tampered directly via the hypertable-safe (action_id, timestamp) form. Note: the previous tamper test only ever touched the JSONL copy — the Postgres verifier had no tamper coverage in this file at all.

Also

Parametrizes the existing tamper test over input_hash and tool_name — both canonical, so either must surface at the sequence it was altered.

Verification

  • 6 passed in this file (was 3).
  • Full suite: 500 passed, 6 skipped.
  • ruff check clean apart from the 2 pre-existing E402s.

🤖 Generated with Claude Code

…the digest

The file already claimed both backends "feed the same canonical fields to the
same frozen formula" — but nothing asserted it. The existing tests compare
digests and verify verdicts, which mostly re-proves that SHA-256 is
deterministic. What actually drifts is *which* fields are canonical, in what
order, and how each value is normalized. None of that was covered.

Adds three things.

1. Byte-identity of the SHA-256 pre-image

   `_CapturePreimages` intercepts `hashlib` inside rootsign.hashing and records
   the exact byte string handed to sha256. The canonical form is never
   reconstructed in the test — a test-local copy would agree with itself while
   the store quietly diverged, which is the failure ADR-001's
   "never re-implement" rule exists to prevent.

   Same scripted session through both backends, then field sets compared first
   (a legible failure) and full byte-identity per record second.

2. Identity assignment pinned, so the comparison isolates canonicalization

   `action_id` is store-assigned (`uuid4()` in jsonl_client and crud.action),
   not a property of the logical action, so an unpinned run differs there and —
   via the chain — in prev_action_hash too. The module docstring already noted
   this as a caveat; `_pin_action_ids` holds it constant so any *remaining*
   difference is a genuine divergence. The caveat becomes an assertion.

   Verified non-vacuous: with pinning disabled the test fails, and the failure
   prints both pre-images side by side showing action_id as the sole
   difference — every other field identical.

3. Cross-backend tamper-detection parity

   `input_redacted` sits outside self_hash (ADR-001) but is re-bound to
   input_hash, which is canonical. That binding is implemented twice —
   `crud.action._payload_binding_error` and `sdk.chain` — so the two can drift.
   A store where the DB path catches a rewritten payload and the offline path
   does not would be a silent hole in the audit story. Now asserted on both,
   same verdict and same sequence number. The Postgres side is tampered
   directly via the hypertable-safe (action_id, timestamp) form.

   Note: the previous tamper test only ever touched the JSONL copy, so the
   Postgres verifier had no tamper coverage here at all.

Also parametrizes the existing tamper test over input_hash and tool_name —
both are canonical, so either must surface at the sequence it was altered.

6 passed (was 3). Full suite: 500 passed, 6 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread tests/integration/test_cross_backend_hash.py Fixed
github-code-quality flagged `rootsign.hashing` imported both as
`import ... as` and `from ... import` in the same file. Accurate — the module
alias came in with this branch's `_CapturePreimages`, alongside the file's
existing `from rootsign.hashing import compute_action_self_hash`.

Dropped the alias and reached the module through `sys.modules` instead, which
is already how `_pin_action_ids` gets at its own patch targets. That leaves one
import style and one patching idiom, rather than the bot's suggested fix of
keeping the alias — which would have forced
`rs_hashing.compute_action_self_hash(...)` at the existing call site and made
this module the odd one out.

Behaviour unchanged: 6 passed, and the negative control (pinning disabled)
still fails as intended, so the capture is still live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oabolade
oabolade merged commit 623eefc into main Aug 18, 2026
24 checks passed
@oabolade
oabolade deleted the test/t28-canonical-input-parity branch August 18, 2026 20:38
oabolade added a commit that referenced this pull request Aug 19, 2026
Patch release. Every change that ships in the wheel since v0.2.0 is a bug fix;
no new public API and nothing breaking.

  #25  fix(security)  log injection — `tool_name` off the MCP proxy wire
                      reached two logger calls unescaped
  #27  fix(security)  same guard reshaped so CodeQL can see it; output
                      byte-identical
  #28  fix(sdk)       BOTH console scripts were unusable on a bare install —
                      `rootsign version`, `rootsign --help`,
                      `rootsign verify --local` and every `rootsign-admin`
                      command died on ModuleNotFoundError
  #30  fix(sdk)       missing `postgres` extra now names the install command on
                      every DB-backed path, not just some
  #33  fix(cli)       single import style for importlib

#29/#31/#32 (test + CI) and #26 (actions bump) change nothing in the
distribution.

README's Status headline moves to v0.2.1; the phase table's "✅ v0.2.0" row
stays put — that records when 1.5 shipped, which is history, not the current
version.

`rootsign/_version.py` reads the version from installed distribution metadata,
so pyproject.toml is the only place it is written. Reinstalled and verified it
propagates to `rootsign.__version__`, `SDK_VERSION` (which lands in every
envelope's `sdk_version`), and `rootsign version`.

Built the artifacts and smoke-tested the wheel in a fresh no-extras venv —
the #28 fix holds in the thing that would actually go to PyPI:

    rootsign version        -> rootsign 0.2.1
    rootsign --help         -> exit 0
    rootsign-admin --help   -> exit 0
    rootsign verify <uuid>  -> install hint, exit 1
    DB packages resolved    -> none

Full suite: 500 passed, 6 skipped.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant