Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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.
- **`notify sweep`'s backlog alert re-arms after dropping below threshold**
(#652): the `queue.backlogged` idempotence marker was a one-way latch,
cleared only when the pending queue reached exactly zero — not when it
Expand Down
4 changes: 2 additions & 2 deletions src/vouch/receipts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions tests/test_receipts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ----


Expand Down
Loading