From 1c3c66d467a21c066cb7a08d9be4e1d5e762ab7f Mon Sep 17 00:00:00 2001 From: galuis116 Date: Thu, 30 Jul 2026 10:10:12 -0700 Subject: [PATCH] fix(receipts): reject empty-quote receipts on the verify side too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_receipt and verify_evidence both guarded on quote is None, not an empty string. quote="" paired with a zero-length span (byte_start == byte_end) decodes source_bytes[start:end] to "", which trivially string-equals the empty quote and returned VERIFIED regardless of source content or length. Evidence.quote carries no min-length constraint at the model layer, and bundle/sync intake write incoming Evidence straight to disk after schema validation only, so a hand-crafted bundle or a malicious federation peer can plant a forged empty-quote Evidence and cite it from an inbound claim. With review.auto_approve_on_receipt (the starter-config default), a claim citing only that receipt cleared evaluate_claim_receipts and landed as a durable, approved claim with zero real evidentiary backing and no human review. locate_span already refuses to mint a receipt for an empty quote (`if not needle: return None`) — the verify side never got the matching guard. Both now use `not quote`, treating "" the same as None. this exact fix was previously submitted and CodeRabbit-approved as #513, but that PR was closed unmerged for going stale against a fast-moving test branch, not for anything wrong with the change; the maintainer's closing comment explicitly invited a fresh PR. Fixes #654 --- CHANGELOG.md | 16 ++++++++++++++++ src/vouch/receipts.py | 4 ++-- tests/test_receipts.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 886ab728..a7e1407a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,22 @@ All notable changes to vouch are documented here. Format follows artifact the caller could not already retrieve, and it touches no write path. ### Fixed +- **security: empty-quote receipts no longer clear the auto-approve gate** + (#513 reopened, root-caused): `verify_receipt` and `verify_evidence` both + guarded only on `quote is None`, not an empty string. An `Evidence` with + `quote=""` and a zero-length span (`byte_start == byte_end`) decodes to + `""`, trivially string-equals the empty quote, and returned `VERIFIED` — + so a claim citing only a forged, content-free receipt cleared + `evaluate_claim_receipts` and, with `review.auto_approve_on_receipt` + (the starter-config default), landed as a durable, approved claim with + zero real evidentiary backing. `Evidence.quote` carries no min-length + constraint at the model layer and `bundle`/`sync` intake write incoming + Evidence straight to disk after schema validation only, so this was + reachable from a hand-crafted bundle or a malicious federation peer, not + just the normal propose path (which already routes through + `locate_span`'s existing empty-quote guard on the *mint* side). Both + guards now reject an empty quote the same way `locate_span` already + does, closing the gap on the *verify* side. - **`kb.explain_ranking` no longer leaks status-filtered candidate text** (#650): a retracted/superseded/redacted claim or archived page correctly reported `gate: "status-filtered"`, but its `summary` was still sourced diff --git a/src/vouch/receipts.py b/src/vouch/receipts.py index 00ac10a1..12416ff3 100644 --- a/src/vouch/receipts.py +++ b/src/vouch/receipts.py @@ -58,7 +58,7 @@ def verify_receipt(evidence: Evidence, source_bytes: bytes) -> ReceiptResult: when the decoded span equals the quote exactly. """ start, end, quote = evidence.byte_start, evidence.byte_end, evidence.quote - if start is None or end is None or quote is None: + if start is None or end is None or not quote: return ReceiptResult(ReceiptStatus.NO_RECEIPT, "no byte-offset span") if start > end or end > len(source_bytes): return ReceiptResult( @@ -184,7 +184,7 @@ def verify_evidence(store: KBStore, evidence: Evidence) -> ReceiptResult: """ from .storage import ArtifactNotFoundError - if evidence.byte_start is None or evidence.byte_end is None or evidence.quote is None: + if evidence.byte_start is None or evidence.byte_end is None or not evidence.quote: return ReceiptResult(ReceiptStatus.NO_RECEIPT, "no byte-offset span") try: source_bytes = store.read_source_content(evidence.source_id) diff --git a/tests/test_receipts.py b/tests/test_receipts.py index 23c30eb0..573251ef 100644 --- a/tests/test_receipts.py +++ b/tests/test_receipts.py @@ -98,6 +98,16 @@ def test_no_receipt_when_quote_absent() -> None: assert result.status is ReceiptStatus.NO_RECEIPT +def test_no_receipt_when_quote_is_empty_string() -> None: + # an empty quote with a zero-length span decodes to "" and trivially + # equals the quote — the gate must treat this as nothing to compare, + # never as a verified receipt for an attacker-chosen claim. + ev = _ev(quote="", byte_start=0, byte_end=0) + result = verify_receipt(ev, SOURCE) + assert result.status is ReceiptStatus.NO_RECEIPT + assert result.verified is False + + def test_receipt_uses_byte_offsets_not_char_offsets() -> None: # "café — au lait": 'é' is 2 bytes (0xc3 0xa9), '—' is 3 bytes (em dash). # "au lait" starts at char index 7 but byte index 10. A char-offset @@ -160,6 +170,30 @@ def test_verify_evidence_not_verified_when_source_missing(store: KBStore) -> Non assert result.verified is False +def test_verify_evidence_no_receipt_for_empty_quote(store: KBStore) -> None: + src = store.put_source(b"the quick brown fox", title="t") + ev = Evidence( + id="e4", source_id=src.id, locator="b0-0", + quote="", byte_start=0, byte_end=0, + ) + assert verify_evidence(store, ev).status is ReceiptStatus.NO_RECEIPT + + +def test_claim_gate_rejects_forged_empty_quote_receipt(store: KBStore) -> None: + # end-to-end: a claim citing only an empty-quote, zero-length-span + # Evidence must never clear the mechanical auto-approve gate, even + # though `source_bytes[0:0] == ""` trivially string-equals the quote. + src = store.put_source(b"the quick brown fox", title="t") + ev = store.put_evidence( + Evidence( + id="forged-empty", source_id=src.id, locator="b0-0", + quote="", byte_start=0, byte_end=0, + ) + ) + verdict = evaluate_claim_receipts(store, [ev.id]) + assert verdict.approve is False + + # ---- the quote step: locate a span, or drop what cannot be quoted ----