fix(security): an unhashed audit chain no longer reports as verified (#1984) - #1985
Merged
Conversation
…1984) `POST /api/audit-log/verify` answered `valid: true, checked: 0` for an audit log in which no entry carried a hash. Found by probing a live instance: 1,162 real audit entries, zero hashes, and a green "✓ Valid · 0 entries" badge in the UI — the tick sitting directly beside the zero that contradicts it. `valid: true` was returned for three materially different states — chain verified, range empty, and *no integrity data exists at all*. The last is the DEFAULT for every install that never enabled hashing, so the endpoint's verdict was effectively independent of its input: the class `docs/memory/learnings.md` already names from #1941. Skipping an unhashed entry is correct on its own — a chain enabled midway legitimately has an unhashed prefix. The defect was the aggregate verdict when EVERY entry was skipped. `valid` is now tri-state: True verified, False mismatch, **None unverifiable**. `None` rather than `False` deliberately — `False` claims tampering, which is an equally wrong and considerably louder lie, and a caller doing a plain truthiness test degrades to "not verified", the safe direction. `status` carries the precise verdict (`verified`, `verified_partial`, `tampered`, `unverifiable`, `empty_range`) and `skipped_unhashed` makes a late-enabled chain's permanent unhashed prefix visible instead of silently averaged over. The response also reports `hash_chain_enabled`, which is what made `checked: 0` uninterpretable. Trinity already does this correctly one endpoint over: `/api/monitoring/status` returns `enabled: false` beside a stale summary so the reader can interpret it. **The frontend had to change too.** The store did `data.valid ? 'valid' : 'invalid'`, so a backend-only fix would have turned a false green into a false RED — a tamper alarm for a log that is merely unhashed. It now branches on the tri-state and renders an amber "Unverifiable" badge. The store also had its own vacuous path, asserting `verifyState='valid'` for an empty list without calling the API at all; that is gone. tests/unit/test_1984_audit_verify_unverifiable.py — 16 checks, 15 of which fail against the pre-fix tree. Closes #1984
Resolve tests/registry.json (rebuild: dev entries + this PR's entry). Also sync the two stale surfaces the review found: - tests/test_audit_log_unit.py::test_verify_chain_empty_range still asserted the old vacuous valid=True empty-range contract this PR removes — it is not collected by PR CI (unit/ only) but breaks tests/run-core.sh on dev. - docs/memory/feature-flows/audit-trail.md verify example updated to the tri-state response shape.
vybe
approved these changes
Aug 4, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
Validated via /validate-pr — the fix itself is exemplary (honest tri-state semantics, 16-check named regression test across model/service/store/view; verified an all-unhashed range can never report valid:true). The one blocker from validation — tests/test_audit_log_unit.py::test_verify_chain_empty_range still asserting the old vacuous empty-range contract (invisible to PR CI which runs unit/ only, but breaks tests/run-core.sh on dev) — I fixed on the branch in the merge commit, plus the stale audit-trail.md response example. Full CI green post-rebase.
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.
Closes #1984 — a bug I found by probing the live instance for siblings of the #1966–#1971 batch.
Problem, as observed on real data
And in the UI: a green "✓ Valid · 0 entries" badge — the tick sitting directly beside the zero that contradicts it.
valid: truewas returned for three materially different states:valid: truevalid: truevalid: trueThe third is the default for every install that never enabled hashing — so the verdict was effectively independent of the input. That is the class
learnings.mdalready names from #1941: "when a check reports the SAME verdict for every input, that is the signal that it is broken."Skipping an unhashed entry is correct on its own — a chain enabled midway legitimately has an unhashed prefix. The defect was the aggregate verdict when every entry was skipped.
Fix
validbecomes tri-state:Trueverified ·Falsemismatch ·Noneunverifiable.Noneand notFalse, deliberately.Falseclaims tampering — an equally wrong and considerably louder lie that would page someone about a breach that did not happen. And a caller doing a plain truthiness test degrades to "not verified", which is the safe direction.statuscarries the precise verdict (verified/verified_partial/tampered/unverifiable/empty_range), andskipped_unhashedmakes a late-enabled chain's permanent unhashed prefix visible rather than silently averaged over — a partially-hashed range can no longer pass as fully verified.The response now also reports
hash_chain_enabled, which is precisely what madechecked: 0uninterpretable.Trinity already solves this correctly one endpoint over.
GET /api/monitoring/statusfaces the identical situation — a summary produced by a loop that is default-OFF (#1121) — and returnsenabled: falsebeside a stalelast_check_atso the reader can interpret the payload. This is that pattern applied where it was missing.The frontend had to change too
This is the part a backend-only fix would have got wrong. The store did:
So
valid: nullwould have rendered as "✗ Tamper detected" — trading a false green for a false red, which is worse. It now branches on the tri-state and renders an amber "Unverifiable · no hashes on N entries" badge.The store also carried its own copy of the bug: an empty entry list set
verifyState='valid', checked:0without calling the API at all. Also gone.Verification
tests/unit/test_1984_audit_verify_unverifiable.py— 16 checks, 15 of which fail against the pre-fix tree. 74 green across all audit-related suites.Scope of what I actually verified, stated precisely:
Two of my own mistakes are recorded in the test file rather than quietly fixed:
//comments before matching. This fix's own comments quote the olddata.valid ? … : …while explaining why it went, and an unstripped search failed on the explanation — the exact inverse of the bug(ci): nightly unit-suite reports a false merge conflict on every PR — shallow fetch on both sides leaves no common ancestor #1941 trap, where a comment made a naive search pass vacuously._compute_hashreads several fields with[]rather than.get, so the hasher raisedKeyErrorand it looked like a code bug. There is now one complete row builder.Acceptance criteria
valid: truevalid: true🤖 Generated with Claude Code