You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
bundle.import_check and bundle.import_apply never verify each
tar member's content against the per-file sha256 recorded in manifest.json. The integrity guarantee that the README markets
("tar.gz + manifest.json with per-file sha256") is therefore only
enforced by export_check; the import side trusts whatever is
in the tarball as long as the path appears in the manifest and the
schema parses.
An attacker (or an accidental disk-corruption event) who substitutes
a tar member's bytes without re-signing the manifest can land
arbitrary content into a .vouch/ KB:
The audit log records a successful bundle.import event with
the legitimate bundle_id — there is no trace that the bytes
ever differed from the manifest.
For YAML/markdown artifacts the attacker only has to ship a
payload that passes pydantic validation. Claim text,
confidence, status, citations, page bodies, entity attributes,
relation targets — all are attacker-controlled in a single
shipped bundle.
For sources/<sha>/content files _validate_content skips
validation entirely, so source content can be replaced with
arbitrary bytes. Source.sha256 in meta.yaml remains the
legitimate hash, so the resulting KB is internally inconsistent
(vouch source verify would now fail), but the import itself
succeeds.
This defeats the documented integrity story of vouch's portable
bundle format on every platform.
What you expected
vouch import-check and vouch import-apply reject any bundle
whose member bytes don't hash to the per-file sha256 in manifest.json — the same check that vouch export-check already
performs. import_apply raises before writing a single byte when
the bundle has been tampered with.
Reproduction
$ python tamper_repro.py
work dir: /tmp/vouch-tamper-XXXXXXXX
original claims/c1.yaml sha256: bccff52f5cf3cf9e...
tampered claims/c1.yaml sha256: bb996b9aa4bd7058...
export_check(tampered): ok=False
issues: ['hash mismatch: claims/c1.yaml']
import_check(tampered): ok=True
issues: []
import_apply(tampered): wrote 4 files
landed c1.text: 'TAMPERED the sky is red'
landed claims/c1.yaml sha256: bb996b9aa4bd7058...
manifest sha256 for that path: bccff52f5cf3cf9e...
BUG CONFIRMED: import_apply wrote attacker content; on-disk hash
does not match the manifest sha256.
tamper_repro.py exports a one-claim bundle, rewrites the tarball
so that claims/c1.yaml contains a different (but schema-valid)
claim body, leaves manifest.json byte-for-byte unchanged, and
imports into a fresh KB. The substituted text lands in the
durable artifact and the audit log records a clean bundle.import event with the original bundle_id.
The asymmetry is at:
src/vouch/bundle.py:139 — export_check reads each member,
hashes it, and reports hash mismatch: <name> on disagreement.
src/vouch/bundle.py:231 — import_check reads each member
only to feed _validate_content (schema check), never hashes,
never compares to manifest.
src/vouch/bundle.py:279 — import_apply reads each member
and writes it after schema validation; the manifest sha256
is loaded into recorded[...]["sha256"] but only consulted to
decide whether the destination file conflicts under on_conflict="skip". It is never compared to the bundle
member.
OS: Windows 10 Pro 10.0.19045 (platform-independent — same on Linux/macOS)
Host: standalone CLI / direct vouch.bundle Python API
.vouch/ state
$ vouch doctor
Not relevant — the gap is in the import code path itself and
reproduces against a fresh .vouch/ initialised by the test.
Anything else
Suggested fix
In src/vouch/bundle.py, mirror the existing export_check
hash-verification logic into both import_check and import_apply:
import_check should, for every tar member that appears in the
manifest, compare sha256_hex(member_bytes) to manifest_entry["sha256"] and append a hash mismatch: <name>
issue on disagreement. (This makes the ok field honest.)
import_apply should re-verify the same hash immediately before dest.write_bytes(body) and skip-and-issue on disagreement —
defence in depth against a TOCTOU between import_check and
the re-open inside import_apply. Because import_apply already
calls import_check and raises on any issue, the practical effect
is that tampered bundles raise RuntimeError("refusing to import: hash mismatch: ...") before writing.
Why it's worth fixing
This is the only check that separates "the bundle vouch
produced" from "any tar.gz with the same path layout." Without
it, manifest.sha256 is decorative.
Affects every platform and every host (CLI, MCP, JSONL) that
calls kb.import_apply — not a platform-specific edge case.
The fix is small (one hash call per member in two functions),
has zero impact on Linux/macOS/Windows bundles produced and
imported on the same machine (the manifest hashes already
match), and is testable end-to-end with the repro above.
Defensible audit story: after the fix, the audit log of a
successful import is a true statement; today it is not.
What happened
bundle.import_checkandbundle.import_applynever verify eachtar member's content against the per-file
sha256recorded inmanifest.json. The integrity guarantee that the README markets("tar.gz + manifest.json with per-file sha256") is therefore only
enforced by
export_check; the import side trusts whatever isin the tarball as long as the path appears in the manifest and the
schema parses.
An attacker (or an accidental disk-corruption event) who substitutes
a tar member's bytes without re-signing the manifest can land
arbitrary content into a
.vouch/KB:bundle.importevent withthe legitimate
bundle_id— there is no trace that the bytesever differed from the manifest.
payload that passes
pydanticvalidation. Claim text,confidence, status, citations, page bodies, entity attributes,
relation targets — all are attacker-controlled in a single
shipped bundle.
sources/<sha>/contentfiles_validate_contentskipsvalidation entirely, so source content can be replaced with
arbitrary bytes.
Source.sha256inmeta.yamlremains thelegitimate hash, so the resulting KB is internally inconsistent
(
vouch source verifywould now fail), but the import itselfsucceeds.
This defeats the documented integrity story of vouch's portable
bundle format on every platform.
What you expected
vouch import-checkandvouch import-applyreject any bundlewhose member bytes don't hash to the per-file
sha256inmanifest.json— the same check thatvouch export-checkalreadyperforms.
import_applyraises before writing a single byte whenthe bundle has been tampered with.
Reproduction
tamper_repro.pyexports a one-claim bundle, rewrites the tarballso that
claims/c1.yamlcontains a different (but schema-valid)claim body, leaves
manifest.jsonbyte-for-byte unchanged, andimports into a fresh KB. The substituted text lands in the
durable artifact and the audit log records a clean
bundle.importevent with the originalbundle_id.The asymmetry is at:
src/vouch/bundle.py:139—export_checkreads each member,hashes it, and reports
hash mismatch: <name>on disagreement.src/vouch/bundle.py:231—import_checkreads each memberonly to feed
_validate_content(schema check), never hashes,never compares to
manifest.src/vouch/bundle.py:279—import_applyreads each memberand writes it after schema validation; the manifest
sha256is loaded into
recorded[...]["sha256"]but only consulted todecide whether the destination file conflicts under
on_conflict="skip". It is never compared to the bundlemember.
Environment
vouch, version 0.0.1(commitc3accb6, on top of fix: enforce forbidden_self_approval gate in proposals.approve() #46)Python 3.12.13vouch.bundlePython API.vouch/stateNot relevant — the gap is in the import code path itself and
reproduces against a fresh
.vouch/initialised by the test.Anything else
Suggested fix
In
src/vouch/bundle.py, mirror the existingexport_checkhash-verification logic into both
import_checkandimport_apply:import_checkshould, for every tar member that appears in themanifest, compare
sha256_hex(member_bytes)tomanifest_entry["sha256"]and append ahash mismatch: <name>issue on disagreement. (This makes the
okfield honest.)import_applyshould re-verify the same hash immediately beforedest.write_bytes(body)and skip-and-issue on disagreement —defence in depth against a TOCTOU between
import_checkandthe re-open inside
import_apply. Becauseimport_applyalreadycalls
import_checkand raises on any issue, the practical effectis that tampered bundles raise
RuntimeError("refusing to import: hash mismatch: ...")before writing.Why it's worth fixing
produced" from "any tar.gz with the same path layout." Without
it,
manifest.sha256is decorative.traversal fix (Path Traversal in Bundle Import — Arbitrary File Write (Critical) #9 → PR fix: validate bundle content against Pydantic models before import (#13) #34); the team already invested in
hardening this surface and this is the missing companion check.
calls
kb.import_apply— not a platform-specific edge case.has zero impact on Linux/macOS/Windows bundles produced and
imported on the same machine (the manifest hashes already
match), and is testable end-to-end with the repro above.
successful import is a true statement; today it is not.
Checked for duplicates
vouchdev/vouchmatchingsha256|hash|integrity|manifest: Bundle import writes unvalidated content to committed artifact directories #13 (closed, addressed byPR fix: validate bundle content against Pydantic models before import (#13) #34 — pydantic schema validation only, no hash check), Path Traversal in Bundle Import — Arbitrary File Write (Critical) #9
(closed — path traversal, different concern), Artifact ID collision causes silent data loss on approval #12 (closed —
artifact ID collision, different concern). None propose the
per-member hash check.
vouchdev/vouch: PR fix: validate bundle content against Pydantic models before import (#13) #34 (merged) addedschema validation; PR fix: catch ArtifactNotFoundError in verify (#30) and validate bundle content on import (#13) #33 (merged) added related error
handling. Neither touches
sha256verification in the importpath.