fix(bundle): verify per-member sha256 against manifest on import#75
Conversation
…chdev#74) import_check and import_apply previously trusted any tar member whose path appeared in manifest.json — the per-file sha256 was only enforced by export_check. A tampered tarball with an unchanged manifest could therefore land attacker-controlled content into the KB while the audit log recorded a clean bundle.import event with the legitimate bundle_id. Mirror the export_check hash comparison into import_check (so the diff API surfaces hash_mismatch issues) and re-verify at write time inside import_apply (defence in depth against a TOCTOU between the two opens of the tarball). Adds three tests against a malicious bundle whose tar member body diverges from the sha256 the manifest claims: - test_import_rejects_member_with_mismatched_sha256 - test_import_rejects_source_content_mismatch - test_import_check_passes_when_member_matches_manifest No on-disk-layout, schema, or bundle-format change. Honest bundles produced by vouch export remain accepted byte-for-byte.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
✅ Files skipped from review due to trivial changes (1)
📝 WalkthroughWalkthroughThis PR adds per-member sha256 verification to bundle import operations. ChangesBundle import integrity verification
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/vouch/bundle.py`:
- Around line 326-331: The code currently rechecks the manifest hash with
sha256_hex(body) vs recorded[member.name]["sha256"] and merely appends to
skipped and continues; change this to raise a RuntimeError immediately
(including context: member.name and both expected and actual hashes) to
hard-fail on write-time hash mismatch so import_apply cannot silently succeed
after a TOCTOU; update any surrounding logic in the import_apply/import_check
flow to not rely on skipped for this condition.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 913b5372-38ee-4d1b-a46c-23767f08e2fc
📒 Files selected for processing (3)
CHANGELOG.mdsrc/vouch/bundle.pytests/test_bundle.py
|
Nice fix — this is exactly the gap #74 was about, and you closed it the cleanest way possible by reusing the same hashing logic The core of it looks solid. Hashing each member in The one thing I'd really like your take on is the write-time re-verify at Two smaller things while you're in there. At |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/vouch/bundle.py (1)
251-278:⚠️ Potential issue | 🟠 Major | ⚡ Quick winGuard manifest
sha256access inimport_check/import_apply
src/vouch/bundle.pyindexesf["sha256"]/recorded[member.name]["sha256"]directly inimport_checkandimport_apply(lines ~251-278, ~312-333). If a bundle’smanifest.jsonomitssha256, the import raisesKeyErrorinstead of cleanly reportinghash mismatch/ skipping the member. Usef.get("sha256")(and similarly forrecorded[...]) and treat missing/invalid hashes as integrity failures.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/vouch/bundle.py` around lines 251 - 278, The import_check/import_apply flow currently indexes f["sha256"] and recorded[member.name]["sha256"] directly which raises KeyError if a manifest entry lacks a sha256; update the logic in import_check/import_apply to use f.get("sha256") and recorded[member.name].get("sha256") (or otherwise fetch via .get) and treat missing or non-string/invalid hashes as integrity failures by appending an issues entry like "hash mismatch: <path>" (or similar) and skipping the file; ensure sha256_hex is only called when a valid hash string is present and continue to call _validate_content for files that pass the hash check.
🧹 Nitpick comments (1)
tests/test_bundle.py (1)
158-189: ⚡ Quick winAdd malformed-manifest coverage for missing
sha256entries.Current regression coverage is strong for mismatched bytes, but it doesn’t lock in behavior when a manifest file entry omits
sha256entirely. Add one test to ensure import rejects cleanly (instead of surfacing a rawKeyError) for that malformed-manifest case.🧪 Suggested additional regression test
+def test_import_rejects_member_missing_sha256(store: KBStore, tmp_path: Path) -> None: + payload = b"text: original\n" + bundle_path = tmp_path / "missing-sha256.tar.gz" + manifest = { + "spec": bundle.SPEC_VERSION, + "bundle_id": "deadbeef", + "files": [{"path": "claims/c1.yaml", "size": len(payload)}], # no sha256 + "counts": {}, + "safety": {"has_proposed": False, "has_state_db": False, "has_audit_log": False}, + } + with tarfile.open(bundle_path, "w:gz") as tar: + info = tarfile.TarInfo("claims/c1.yaml") + info.size = len(payload) + tar.addfile(info, io.BytesIO(payload)) + mf_bytes = json.dumps(manifest).encode() + mf_info = tarfile.TarInfo(bundle.MANIFEST_NAME) + mf_info.size = len(mf_bytes) + tar.addfile(mf_info, io.BytesIO(mf_bytes)) + + diff = bundle.import_check(store.kb_dir, bundle_path) + assert not diff.ok + assert any("hash mismatch" in i for i in diff.issues) + with pytest.raises(RuntimeError, match="hash mismatch"): + bundle.import_apply(store.kb_dir, bundle_path)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_bundle.py` around lines 158 - 189, Add a new test that builds a tar.gz using the same pattern as _write_hash_mismatched_bundle but whose manifest file entry for the member omits the "sha256" key; write the member payload and a manifest (bundle.MANIFEST_NAME) whose file entry includes "path" and "size" but no "sha256", then call the import routine (e.g., bundle.import_bundle or the same import helper used by other tests) and assert it raises a clean validation error (e.g., a ValueError or the library's bundle-validation exception) rather than surfacing a raw KeyError; implement the helper alongside _write_hash_mismatched_bundle (or inline in the new test) and add a test function that uses it and verifies the import fails with the expected validation exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/vouch/bundle.py`:
- Around line 251-278: The import_check/import_apply flow currently indexes
f["sha256"] and recorded[member.name]["sha256"] directly which raises KeyError
if a manifest entry lacks a sha256; update the logic in
import_check/import_apply to use f.get("sha256") and
recorded[member.name].get("sha256") (or otherwise fetch via .get) and treat
missing or non-string/invalid hashes as integrity failures by appending an
issues entry like "hash mismatch: <path>" (or similar) and skipping the file;
ensure sha256_hex is only called when a valid hash string is present and
continue to call _validate_content for files that pass the hash check.
---
Nitpick comments:
In `@tests/test_bundle.py`:
- Around line 158-189: Add a new test that builds a tar.gz using the same
pattern as _write_hash_mismatched_bundle but whose manifest file entry for the
member omits the "sha256" key; write the member payload and a manifest
(bundle.MANIFEST_NAME) whose file entry includes "path" and "size" but no
"sha256", then call the import routine (e.g., bundle.import_bundle or the same
import helper used by other tests) and assert it raises a clean validation error
(e.g., a ValueError or the library's bundle-validation exception) rather than
surfacing a raw KeyError; implement the helper alongside
_write_hash_mismatched_bundle (or inline in the new test) and add a test
function that uses it and verifies the import fails with the expected validation
exception.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 33c98b9f-0245-4f24-bb16-54e98cf941a5
📒 Files selected for processing (2)
src/vouch/bundle.pytests/test_bundle.py
…w on vouchdev#75 Review feedback from vouchdev#75: - import_apply now raises RuntimeError("hash mismatch at write time") instead of silently appending to skipped and still emitting a clean bundle.import audit event when the write-time re-verify fails. The only way to reach that branch is mid-import tampering, and the prior skip-and-log-success behaviour was the exact audit-truthfulness anti-pattern vouchdev#74 was filed to fix. - recorded[member.name]["sha256"] (three sites in bundle.py) replaced with .get("sha256") so a hand-crafted manifest entry missing the field is reported as a clean mismatch rather than raising a bare KeyError mid-import. - Two new regression tests in tests/test_bundle.py: * test_import_apply_raises_on_write_time_hash_mismatch — uses monkeypatch to make import_check report clean, then asserts import_apply raises and that audit.log.jsonl does NOT contain a bundle.import event. * test_import_treats_missing_manifest_sha256_as_mismatch — builds a bundle whose manifest entry omits sha256 and asserts both import_check and import_apply surface a hash mismatch. CHANGELOG updated to reflect the new raise-on-mismatch semantics.
…a256-verification Brings in vouchdev#60 (FTS5 indexing of crystallize summary page) and vouchdev#55 onboarding seed from main. CHANGELOG conflict resolved by keeping both the incoming vouchdev#60 entry and the local vouchdev#74 sha256-verification entry.
|
Thanks for the careful review — agreed on all three points. Write-time mismatch now raises. Regression test
Missing-from-tarball gap → follow-up #80. Out of scope here
|
|
LGTM! Thanks |
Resolves conflicts in CHANGELOG.md, tests/test_sessions.py, and auto-merges src/vouch/sessions.py with the recently-merged: - vouchdev#61 (FTS5 indexing of crystallize summary page) — adds index_db.index_page(...) right after store.put_page(...) inside crystallize. Composes cleanly with this PR's strict-derivation _build_summary_body and the summary_page_id audit fix. - vouchdev#62 (test_crystallize_collects_approval_failures) — preserved as a sibling test. - vouchdev#75 (bundle import sha256 verification) — CHANGELOG entry only. - vouchdev#73 (bundle POSIX separators) — CHANGELOG entry only. Both regression tests added by this PR (test_crystallize_summary_page_does_not_leak_agent_controlled_fields and test_crystallize_audit_event_records_summary_page_id) still pass against the merged sessions.crystallize.
Problem
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 onlyenforced by
export_check; the import side trusts whatever is inthe tarball as long as the path appears in the manifest and the
schema parses.
An attacker (or accidental disk corruption) who substitutes a tar
member's bytes without re-signing
manifest.jsoncan landarbitrary content into a
.vouch/KB:bundle.importevent with thelegitimate
bundle_id— no trace that the bytes ever differedfrom the manifest.
payload that passes
pydanticvalidation. Claim text, confidence,status, citations, page bodies, entity attributes, relation
targets — all attacker-controlled in a single shipped bundle.
sources/<sha>/contentfiles_validate_contentskipsvalidation entirely, so source bytes can be replaced with
anything.
Source.sha256inmeta.yamlremains the legitimatehash, 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. The asymmetry:
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 member onlyto feed
_validate_content; never hashes, never compares tomanifest.
src/vouch/bundle.py:279—import_applyreads each member andwrites it after schema validation; the manifest sha256 is loaded
into
recorded[...]["sha256"]but only consulted to decidewhether the destination file conflicts under
on_conflict="skip". It is never compared to the bundlemember.
Fix
Mirror the existing
export_checkhash logic into bothimport_checkand
import_apply:import_check— for every tar member that appears in the manifest,compare
sha256_hex(member_bytes)tomanifest_entry["sha256"]and append
hash mismatch: <name>on disagreement. This makes theokfield honest and is what callers of the diff API rely on.import_apply— re-verify the same hash immediately beforedest.write_bytes(body)and skip on disagreement. This is defencein depth against a TOCTOU between
import_checkand the re-openinside
import_apply. Becauseimport_applyalready callsimport_checkand raises on any issue, the practical effect isthat tampered bundles raise
RuntimeError("refusing to import: hash mismatch: ...")before writing.No on-disk-layout, schema, or bundle-format change. Honest bundles
produced by
vouch exportremain byte-for-byte accepted; theexisting happy-path tests still pass on Linux/macOS.
Tests
Added three regression tests in
tests/test_bundle.py:test_import_rejects_member_with_mismatched_sha256— manifestdeclares one hash, tar member contains different bytes;
import_check.okisFalse,import_applyraisesRuntimeError("hash mismatch: claims/c1.yaml"), and no file iswritten to the destination KB.
test_import_rejects_source_content_mismatch— same attackagainst
sources/<sha>/content, which_validate_contentexplicitly skips, so the sha256 check is the only protection.
test_import_check_passes_when_member_matches_manifest— sanity:matching bytes do not surface as
hash mismatch.All three pass on Windows (Python 3.12.13). The full suite shows
the same pre-existing failures on Windows that exist without this
change (#72 path-separator bug on bundle export;
O_NOFOLLOWusein
storage.py) — both untouched by this PR.End-to-end demonstration with a malicious bundle constructed
independently of
bundle.export:Fixes #74
Summary by CodeRabbit