Skip to content

security: empty-quote receipts clear the mechanical auto-approve gate, landing forged claims with zero evidence #654

Description

@philluiz2323

What happened

verify_receipt() and verify_evidence() in src/vouch/receipts.py both
guard on quote is None, not on an empty string:

# verify_receipt
start, end, quote = evidence.byte_start, evidence.byte_end, evidence.quote
if start is None or end is None or quote is None:
    return ReceiptResult(ReceiptStatus.NO_RECEIPT, "no byte-offset span")
if start > end or end > len(source_bytes):
    return ReceiptResult(ReceiptStatus.FORGED, ...)
try:
    span = source_bytes[start:end].decode("utf-8")
except UnicodeDecodeError:
    return ReceiptResult(ReceiptStatus.FORGED, "span does not decode as utf-8")
if span != quote:
    return ReceiptResult(ReceiptStatus.FORGED, "span does not match quote")
return ReceiptResult(ReceiptStatus.VERIFIED)

For quote="" and any zero-length span (byte_start == byte_end, e.g.
0, 0), source_bytes[0:0] decodes to "", which trivially string-equals
the empty quote → VERIFIED. This holds regardless of source content or
length, even against an empty source.

The module's own docstring states the entire premise this breaks: "This is
what lets the review gate become arithmetic instead of a person: a
citation whose receipt verifies can be auto-approved... The quoted span is
in the source at those offsets or it is not." An empty receipt proves
nothing, yet is scored identically to a real one.

verify_evidence() carries the identical gap (evidence.quote is None)
ahead of its own source-read, so a missing source plus an empty quote is
misreported as FORGED ("source not in kb") rather than the more
fundamentally correct NO_RECEIPT ("nothing to compare in the first
place").

Evidence.quote has no min-length constraint at the model layer
(quote: str | None = None in models.py, no Field length bound), so
nothing upstream of verify_receipt closes this off.

What you expected

An empty quote should be treated the same as no receipt at all
(NO_RECEIPT), exactly as locate_span() already does on the mint
side (if not needle: return None) — the verify side never got the
matching guard.

Reproduction

Unit level:

from vouch.models import Evidence
from vouch.receipts import verify_receipt
source = b"the quick brown fox jumps over the lazy dog"
ev = Evidence(id="e1", source_id="s1", locator="b0-0", quote="", byte_start=0, byte_end=0)
print(verify_receipt(ev, source).status)  # 'verified' — should be 'no_receipt'

Full end-to-end, no human in the loop (the capture.py-documented
starter-config default review.auto_approve_on_receipt: true):

import tempfile, pathlib
from vouch.storage import KBStore
from vouch.models import Evidence
from vouch import proposals

d = pathlib.Path(tempfile.mkdtemp())
store = KBStore.init(d)
store.config_path.write_text(
    store.config_path.read_text(encoding="utf-8") + "\nreview:\n  auto_approve_on_receipt: true\n",
    encoding="utf-8",
)
src = store.put_source(b"the quick brown fox jumps over the lazy dog", title="t")
ev = store.put_evidence(Evidence(id="forged-empty", source_id=src.id, locator="b0-0",
                                  quote="", byte_start=0, byte_end=0))
pr = proposals.propose_claim(store, text="the moon is made of cheese and nobody can stop me",
                              evidence=[ev.id], proposed_by="attacker-agent")
print(list(proposals.auto_approve_receipts(store, actor="vouch-auto")))
for c in store.list_claims():
    print(c.id, c.status, c.text)

Output: the claim lands with status working — durable, approved, zero
real evidentiary backing, and no human ever reviewed it.

store.put_evidence() only checks the cited source exists; it never
re-verifies the receipt. bundle.import_as_proposals /
sync._sync_apply_as_proposals write incoming Evidence.model_validate(...)
objects straight to put_evidence 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 — this isn't only
reachable through the normal propose path (which already can't trigger it,
since proposals.py builds evidence via receipt_for_quote
locate_span's existing guard).

Environment

  • vouch version: test branch @ current HEAD
  • Python version: 3.11+
  • OS: any
  • Host: any — reachable via bundle import, sync, or any code path that can
    register an Evidence directly

.vouch/ state

Not required to reproduce — the repro above uses a fresh temp KB.

Anything else

This exact bug and fix were previously identified and correctly fixed in
#513 (CodeRabbit-approved, all substantive checks green) — but that PR was
closed unmerged on 2026-07-29 purely for going stale against a fast-moving
test branch (CHANGELOG.md conflict), not for anything wrong with the
change itself; the maintainer's closing comment explicitly invited a
rebase or a fresh PR: "this isn't a judgement on the change itself... happy
to look again." Notably, #513's CI also hit the fork-PR coderabbit-gate
403 permission bug (#511/#512) along the way, which likely contributed to
the review friction that let it go stale. Re-verified independently against
current test HEAD — the fix never landed, so the bug remains live.

Suggested fix: change both guards from quote is None (verify_receipt)
and evidence.quote is None (verify_evidence) to not quote, so an
empty string is treated identically to None. Add regression tests
mirroring the existing test_locate_span_returns_none_for_empty_quote
for both verify_receipt/verify_evidence directly and for
evaluate_claim_receipts end-to-end, so the mint-time and verify-time
guards stay in lockstep.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions