From a171a3db8372d7beb50f47017357e68bc13e7fa6 Mon Sep 17 00:00:00 2001 From: Jasper Date: Thu, 20 Aug 2026 09:45:17 +0000 Subject: [PATCH] fix(security): bound call graph traversal (#11193) Signed-off-by: Jasper Hugo --- .../platform_extensions/analyze/graph.rs | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/goose/src/agents/platform_extensions/analyze/graph.rs b/crates/goose/src/agents/platform_extensions/analyze/graph.rs index e6c891a41..2ac1081fd 100644 --- a/crates/goose/src/agents/platform_extensions/analyze/graph.rs +++ b/crates/goose/src/agents/platform_extensions/analyze/graph.rs @@ -7,6 +7,9 @@ use super::parser::{Call, FileAnalysis, Symbol}; /// functions in the same file (e.g. two `process()` in different impl blocks). type NodeKey = (PathBuf, String, usize); +const MAX_CALL_GRAPH_DEPTH: u32 = 32; +const MAX_CALL_GRAPH_PATHS: usize = 2_000; + #[derive(Clone)] pub struct ChainLink { pub file: PathBuf, @@ -150,13 +153,17 @@ impl CallGraph { if depth == 0 { return vec![]; } + let depth = depth.min(MAX_CALL_GRAPH_DEPTH); let mut chains = Vec::new(); let mut queue: VecDeque<(Vec, u32)> = VecDeque::new(); - for start in starts { + 'starts: for start in starts { if let Some(neighbors) = edges.get(start) { for neighbor in neighbors { + if queue.len() >= MAX_CALL_GRAPH_PATHS { + break 'starts; + } queue.push_back((vec![start.clone(), neighbor.clone()], 1)); } } @@ -178,6 +185,9 @@ impl CallGraph { let mut extended = false; for neighbor in neighbors { if !visited.contains(neighbor) { + if chains.len() + queue.len() >= MAX_CALL_GRAPH_PATHS { + break; + } let mut new_path = path.clone(); new_path.push(neighbor.clone()); queue.push_back((new_path, d + 1)); @@ -272,3 +282,92 @@ fn resolve_callee( vec![] } } + +#[cfg(test)] +mod tests { + use super::*; + + fn symbol(name: impl Into, line: usize) -> Symbol { + Symbol { + name: name.into(), + line, + parent: None, + detail: None, + } + } + + #[test] + fn outgoing_paths_are_bounded_for_dense_graphs() { + let width = 50; + let mut functions = vec![symbol("root", 1)]; + let mut calls = Vec::new(); + + for i in 0..width { + let name = format!("branch_{i}"); + let line = 100 + i; + functions.push(symbol(&name, line)); + calls.push(Call { + caller: "root".to_string(), + callee: name, + line: 2 + i, + }); + } + + for j in 0..width { + functions.push(symbol(format!("leaf_{j}"), 1_000 + j)); + } + + for i in 0..width { + for j in 0..width { + calls.push(Call { + caller: format!("branch_{i}"), + callee: format!("leaf_{j}"), + line: 100 + i, + }); + } + } + + let graph = CallGraph::build(&[FileAnalysis { + path: PathBuf::from("dense.rs"), + language: "rust", + loc: 2_000, + functions, + classes: Vec::new(), + imports: Vec::new(), + calls, + }]); + + let chains = graph.outgoing("root", 2); + + assert_eq!(chains.len(), MAX_CALL_GRAPH_PATHS); + } + + #[test] + fn outgoing_depth_is_bounded() { + let node_count = MAX_CALL_GRAPH_DEPTH as usize + 20; + let functions = (0..node_count) + .map(|i| symbol(format!("node_{i}"), i + 1)) + .collect(); + let calls = (0..node_count - 1) + .map(|i| Call { + caller: format!("node_{i}"), + callee: format!("node_{}", i + 1), + line: i + 1, + }) + .collect(); + let graph = CallGraph::build(&[FileAnalysis { + path: PathBuf::from("deep.rs"), + language: "rust", + loc: node_count, + functions, + classes: Vec::new(), + imports: Vec::new(), + calls, + }]); + + let chains = graph.outgoing("node_0", u32::MAX); + + assert_eq!(chains.len(), 1); + assert_eq!(chains[0].len(), MAX_CALL_GRAPH_DEPTH as usize + 1); + } +}