fix: make provenance resolution depth-safe (iterative traversal)

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 <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X
This commit is contained in:
ruv 2026-08-20 10:08:19 -04:00
parent 23cf4e1da1
commit d43c6d97e3
2 changed files with 59 additions and 4 deletions

View file

@ -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<BTreeSet<ObservationId>, 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<NodeRef> = 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)
}

View file

@ -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"));