From d43c6d97e3fdbb3fc84c814fce9195f26ce729ae Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 20 Aug 2026 10:08:19 -0400 Subject: [PATCH] fix: make provenance resolution depth-safe (iterative traversal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security audit on #874 (MEDIUM): resolve_provenance/collect_provenance was recursive — cycle-safe via the visited_clusters guard but NOT depth-safe. A deep linear fuse chain (C1=fuse([obs]); C2=fuse([C1]); … CN) recursed ~N deep and overflowed the stack (reproduced N=200k → SIGABRT) on this load-bearing query. Convert to an iterative worklist (heap-allocated Vec) + the existing visited_clusters guard, so traversal depth is bounded by heap, not the call stack. Cycle-safety and error semantics are unchanged. Add a 200k-level deep-chain test asserting resolve_provenance returns the correct atomic source set without aborting (runs in <0.5s iteratively). Refs #865 #837 Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X --- crates/ruvector-agent-memory/src/fusion.rs | 35 ++++++++++++++++--- .../tests/atomic_observation_fusion.rs | 28 +++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/ruvector-agent-memory/src/fusion.rs b/crates/ruvector-agent-memory/src/fusion.rs index 169b1622a..80b773a06 100644 --- a/crates/ruvector-agent-memory/src/fusion.rs +++ b/crates/ruvector-agent-memory/src/fusion.rs @@ -260,16 +260,43 @@ impl CausalEpisodicGraph { /// Resolve `node` to the set of **atomic source observations** it derives /// from — the load-bearing provenance guarantee. For an observation this is /// the observation itself; for a cluster it is the transitive union of its - /// members' atomic sources. The traversal is cycle-safe (the cluster layer - /// is acyclic by construction, but a `visited` guard makes resolution total - /// regardless). + /// members' atomic sources. + /// + /// Traversal is **iterative** (an explicit heap-allocated worklist, not the + /// call stack), so a deeply nested fuse chain (`C1=fuse([obs])`, + /// `C2=fuse([C1])`, …) is bounded by heap, not stack depth, and cannot + /// overflow the stack / abort the process on this load-bearing query. It is + /// also **cycle-safe**: the `visited_clusters` guard makes resolution total + /// even though the cluster layer is acyclic by construction. pub fn resolve_provenance( &self, node: NodeRef, ) -> Result, FusionError> { let mut atomic = BTreeSet::new(); let mut visited_clusters = BTreeSet::new(); - self.collect_provenance(node, &mut atomic, &mut visited_clusters)?; + let mut worklist: Vec = vec![node]; + while let Some(current) = worklist.pop() { + match current { + NodeRef::Observation(id) => { + if !self.observations.contains_key(&id) { + return Err(FusionError::UnknownObservation(id)); + } + atomic.insert(id); + } + NodeRef::Cluster(id) => { + if !visited_clusters.insert(id) { + continue; // already expanded; cycle-safe + } + let cluster = self + .clusters + .get(&id) + .ok_or(FusionError::UnknownCluster(id))?; + for member in &cluster.members { + worklist.push(*member); + } + } + } + } Ok(atomic) } diff --git a/crates/ruvector-agent-memory/tests/atomic_observation_fusion.rs b/crates/ruvector-agent-memory/tests/atomic_observation_fusion.rs index 95fa4600e..bcfa796dd 100644 --- a/crates/ruvector-agent-memory/tests/atomic_observation_fusion.rs +++ b/crates/ruvector-agent-memory/tests/atomic_observation_fusion.rs @@ -249,6 +249,34 @@ fn governed_ingest_composes_with_wp4_ledger() { assert_eq!(prov, [parent_id, child_id].into_iter().collect()); } +/// Depth-safety (security audit #874, MEDIUM): a deeply nested linear fuse +/// chain (`C1=fuse([obs])`, `C2=fuse([C1])`, … `CN`) must resolve without +/// overflowing the stack. The old recursive `collect_provenance` aborted the +/// process (SIGABRT) at this depth; the iterative worklist bounds depth by heap. +#[test] +fn deep_fuse_chain_provenance_is_depth_safe() { + let mut graph = CausalEpisodicGraph::new(Tenant::new("acme")); + let obs = observe(SourceKind::RuViewRf, "rf", "acme", 0.5, vec![], b"root-evidence"); + let obs_id = graph.ingest(obs).unwrap(); + + // 200_000 levels deep — far past what the recursive version could survive, + // but linear and fast iteratively. + const DEPTH: usize = 200_000; + let mut current = graph.fuse(&[NodeRef::Observation(obs_id)], "level-0").unwrap(); + for level in 1..DEPTH { + current = graph + .fuse(&[NodeRef::Cluster(current)], format!("level-{level}")) + .unwrap(); + } + + // The load-bearing provenance query resolves to exactly the one atomic + // source, without aborting. + let provenance = graph.resolve_provenance(NodeRef::Cluster(current)).unwrap(); + assert_eq!(provenance, [obs_id].into_iter().collect()); + // Weakest-link confidence propagated unchanged through the whole chain. + assert!((graph.cluster(current).unwrap().confidence - 0.5).abs() < 1e-6); +} + #[test] fn governed_ingest_rejects_ungoverned_parent() { let mut graph = CausalEpisodicGraph::new(Tenant::new("acme"));