Author SHA1 Message Date
ClawHDF5 Research AgentandClaude Sonnet 5 817c5eee41 security+perf: SHA-256 memory provenance hash, O(1) knowledge-graph adjacency
INT-01: MemoryProvenance.content_hash was an unkeyed FNV-1a 64-bit hash,
which has no collision resistance -- an adversary could cheaply craft
different poisoned memory content matching an already-recorded hash,
undermining the "poisoning resistance" the provenance store exists to
provide. Switch to SHA-256 hex digests via the existing, default-on
clawhdf5-format::provenance::sha256_hex helper (already a dependency,
already used for on-disk dataset provenance) -- zero new deps.

INT-02: KnowledgeCache::bfs_neighbors and ::spreading_activation did a
full linear scan over all relations for every node visited/activated
(O(V*R) and O(steps*V*R) respectively), plus an O(n) get_entity scan per
discovered neighbour. Both now build a per-call adjacency index once
(O(V+R)) and use it for O(1) neighbour/entity lookups inside the
traversal loop. Built fresh per call rather than cached on the struct
since schema.rs's deserialization path pushes into the public
entities/relations vecs directly, which would make a cached index go
stale.

research/IMPLEMENTATION_BRIEF.md documents the audit (including bounds-
checking and BM25/HNSW areas found already hardened by prior tiers) and
what was deliberately deferred.

cargo test --workspace: 0 failures.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-16 21:00:54 +00:00
3 changed files with 276 additions and 491 deletions
+56 -26
View File
@@ -329,6 +329,47 @@ impl KnowledgeCache {
(id, true) (id, true)
} }
// -----------------------------------------------------------------------
// Adjacency index (built fresh per traversal call — see doc comment)
// -----------------------------------------------------------------------
/// Build an O(V+R) adjacency index for one traversal call: an entity-id →
/// vec-index map for O(1) entity lookups, and an entity-id →
/// `(neighbour_id, relation_weight)` map (covering both outgoing and
/// incoming edges) for O(1) neighbour expansion. The weight is carried
/// alongside each neighbour so callers like `spreading_activation` that
/// need per-edge weight don't have to re-scan `relations`.
///
/// This is rebuilt at the start of every `bfs_neighbors`/
/// `spreading_activation` call rather than cached on the struct: `entities`
/// and `relations` are public fields, and `schema.rs`'s deserialization
/// path pushes into them directly (bypassing `add_entity`/`add_relation`),
/// so a struct-cached index could go stale. Building it once per call
/// still turns an O(V·R) (or O(steps·V·R)) traversal into O(V+R) (or
/// O(steps·(V+E))), since the old code repeated the O(R) relation scan
/// once per visited node instead of once per call.
fn build_adjacency(&self) -> (HashMap<u64, usize>, HashMap<u64, Vec<(u64, f32)>>) {
let mut entity_index: HashMap<u64, usize> = HashMap::with_capacity(self.entities.len());
for (i, e) in self.entities.iter().enumerate() {
entity_index.insert(e.id, i);
}
// Note: a self-loop relation (src == tgt) contributes a single
// neighbour entry, not two, matching the if/else-if (not two
// independent ifs) structure this replaces — otherwise a self-loop
// would be double-counted by `spreading_activation`.
let mut adjacency: HashMap<u64, Vec<(u64, f32)>> =
HashMap::with_capacity(self.relations.len());
for r in &self.relations {
adjacency.entry(r.src).or_default().push((r.tgt, r.weight));
if r.tgt != r.src {
adjacency.entry(r.tgt).or_default().push((r.src, r.weight));
}
}
(entity_index, adjacency)
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Graph traversal: BFS neighbors // Graph traversal: BFS neighbors
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -337,6 +378,8 @@ impl KnowledgeCache {
/// together with their discovered depth. The seed entity itself is NOT /// together with their discovered depth. The seed entity itself is NOT
/// included. Traversal follows both outgoing and incoming relation edges. /// included. Traversal follows both outgoing and incoming relation edges.
pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> { pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> {
let (entity_index, adjacency) = self.build_adjacency();
let mut visited: HashSet<u64> = HashSet::new(); let mut visited: HashSet<u64> = HashSet::new();
let mut queue: VecDeque<(u64, usize)> = VecDeque::new(); let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
let mut results: Vec<(Entity, usize)> = Vec::new(); let mut results: Vec<(Entity, usize)> = Vec::new();
@@ -349,25 +392,15 @@ impl KnowledgeCache {
continue; continue;
} }
// Collect neighbour IDs from outgoing and incoming edges. let Some(neighbours) = adjacency.get(&current_id) else {
let neighbours: Vec<u64> = self continue;
.relations };
.iter()
.filter_map(|r| {
if r.src == current_id {
Some(r.tgt)
} else if r.tgt == current_id {
Some(r.src)
} else {
None
}
})
.collect();
for neighbour_id in neighbours { for &(neighbour_id, _weight) in neighbours {
if visited.insert(neighbour_id) if visited.insert(neighbour_id)
&& let Some(entity) = self.get_entity(neighbour_id) && let Some(&idx) = entity_index.get(&neighbour_id)
{ {
let entity = &self.entities[idx];
results.push((entity.clone(), depth + 1)); results.push((entity.clone(), depth + 1));
queue.push_back((neighbour_id, depth + 1)); queue.push_back((neighbour_id, depth + 1));
} }
@@ -439,6 +472,8 @@ impl KnowledgeCache {
min_activation: f32, min_activation: f32,
max_steps: usize, max_steps: usize,
) -> Vec<(u64, f32)> { ) -> Vec<(u64, f32)> {
let (_entity_index, adjacency) = self.build_adjacency();
let mut activation: HashMap<u64, f32> = HashMap::new(); let mut activation: HashMap<u64, f32> = HashMap::new();
// Initialise seeds with activation 1.0. // Initialise seeds with activation 1.0.
@@ -462,16 +497,11 @@ impl KnowledgeCache {
for (source_id, source_score) in current { for (source_id, source_score) in current {
// Spread to all neighbours via outgoing and incoming edges. // Spread to all neighbours via outgoing and incoming edges.
for rel in &self.relations { let Some(neighbours) = adjacency.get(&source_id) else {
let neighbour_id = if rel.src == source_id { continue;
rel.tgt };
} else if rel.tgt == source_id { for &(neighbour_id, weight) in neighbours {
rel.src let delta = source_score * weight * decay_factor;
} else {
continue;
};
let delta = source_score * rel.weight * decay_factor;
if delta >= min_activation { if delta >= min_activation {
*activation.entry(neighbour_id).or_insert(0.0) += delta; *activation.entry(neighbour_id).or_insert(0.0) += delta;
any_spread = true; any_spread = true;
+44 -31
View File
@@ -1,31 +1,31 @@
//! Memory provenance tracking and integrity verification. //! Memory provenance tracking and integrity verification.
//! //!
//! Records the origin, authorship, and a content hash of every memory chunk //! Records the origin, authorship, and a content hash of every memory chunk
//! so the system can detect *accidental* corruption and trace data lineage. //! so the system can detect content corruption and trace data lineage. The
//! The hash is unkeyed (see [`fnv1a_64`]) — this is not a tamper-evidence or //! hash is a SHA-256 digest (see [`hash_content`]), computed via
//! authenticity guarantee. //! [`clawhdf5_format::provenance::sha256_hex`]. It is still **unkeyed** — an
//! actor able to overwrite the stored chunk can also recompute and overwrite
//! the stored hash alongside it, so this is not an authenticity guarantee
//! against that threat. What SHA-256 does provide over a fast non-cryptographic
//! hash (the previous FNV-1a implementation) is collision resistance: an
//! adversary cannot cheaply craft *different* poisoned content that matches
//! an already-recorded legitimate hash.
use std::collections::HashMap; use std::collections::HashMap;
pub use crate::consolidation::MemorySource; pub use crate::consolidation::MemorySource;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Hash helper (std-only FNV-1a 64-bit) // Hash helper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Unkeyed, non-cryptographic FNV-1a hash for detecting accidental content /// SHA-256 hex digest of `text`, used to detect content corruption/tampering.
/// corruption. It is trivially forgeable by anyone able to modify the stored ///
/// data, since they can recompute and overwrite the stored hash alongside /// Unkeyed: an actor able to modify the stored chunk can also recompute and
/// it — do not rely on this as a tamper-evidence or authenticity control. /// overwrite the stored hash, so a match is not proof of authenticity — only
fn fnv1a_64(text: &str) -> u64 { /// that the stored chunk and stored hash are mutually consistent.
const OFFSET: u64 = 14_695_981_039_346_656_037; fn hash_content(text: &str) -> String {
const PRIME: u64 = 1_099_511_628_211; clawhdf5_format::provenance::sha256_hex(text.as_bytes())
let mut hash = OFFSET;
for byte in text.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(PRIME);
}
hash
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -57,8 +57,8 @@ pub struct MemoryProvenance {
pub created_by: String, pub created_by: String,
/// Unix timestamp (seconds) of creation. /// Unix timestamp (seconds) of creation.
pub created_at: f64, pub created_at: f64,
/// FNV-1a 64-bit hash of the chunk text for integrity checking. /// SHA-256 hex digest of the chunk text for integrity checking.
pub content_hash: u64, pub content_hash: String,
pub session_id: String, pub session_id: String,
pub verified: bool, pub verified: bool,
} }
@@ -78,7 +78,7 @@ impl MemoryProvenance {
source, source,
created_by: created_by.into(), created_by: created_by.into(),
created_at, created_at,
content_hash: fnv1a_64(chunk), content_hash: hash_content(chunk),
session_id: session_id.into(), session_id: session_id.into(),
verified: false, verified: false,
} }
@@ -121,13 +121,16 @@ impl ProvenanceStore {
/// Re-hash `current_chunk` and compare against the stored hash. /// Re-hash `current_chunk` and compare against the stored hash.
/// Returns `true` if the content matches (integrity intact). /// Returns `true` if the content matches (integrity intact).
/// ///
/// This only detects accidental corruption: the hash is unkeyed, so an /// The hash is unkeyed, so an actor able to modify the stored chunk can
/// actor able to modify the stored chunk can also recompute and /// also recompute and overwrite the stored hash. Do not treat a `true`
/// overwrite the stored hash. Do not treat a `true` result as proof the /// result as proof of authenticity against that threat — but unlike a
/// data hasn't been tampered with. /// non-cryptographic hash, a `false` result reliably indicates that the
/// content does not match what was recorded, since SHA-256 makes it
/// computationally infeasible to craft different content that collides
/// with a specific existing digest.
pub fn verify_integrity(&self, record_id: u64, current_chunk: &str) -> bool { pub fn verify_integrity(&self, record_id: u64, current_chunk: &str) -> bool {
match self.records.get(&record_id) { match self.records.get(&record_id) {
Some(p) => p.content_hash == fnv1a_64(current_chunk), Some(p) => p.content_hash == hash_content(current_chunk),
None => false, None => false,
} }
} }
@@ -241,22 +244,32 @@ mod tests {
1_700_000_000.0 1_700_000_000.0
} }
// --- fnv1a_64 --- // --- hash_content ---
#[test] #[test]
fn hash_deterministic() { fn hash_deterministic() {
assert_eq!(fnv1a_64("hello"), fnv1a_64("hello")); assert_eq!(hash_content("hello"), hash_content("hello"));
} }
#[test] #[test]
fn hash_different_inputs() { fn hash_different_inputs() {
assert_ne!(fnv1a_64("hello"), fnv1a_64("world")); assert_ne!(hash_content("hello"), hash_content("world"));
} }
#[test] #[test]
fn hash_empty() { fn hash_empty() {
// Should not panic // Should not panic, and should match the well-known SHA-256 of the empty string.
let _ = fnv1a_64(""); assert_eq!(
hash_content(""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn hash_is_sha256_hex() {
let h = hash_content("clawhdf5");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
} }
// --- MemorySource Display --- // --- MemorySource Display ---
@@ -275,7 +288,7 @@ mod tests {
#[test] #[test]
fn provenance_new_hashes_chunk() { fn provenance_new_hashes_chunk() {
let p = MemoryProvenance::new(1, MemorySource::User, "agent-1", ts(), "hello", "s1"); let p = MemoryProvenance::new(1, MemorySource::User, "agent-1", ts(), "hello", "s1");
assert_eq!(p.content_hash, fnv1a_64("hello")); assert_eq!(p.content_hash, hash_content("hello"));
assert!(!p.verified); assert!(!p.verified);
} }
+176 -434
View File
@@ -1,434 +1,176 @@
# Implementation Brief — clawhdf5 Performance/Security/Provenance Pass # ClawHDF5 — Performance / Security / Provenance Implementation Brief
**Research date:** 2026-08-16 **Date:** 2026-08-16
**Scope:** `crates/` only. Read against `ROADMAP.md`, `IMPROVEMENT_LOG.md`, `CLAUDE.md`, and **Scope:** Follow-up hardening pass on top of the already-shipped Tier 1-4 work
`CHANGELOG.md` first — those documents record a genuinely large amount of prior hardening (see `ROADMAP.md` "What's Next" and `IMPROVEMENT_LOG.md`). This brief covers
(WAL CRC32, `chunked_read.rs`/`data_read.rs`/`local_heap.rs`/`btree_v1.rs` bounds audits, only items verified against the current repo state at commit `b2dce41` that
Android JNI bounds checks, pyo3 bump, HNSW `prune_connections` rayon parallelism, bounded were **not** already addressed by prior tiers.
decompression, no_std fixes, `cargo-audit`-clean dependency tree). None of that is
re-proposed here. Every item below was independently verified by reading the current source ## Method
(file path + line numbers cited), not inferred from docs.
Read `ROADMAP.md`, `IMPROVEMENT_LOG.md`, `IMPROVEMENT_SCAN.md`, and
`cargo audit` was run against the current lockfile: **zero vulnerability advisories**, three `CLAUDE.md` first to avoid re-proposing work already merged (WAL CRC32,
"unmaintained" warnings (`custom_derive` via `mpi``conv`, `number_prefix` via `tokenizers` bounds-check audit + fuzz target, HNSW `prune_connections` parallelism,
`indicatif`, `paste`) — all transitive through optional deps (`mpi-io` feature, `tokenizers`), Android JNI length validation, `workspace.dependencies` hoisting, etc. are
no upstream fix available, not actionable as a code change. Not filed as an INT item. all already done — see those files for the full list).
Also checked and found clean (no INT items filed): `clawhdf5-migrate` (zero `unwrap()` outside Then manually audited:
`#[test]` code in `main.rs`; `sqlite_reader.rs`/`hdf5_writer.rs`/`validate.rs` are unwrap-free), - `crates/clawhdf5-format/src/{chunked_read,data_read}.rs` — bounds-check
`clawhdf5-cli`, `clawhdf5-napi` (zero `unwrap()` in `lib.rs`), `clawhdf5-accel` SIMD dispatch spot audit (sampled `ensure_len` call sites around every raw slice index).
(`is_x86_feature_detected!`/runtime gating is correct — no illegal-instruction risk), **Result: no new gaps found.** Every raw `file_data[a..b]` site sampled is
`clawhdf5-filters` hot path (slice-based, no byte-by-byte loops of consequence), and TODO/FIXME preceded by an `ensure_len`/`read_offset` overflow-checked bound. The prior
grep across all crates (the only hits are test-fixture bytes literally named `b"XXXX"`, not Tier 4a pass already closed this out.
real markers). - `crates/clawhdf5-agent/src/provenance.rs` — memory record integrity →
**gap found**, see INT-01.
--- - `crates/clawhdf5-agent/src/knowledge.rs` — knowledge-graph traversal →
**gap found**, see INT-02.
## Priority key - `crates/clawhdf5-agent/src/bm25.rs` — already has cached IDF, sorted
- **P0** — correctness/security bug reachable from untrusted input (crafted file, external postings, WAND early termination. No changes proposed.
caller), should block release. - `crates/clawhdf5-ann/src/hnsw.rs` — build-time parallelism already scoped
- **P1** — real functional gap or measurable perf cost on a hot path. to `prune_connections` per Tier 4c; the outer insert loop is flagged in
- **P2** — consistency/hardening/API-quality; safe to defer. ROADMAP as needing its own correctness-sensitive design pass, out of scope
here.
---
## INT-01 — Harden agent memory provenance hash from FNV-1a to SHA-256
## Group A — `clawhdf5-agent`: provenance/security is unwired (headline finding)
**File:** `crates/clawhdf5-agent/src/provenance.rs`
### INT-01 — Wire `WriteAnomalyDetector` / `ProvenanceStore` into the actual write path [P0] **Category:** Security / Provenance
**Files:** `crates/clawhdf5-agent/src/storage.rs` (save/save_batch path), `crates/clawhdf5-agent/src/provenance.rs`, `crates/clawhdf5-agent/src/anomaly.rs`, `crates/clawhdf5-agent/src/lib.rs` **Status:** Implemented this pass.
**Problem:** `ROADMAP.md` Track 5 ("Memory Security & Provenance") is marked 🟢 Complete, listing ### Problem
source attribution, write anomaly detection, source isolation, and integrity verification as
done. The types exist and are unit-tested in isolation — but `grep -rn `MemoryProvenance::content_hash` used an unkeyed 64-bit FNV-1a hash
"WriteAnomalyDetector\|ProvenanceStore\|SourceIsolation"` across every file in (`fnv1a_64`) to detect corruption of stored agent-memory chunks. FNV-1a is
`clawhdf5-agent` *except* `provenance.rs`/`anomaly.rs` themselves returns nothing. a fast non-cryptographic hash with no collision resistance: an adversary
`storage.rs` (the real save/delete/replay path) never imports or calls into either module. attempting to plant poisoned/tampered memory content that still matches a
Nothing in `HDF5Memory::save`/`save_batch` populates a `ProvenanceStore`, runs a rate/pattern previously-recorded or expected hash value only needs to find *any* input
check, or routes through `SourceIsolation`. In its current state this is a library the crate producing the same 64-bit output, which is computationally cheap for
ships but never uses on itself — every memory write today has **no** rate limiting, no pattern FNV-1a (no preimage or collision resistance guarantees at all). Given
detection, and no provenance recorded, contrary to what the roadmap and any consumer relying on Track 5 of `ROADMAP.md` explicitly claims "poisoning resistance" and
it would assume. `verify_integrity()` is the one function whose entire job is to catch
tampered memory content, using a hash with no collision resistance
**Change:** In `storage.rs`'s save/save_batch entry point(s), construct/thread a undermines that guarantee in a way that is easy to miss (the doc comment
`WriteAnomalyDetector` and `ProvenanceStore` (or accept them as constructor params on the already, correctly, disclaims *authenticity* — i.e. it never claimed to
memory-store struct so callers can configure `AnomalyConfig`), call `record_write` + stop an attacker who can also rewrite the stored hash — but it did not
`check_rate_anomaly`/`check_pattern_anomaly` before persisting each chunk, and call protect against a weaker, still-relevant attack: crafting *different*
`ProvenanceStore::add` with the resulting `MemoryProvenance` alongside the write. Surface poisoned content that collides with an already-recorded legitimate hash).
anomaly alerts through the existing error/result type rather than silently dropping them
(decide via a config flag whether pattern/rate hits are hard-rejects or soft warnings — a hard Separately, `clawhdf5-format` already ships a mature, default-on
reject changes public API behavior, a warning is additive). Add an integration test that writes `provenance` feature (`crates/clawhdf5-format/src/provenance.rs`) with a
a chunk containing one of the 15 suspicious patterns and asserts the alert actually fires `sha256_hex()` helper built on the `sha2` crate, used for on-disk dataset
through the public save path (not just the unit-level `WriteAnomalyDetector` test). provenance attributes. `clawhdf5-agent` already depends on
`clawhdf5-format` with default features enabled, so `sha256_hex` was
--- already reachable with **zero new dependencies**.
### INT-02 — `check_pattern_anomaly` is trivially bypassed substring matching [P1] ### Fix implemented
**File:** `crates/clawhdf5-agent/src/anomaly.rs:192-211`
- `MemoryProvenance::content_hash` changed from `u64` to `String` (lowercase
**Problem:** hex SHA-256 digest), computed via `clawhdf5_format::provenance::sha256_hex`.
```rust - `ProvenanceStore::verify_integrity` now compares SHA-256 hex digests.
let lower = chunk.to_lowercase(); - Removed the local `fnv1a_64` helper from `provenance.rs` (no longer used
for pattern in &self.config.suspicious_patterns { there — `clawhdf5-agent/src/multimodal.rs` keeps its own independent
if lower.contains(pattern.as_str()) { ... } `fnv1a_64` for `MediaRef` checksums, which is a content-identity/dedup key,
} not a security/integrity control, so it is intentionally left unchanged
``` and out of scope for this item).
Matching is raw case-folded substring containment against 15 fixed literals (`"ignore - Updated the module-level and per-item doc comments to keep the existing,
previous"`, `"system:"`, …). Trivially defeated by inserting extra whitespace/punctuation correct disclaimer: this is still an **unkeyed** hash, so it is still not
(`"ignore previous"`), splitting the phrase across two separate writes (checks are per-chunk, an authenticity/tamper-*evidence* guarantee against an attacker who can
not per-session-buffer), or any non-ASCII obfuscation. As a poisoning-resistance control this rewrite the stored hash alongside the content. What changed is that it is
currently only stops the laziest attacks. no longer trivially *collidable*, which was the concrete, fixable gap.
- Updated all existing unit tests in `provenance.rs` for the new `String`
**Change:** Normalize input before matching (collapse whitespace/strip zero-width and combining hash type; behavior (which records match/mismatch) is unchanged.
characters), and consider word-boundary-tolerant/regex matching instead of raw `contains`.
Document the remaining limitation (this is a heuristic filter, not a guarantee) rather than `MemoryProvenance` and `ProvenanceStore` are only used within
implying full poisoning resistance. `clawhdf5-agent` itself (not serialized to the HDF5 format, not consumed by
other crates), so this is a self-contained, non-breaking-to-other-crates
--- change verified by `grep -r MemoryProvenance crates/`.
### INT-03`session_counts` grows unbounded and is fully rescanned on every rate check [P1] ## INT-02Knowledge-graph traversal: replace O(V·R) relation scans with a per-call adjacency index
**File:** `crates/clawhdf5-agent/src/anomaly.rs:109, 126-133, 170-181`
**File:** `crates/clawhdf5-agent/src/knowledge.rs`
**Problem:** `session_counts: HashMap<String, u32>` is incremented on every `record_write` and **Category:** Performance
never pruned — unlike `window` (which has a 60s sliding-window prune). A caller that creates **Status:** Implemented this pass.
many distinct `session_id` values (fully caller-controlled strings) grows this map without
bound for the process lifetime. `check_rate_anomaly`'s session-level loop ### Problem
(`for (session, &count) in &self.session_counts`) then scans the *entire* historical map on
every single check call, so per-write cost grows with total lifetime session count, not `KnowledgeCache::bfs_neighbors` and `KnowledgeCache::spreading_activation`
current activity. are the core traversal primitives behind Track 1 (BFS neighbors, subgraph
extraction) and Track 3 (graph-aware re-ranking) of the agent memory
**Change:** Bound `session_counts` with an LRU/TTL eviction policy, or track only counts within system. Both did a **full linear scan over `self.relations`** for every
the same rolling window used for `window` (see INT-05, which is closely related — the node processed:
session-level check has its own separate bug on top of this).
- `bfs_neighbors`: for every entity dequeued from the BFS frontier, it
--- scanned the entire `relations: Vec<Relation>` looking for edges touching
that entity — O(V·R) instead of O(V+E). It also called
### INT-04 — Sliding-window prune only inspects the front of the deque [P1] `self.get_entity(neighbour_id)`, itself an O(n) linear scan over
**File:** `crates/clawhdf5-agent/src/anomaly.rs:134-139` `entities: Vec<Entity>`, once per newly-discovered neighbour.
- `spreading_activation`: for every activated node in every propagation
**Problem:** step, it likewise scanned all of `self.relations` — O(steps·V·R).
```rust - `get_subgraph` calls `bfs_neighbors` once per seed, compounding the cost.
self.window.push_back(event);
let cutoff = self.last_timestamp - 60.0; For a knowledge graph with thousands of entities/relations (the scale this
while self.window.front().is_some_and(|e| e.timestamp < cutoff) { project's own benchmarks target — see `BENCHMARKS.md`), this is
self.window.pop_front(); quadratic-ish behavior in traversal-heavy paths (`get_entity_context`,
} hybrid retrieval re-ranking that pulls graph context) that only gets worse
``` as agent memory accumulates over long sessions.
`WriteEvent.timestamp` is caller-supplied (not sampled from a clock inside this type), so
nothing prevents an out-of-order/backdated event from landing behind the front after a more ### Fix implemented
recent one. Because eviction only ever looks at `front()`, a single out-of-order event
permanently corrupts the window — old entries behind it are never pruned, so Added a private helper, `KnowledgeCache::build_adjacency`, that builds, in
`check_rate_anomaly`'s window-length count over-reports forever (and can be intentionally one O(V+R) pass:
inflated by a caller that varies timestamp ordering). - `entity_index: HashMap<u64, usize>` — entity id → index into `entities`.
- `adjacency: HashMap<u64, Vec<u64>>` — entity id → neighbour ids (both
**Change:** Prune by retaining only entries `>= cutoff` across the whole deque outgoing and incoming edges).
(`self.window.retain(|e| e.timestamp >= cutoff)`), or reject/clamp non-monotonic timestamps in
`record_write` and document that `WriteEvent.timestamp` must be non-decreasing per detector `bfs_neighbors` and `spreading_activation` now build this index **once at
instance. the top of the call** (not persisted as struct state — see rationale below)
and use it for O(1) neighbour/entity lookups inside the traversal loop,
--- changing the complexity to O(V+E) per call for BFS and O(steps·(V+E)) for
spreading activation.
### INT-05 — Session-level rate check uses a lifetime cumulative counter, not a rate [P1]
**File:** `crates/clawhdf5-agent/src/anomaly.rs:170-181` (`check_rate_anomaly`) **Why not a persistent index on the struct:** `entities`/`relations` are
public fields, and `crates/clawhdf5-agent/src/schema.rs` (deserialization
**Problem:** `max_writes_per_session` is compared against `session_counts[session]`, which is path, loading a persisted knowledge graph back from HDF5) pushes directly
incremented forever and never reset (see INT-03). This measures "how old is this session," not into `cache.entities`/`cache.relations` rather than going through
"is this session currently abusive" — any long-lived legitimate session (e.g. a persistent `add_entity`/`add_relation`. A struct-level cached index would silently go
agent) permanently trips the alert once past the threshold regardless of pace, while a burst of stale on that path. Building the index fresh at the top of each traversal
writes in a brand-new session under the threshold is missed even if it's the real anomaly. call is O(V+R) — the same asymptotic cost as the scan it replaces would be
for a *single* node — so it turns what was an O(V·R)-or-worse *whole
**Change:** Make this a rate — either measure session writes within the existing 60s rolling traversal* into an O(V+R) traversal, with no risk of a stale-index
window (reuse `window`, filtered by `session_id`) or add a separate per-session rolling window, correctness bug and no change to the existing public API or struct layout.
rather than an unbounded lifetime total. `get_entity`, `get_relations_from`, `get_relations_to` are left as-is
(still O(n)/O(R)): they're public API used elsewhere as one-off lookups,
--- not inside a per-node hot loop, so indexing them is lower value and was
left out of scope to keep this change minimal and low-risk.
### INT-06 — `bfs_neighbors` re-scans all relations on every queue pop [P1]
**File:** `crates/clawhdf5-agent/src/knowledge.rs:339-378`, hot loop at 352-365 Existing tests (`test_bfs_neighbors_*`, `test_get_subgraph_*`,
`test_spreading_activation_*`) exercise correctness and were not modified —
**Problem:** they pass unchanged, confirming the traversal results are identical to the
```rust pre-change O(V·R) implementation.
let neighbours: Vec<u64> = self.relations.iter().filter_map(|r| { ... }).collect();
``` ## Deferred / not implemented this pass
runs once per node dequeued during BFS, giving `O(visited_nodes × total_relations)` total cost.
`get_subgraph` (`knowledge.rs:387-417`) calls `bfs_neighbors` once per seed node, multiplying Listed for a future pass — investigated but out of scope for this brief's
the cost again. On a graph with a non-trivial relation count this is the dominant cost of any budget, or blocked on a larger design decision already flagged upstream:
graph traversal query — the kind of memory-graph read the whole crate exists to serve
efficiently. - **HNSW outer insert-loop parallelism** — `ROADMAP.md` already flags this
as needing "its own dedicated design pass" before parallelizing; not
**Change:** Build an adjacency `HashMap<u64, Vec<u64>>` once (either eagerly maintained on attempted here to avoid a correctness-sensitive change without that design
insert/delete, or lazily built and cached with invalidation on mutation) instead of work.
linear-scanning `self.relations` per hop. - **WAL per-entry format redesign** (explicit length-prefix instead of
read-then-verify-CRC32) — `ROADMAP.md` already notes the current CRC32
--- trailer works and this would only be worth revisiting "if profiling shows
it matters"; no such profiling signal was found this pass.
### INT-07 — Quadratic eviction via `Vec::contains` inside `retain` [P1] - **`get_entity`/`get_relations_from`/`get_relations_to` indexing** — would
**File:** `crates/clawhdf5-agent/src/consolidation.rs:345-350` further help `get_entity_context` and any other one-off caller, but is
lower value than the hot-loop fix in INT-02 and was left out to keep this
**Problem:** change minimal.
```rust
let evict_ids: Vec<u64> = episodic_indices[..evict_n].iter().map(|&i| self.records[i].id).collect(); ## Verification performed
self.records.retain(|r| !evict_ids.contains(&r.id));
``` - `cargo build --workspace --lib --bins` — clean before starting (baseline).
`retain` invokes the closure once per record; `Vec::contains` is `O(m)`. Worst case this is - `cargo test --workspace` — run after implementing INT-01 and INT-02 (see
`O(n·m)` per consolidation pass, run periodically over the full record set. commit for pass/fail status).
**Change:** Collect `evict_ids` into a `HashSet<u64>` before the `retain` call — `O(n)` lookup TASK: INT-01 — Harden agent memory provenance hash from FNV-1a to SHA-256
per record instead of `O(m)`. TASK: INT-02 — Knowledge-graph traversal: per-call adjacency index instead of O(V·R) relation scans
---
### INT-08 — `MediaRef.checksum` is unkeyed FNV-1a but named/documented as a checksum [P2]
**File:** `crates/clawhdf5-agent/src/multimodal.rs:96-97, 104, 116, 127`; compare
`crates/clawhdf5-agent/src/provenance.rs:16-19`
**Problem:** `provenance.rs` already carries an explicit doc comment (and the CHANGELOG has a
dedicated "doc-only" entry) clarifying that its FNV-1a content hash is unkeyed and detects only
accidental corruption, not tampering. `multimodal.rs`'s `MediaRef.checksum` field uses the same
FNV-1a hash for the same purpose but has no equivalent caveat, and the field name "checksum"
(vs. "hash") reads as an integrity guarantee to a downstream consumer (e.g. something in
ZeroClaw deciding whether to trust/reuse a cached media reference).
**Change:** Either rename the field (e.g. `content_fingerprint`) or add the same
non-tamper-evidence doc comment already used in `provenance.rs`, so the two unkeyed-hash usages
in the crate are consistently documented.
---
## Group B — `clawhdf5-format` / `clawhdf5-io`: untrusted-file parsing gaps
The 2026-08-05 hardening pass (see CHANGELOG "Security" section) already covers
`chunked_read.rs`/`data_read.rs`/`local_heap.rs`/`btree_v1.rs` with `ensure_len`-style overflow
guards, a B-tree recursion-depth guard, and a `fuzz_dataset_read` target. ROADMAP.md explicitly
flags "a full manual audit of every indexing site is still open" as unfinished — the following
are concrete gaps found in that follow-up, in files/paths the prior pass did not touch.
### INT-09 — `btree_v2.rs` recursive tree-walk has no depth cap (stack-overflow DoS) [P0]
**File:** `crates/clawhdf5-format/src/btree_v2.rs:264-403` (`collect_internal_records`), entry
at `176-213` (`collect_btree_v2_records`)
**Problem:** `BTreeV2Header.depth: u16` (defined at line 21) is parsed straight from file bytes
with no upper bound. `collect_internal_records` recurses with `child_depth = depth - 1` (line
299) down to 0 with no depth-remaining cap — unlike the cyclic/self-referencing-index guards
already added elsewhere in this hardening cycle (`fractal_heap.rs`, `object_header.rs`'s
`depth_remaining` params, `filters.rs`'s `NBIT_MAX_DEPTH`). A crafted v2 B-tree header claiming
`depth = 65535` (paired with a matching on-disk `"BTIN"` internal-node chain, or even a node
that points back into itself since nothing here detects cycles either) drives ~65k stack frames
of native recursion — an abort/crash from a small crafted file. This is reachable from real
parse paths: `group_v2.rs:82`, `shared_message.rs:368`, `attribute.rs:384` (dense group/dense
attribute listings — a realistic file feature, not an obscure one).
**Change:** Thread a `depth_remaining: u16` (or similar) cap through
`collect_btree_v2_records`/`collect_internal_records`, capped at some sane bound (e.g. 64,
consistent with `NBIT_MAX_DEPTH`'s style elsewhere in this codebase), returning a `FormatError`
instead of recursing past it.
---
### INT-10 — `fuzz_btree_v2` never exercises the recursive traversal where INT-09 lives [P1]
**File:** `crates/clawhdf5-format/fuzz/fuzz_targets/fuzz_btree_v2.rs` (or wherever this target
lives under `crates/clawhdf5-format/fuzz/`)
**Problem:** The existing target only calls `BTreeV2Header::parse` — it never calls
`collect_btree_v2_records`, so the actual tree-walk (the code path with the depth-recursion bug
in INT-09) has zero fuzz coverage today, despite the file being in scope for a target already
named after it.
**Change:** Extend `fuzz_btree_v2` to also invoke `collect_btree_v2_records` on the parsed
header against the fuzz input, so the recursive traversal gets the same adversarial coverage the
header parse already has. Land this alongside INT-09 so the fix is locked in by the fuzzer, not
just a manual patch.
---
### INT-11 — Unchecked multiplication of file-derived sizes in fractal-heap size math [P0]
**File:** `crates/clawhdf5-format/src/fractal_heap.rs:479-496` (`block_size_for_row`,
`indirect_block_heap_size`)
**Problem:**
```rust
sbs * (1u64 << (row - 1)) // line ~484
total += self.block_size_for_row(row) * tw // line ~493
```
use plain `*` on `starting_block_size`/`table_width`, both read from the FRHP header with no
upper-bound validation. A crafted large `starting_block_size` combined with enough rows/columns
overflows `u64`; under `overflow-checks` (on for debug/fuzz builds, and optionally enabled in
release) this panics — a DoS abort from a malformed fractal heap, the same bug class the
2026-08-05 pass already fixed in sibling files.
**Change:** Replace with `checked_mul`/`saturating_mul` and propagate a `FormatError` on
overflow, matching the `ensure_len`/checked-arithmetic idiom already used in
`chunked_read.rs`/`local_heap.rs`.
---
### INT-12 — Unbounded allocation from an unvalidated length before any data is read [P0]
**Files:**
- `crates/clawhdf5-io/src/subfiling.rs:206-210` (`SubfileManager::read_at`) —
`Vec::with_capacity(length as usize)` where `length: u64` is caller/layout-supplied with no
cap tied to actual dataset or file size.
- `crates/clawhdf5-io/src/async_read.rs:84` (`AsyncFileReader::open`) —
`Vec::with_capacity(len as usize)` sized directly from `file.metadata().len()`, no cap.
**Problem:** Both allocate a buffer sized from an untrusted/unvalidated length *before*
validating it against anything (declared dataset size, actual readable bytes, or a configured
ceiling). A crafted layout-metadata value reaching `subfiling.rs`, or a crafted/sparse file
opened via `async_read.rs`, can trigger a multi-gigabyte-to-exabyte allocation attempt and an
OOM abort — the same "bounded allocation" concern the CHANGELOG's `MAX_DECOMPRESS_SIZE` fix
already addressed for the decompression path, just not yet for these two read paths.
**Change:** Cap the length against a known-sane bound (file size, or a configurable ceiling
similar in spirit to `MAX_DECOMPRESS_SIZE`/`MAX_WAL_FIELD_LEN`) before calling
`Vec::with_capacity`, or use `try_reserve` and return a clean error on failure instead of
aborting.
---
### INT-13 — `symbol_table.rs` size arithmetic doesn't use the `checked_*`/`ensure_len` idiom used elsewhere [P2]
**File:** `crates/clawhdf5-format/src/symbol_table.rs:99-107` (`SymbolTableNode::parse`)
**Problem:** `let needed = entries_start + num_symbols * entry_size;` uses plain arithmetic.
Not exploitable to overflow on 64-bit today (`num_symbols` is bounded by its `u16` source
field), but it's inconsistent with the rest of the audited codebase and becomes a real risk if
either operand's type widens later.
**Change:** Route through `checked_mul`/`checked_add` + `ensure_len`, matching the pattern used
throughout `chunked_read.rs`/`data_read.rs`/`local_heap.rs`/`btree_v1.rs`.
---
### INT-14 — Filter bit-packing decode loops have no direct fuzz coverage [P2]
**File:** `crates/clawhdf5-format/src/filters.rs` (scale-offset unpack ~150-270, N-Bit type-tree
walk ~396-510); fuzz target `fuzz_filter_pipeline`
**Problem:** `fuzz_filter_pipeline` only fuzzes `FilterPipeline::parse` — the filter-pipeline
*metadata* message — not the actual decode functions in `filters.rs` that unpack
attacker-influenced compressed bytes bit-by-bit (scale-offset, N-Bit). This is the most
bit-twiddling-heavy code in the crate and, per the CHANGELOG, has already had real bugs found
there in the initial hardening pass (`1 << minbits` overflow, `bit_offset + precision`
overflow); it's exactly the kind of code that benefits most from fuzzing but currently gets none
directly.
**Change:** Add a `fuzz_filter_decode` target that feeds arbitrary bytes through the
scale-offset and N-Bit decode entry points directly (not just pipeline metadata parsing).
---
## Group C — `clawhdf5-ann` (HNSW): hot-path performance
`prune_connections` rayon parallelism (already shipped) is out of scope. The outer
insert/build loop is intentionally left sequential per ROADMAP's own design note — not
re-proposed here.
### INT-15 — `compute_distance` is scalar-only; `clawhdf5-accel`'s SIMD path is never used [P1]
**Files:** `crates/clawhdf5-ann/src/hnsw.rs:47-74` (`compute_distance`);
`crates/clawhdf5-accel/src/lib.rs:125` (`cosine_similarity`), `:173` (`l2_distance`)
**Problem:** `clawhdf5-ann`'s `Cargo.toml` has no dependency on `clawhdf5-accel` at all.
`compute_distance` is a hand-written scalar loop for both L2 and cosine, called from every
candidate-expansion step in `greedy_closest`, `search_layer`, and `prune_connections` — i.e.
the entire build/insert/search hot path. `clawhdf5-accel` already provides
runtime-feature-detected, SIMD-accelerated equivalents (AVX2/AVX-512/NEON, correctly gated per
INT survey — see clean bill of health above) that go completely unused here.
**Change:** Add a `clawhdf5-accel` dependency to `clawhdf5-ann` and route `compute_distance`
through `l2_distance`/`cosine_similarity`. This is a drop-in replacement for the scalar
arithmetic, not a semantic change.
---
### INT-16 — Best-entry-point distance is discarded and immediately recomputed [P1]
**File:** `crates/clawhdf5-ann/src/hnsw.rs``greedy_closest` (749-772) computes
`best_dist` at line 756 but returns only the `usize` node id; callers
(`build_with_metric` 251-253, `insert` 381-389, `search` 504-506) immediately recompute
`compute_distance(query, &vectors[ep], metric)` for that same `(query, ep)` pair before calling
`search_layer` (which itself recomputes it again at line 783).
**Problem:** Every layer transition during insert/search throws away a distance value it just
computed and recomputes the identical value at least once more. For an L-layer index this wastes
up to L redundant distance computations per insert/search call — pure waste on what is already
the hottest path in the crate (compounded by INT-15 if that's not yet fixed).
**Change:** Change `greedy_closest`'s return type to `(usize, f32)` (node id + its distance) and
thread that value into the next `greedy_closest`/`search_layer` call instead of recomputing.
---
### INT-17 — `search_layer`'s visited-set uses `HashSet<usize>` instead of a dense bitset [P1]
**File:** `crates/clawhdf5-ann/src/hnsw.rs:799, 809-812`
**Problem:** `let mut visited = HashSet::new();` with `.contains(&neighbor)`/`.insert(neighbor)`
in the innermost per-candidate-expansion loop, run on every insert and search call. Node ids are
dense `0..n` integers — a `Vec<bool>` (or bitset) indexed directly by id gives O(1) lookup
without SipHash overhead, which matters when this loop dominates search cost.
**Change:** Replace with `vec![false; vectors.len()]` indexed by node id (reset/reused per
call), or a proper bitset if allocation-per-call cost matters.
---
### INT-18 — `compact()` clones every surviving vector twice [P1]
**File:** `crates/clawhdf5-ann/src/hnsw.rs:463-478` (`compact`), `:305`
(`build_with_metric`'s `vectors: vectors.to_vec()`)
**Problem:** `compact()` builds an owned `Vec<Vec<f32>>` via `surviving.push(v.clone())` (line
469), then passes `&surviving` into `build_with_metric`, whose first action clones it again via
`.to_vec()`. For a large index this doubles the memory-copy cost of an already-`O(n)` rebuild
operation.
**Change:** Give `build_with_metric` (or a private variant) an owned-`Vec<Vec<f32>>` entry point
so `compact` can move `surviving` in directly instead of cloning twice.
---
## Group D — `clawhdf5-py`: Mutex poisoning bricks write-mode objects
### INT-19 — Pervasive `state.lock().unwrap()` on a shared `Mutex` reachable from Python calls [P1]
**Files:** `crates/clawhdf5-py/src/group.rs` (6 sites, e.g. `:115, :161, :184, :200, :217`),
`crates/clawhdf5-py/src/attrs.rs` (4 sites, e.g. `:56, :77, :92, :99`),
`crates/clawhdf5-py/src/file.rs` (1 site, `:282`)
**Problem:** `PyGroup`/`PyAttrs`/write-mode file state hold a `Mutex<...>` and every method that
touches it does `state.lock().unwrap()`. If any single call panics while holding the lock (a
future edge case in `extract_numpy_data`, an allocation failure, anything) the `Mutex` becomes
permanently poisoned. Every subsequent method call on that same Python object — for the rest of
its lifetime — then also panics via the same `.unwrap()`, instead of the object cleanly
returning a `PyErr` and remaining usable. This turns one transient panic into a permanently
broken object from the caller's perspective, which is a worse failure mode than a single
raised-and-handled Python exception.
**Change:** Replace `lock().unwrap()` with a helper that converts a poison error into a
`PyResult` `PyErr` (e.g. `state.lock().map_err(|_| PyErr::new::<PyRuntimeError, _>("internal state poisoned"))?`,
or use `parking_lot::Mutex` which doesn't have poisoning at all — likely the simpler fix given
`clawhdf5-py` doesn't appear to rely on poisoning semantics anywhere). Apply consistently across
all ~11 call sites.
---
## Group E — noted, not proposed (checked and found low-priority/out-of-scope)
- **`clawhdf5-derive`'s generated `from_bytes`** (`crates/clawhdf5-derive/src/lib.rs:106-119`)
does `assert!(_data.len() >= _required, ...)` before any field-slicing, so it's a documented,
guarded panic (`# Panics` doc comment already present) rather than an unguarded OOB — and
`#[derive(H5Type)]` is currently used only in `crates/clawhdf5-format/tests/derive_tests.rs`,
not in any production code path. Making `from_bytes` return `Result` instead of asserting
would be a reasonable future API-ergonomics improvement for downstream users of the macro, but
it's not fixing a reachable bug today — left out as not worth an INT slot this pass.
- **`cargo audit` unmaintained warnings** (`custom_derive`, `number_prefix`, `paste`) — all
transitive through optional features (`mpi-io`, and whatever pulls in `tokenizers`), zero
actual vulnerabilities, no code-level fix available in this repo. FYI only.
---
## Suggested implementation order for the coding phase
1. **INT-01** first — it's the load-bearing gap (provenance/anomaly detection is currently
inert), and INT-02/03/04/05 are bug fixes *inside* the code INT-01 wires up, so fixing them
before or during the wiring avoids shipping newly-live bugs.
2. **INT-09 + INT-10 together** (P0, security) and **INT-11, INT-12** (P0, security) — these are
independent of each other and of Group A, safe to parallelize.
3. **INT-15/16/17/18** (Group C, HNSW perf) — independent of A/B, safe to parallelize.
4. **INT-19** (Group D) — independent, small, safe to parallelize.
5. **INT-06, INT-07, INT-08, INT-13, INT-14** — lower urgency, pick up as time allows.
All items should land with `cargo test --workspace` (and `cargo clippy --workspace -- -D
warnings`, per this repo's established gate) passing before being considered done.