Skip to content

Verify content hashes on every get_raw() read #110

Closed

Description

@tonibergholm

Question

Fix audit finding #3 (high): both object stores verify content-addressed hashes only in get(), but restore and manifest paths use get_raw(), which returns bytes without verification — a corrupted local cache/disk or a GCS writer with bucket access can alter a segment/manifest/blob while retaining its object key. This falsifies the "atomic rollback is honest" core claim.

Scope:

  • Hash-check every get_raw() result before returning or parsing, in both stores, including cache hits and decompressed GCS data; never cache a failed verification (evidence: crates/agentic-core/src/store.rs:151, crates/agentic-core/src/gcs_store.rs:358, crates/agentic-memory/src/restore.rs:70).
  • Regression tests: tampered blob / segment / manifest rejected on read, on both store impls.
  • Re-check snapshot-model §9 perf targets after the change (rollback < 5s, commit < 2s, write overhead < 5ms p99) — hashing on the restore path adds CPU to exactly the path the demo promises is fast.

Blocked by the Postgres CI job ticket so its restore-path tests run in CI.

Resolution records: PR link, perf numbers before/after, test names.

Activity

  1. added
    ready-for-agentScoped and unambiguous enough for an agent to pick up
    wayfinder:taskWayfinder ticket: work that unblocks a decision or lands a fix
    on Jul 9, 2026
  2. self-assigned this
    on Jul 9, 2026
  3. tonibergholm commented on Jul 9, 2026

    @tonibergholm
    MemberAuthor

    Resolution

    Shipped as PR #120 (pending Toni's review/merge per the map's PR gate).

    What landed: a single check_integrity chokepoint in agentic-core::store — get() verifies object.hash(), get_raw() verifies Hash::of(bytes) (the two distinct addressing schemes, which is exactly why naive verification in get_raw would have broken get()). FS store gets the raw check directly. GCS store's get()/get_raw() share a fetch_checked(verify) helper enforcing the audit's two clauses: poisoned cache hit → evict and self-heal from GCS; corrupt download → rejected before cache_write (never cache a failed verification). No wire change — Error::IntegrityError already classifies non-retryable.

    Demo-safety check (advisor-flagged): confirmed every get_raw caller reads segments/manifests (raw-hash addressed); the demo's prompt/tools/model write-back uses get() via load_blob/load_tree, so the new raw invariant can't break the broken-prompt demo.

    Tests: FS tampered-on-disk rejection; GCS poisoned-cache eviction (deterministic, no server); GCS corrupt-download rejected-and-not-cached (fake-gcs integration — ran the full suite against a live fake-gcs, all 5 pass). Workspace tests, clippy -D warnings, fmt green.

    Perf (before/after deliverable): added cost is one BLAKE3 pass per get_raw — 4 KB → +3.4 µs, 64 KB → +40 µs, 1 MB → +488 µs (~0.5 ms/MiB @ ~2 GB/s). Sub-ms at demo scale vs a 5 s rollback budget dominated by Postgres INSERT replay; §9 targets unaffected. Recorded in docs/architecture/benchmarks.md.

  4. added a commit that references this issue on Jul 10, 2026
    40e5d2d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

ready-for-agentScoped and unambiguous enough for an agent to pick upwayfinder:taskWayfinder ticket: work that unblocks a decision or lands a fix

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions