test(hash): assert T2.8 parity at the canonical hash input, not just the digest - #32
Merged
Merged
Conversation
…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>
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
_CapturePreimagesinterceptshashlibinsiderootsign.hashingand records the exact byte string handed tosha256. 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.
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_idis store-assigned (uuid4()injsonl_clientandcrud.action), not a property of the logical action — so an unpinned run differs there, and via the chain inprev_action_hashtoo. The docstring already noted this as a caveat;_pin_action_idsholds 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:
action_idis 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_redactedsits outsideself_hash(ADR-001) but is re-bound toinput_hash, which is canonical. That binding is implemented twice —crud.action._payload_binding_errorandsdk.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:
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_hashandtool_name— both canonical, so either must surface at the sequence it was altered.Verification
ruff checkclean apart from the 2 pre-existingE402s.🤖 Generated with Claude Code