From 9864f64019f2ea273d7c5a1fa3f326c835a17dc7 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 8 May 2026 14:27:07 -0500 Subject: [PATCH] fix(security): avoid following symlinked workspace walks --- crates/tui/src/tools/file_search.rs | 25 +++++++++++++++++- crates/tui/src/tools/project.rs | 5 +++- crates/tui/src/tools/search.rs | 39 +++++++++++++++++++++++++++-- crates/tui/src/utils.rs | 26 +++++++++++++++++-- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/tools/file_search.rs b/crates/tui/src/tools/file_search.rs index 8f6b18f3..5a37a125 100644 --- a/crates/tui/src/tools/file_search.rs +++ b/crates/tui/src/tools/file_search.rs @@ -124,7 +124,7 @@ fn search_files( let mut results: Vec = Vec::new(); let mut builder = WalkBuilder::new(base_path); - builder.hidden(false).follow_links(true).require_git(false); + builder.hidden(false).follow_links(false).require_git(false); let walker = builder.build(); for entry in walker { @@ -322,4 +322,27 @@ mod tests { assert!(result.content.contains("main.rs")); assert!(!result.content.contains("notes.md")); } + + #[tokio::test] + #[cfg(unix)] + async fn test_file_search_does_not_follow_symlinked_files() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().join("workspace"); + let outside = tmp.path().join("outside"); + std::fs::create_dir_all(&root).expect("mkdir workspace"); + std::fs::create_dir_all(&outside).expect("mkdir outside"); + let outside_file = outside.join("secret.txt"); + std::fs::write(&outside_file, "outside\n").expect("write outside"); + std::os::unix::fs::symlink(&outside_file, root.join("secret.txt")).expect("symlink"); + + let ctx = ToolContext::new(root); + let tool = FileSearchTool; + let result = tool + .execute(json!({"query": "secret"}), &ctx) + .await + .expect("execute"); + + assert!(result.success); + assert!(!result.content.contains("secret.txt")); + } } diff --git a/crates/tui/src/tools/project.rs b/crates/tui/src/tools/project.rs index ba05ff8c..f77ce747 100644 --- a/crates/tui/src/tools/project.rs +++ b/crates/tui/src/tools/project.rs @@ -63,10 +63,13 @@ fn generate_project_map(root: &std::path::Path, max_depth: usize) -> Result String { let mut key_files = Vec::new(); let mut builder = WalkBuilder::new(root); - builder.hidden(false).follow_links(true).max_depth(Some(2)); + builder.hidden(false).follow_links(false).max_depth(Some(2)); let walker = builder.build(); for entry in walker { @@ -59,6 +59,9 @@ pub fn summarize_project(root: &Path) -> String { Ok(entry) => entry, Err(_) => continue, }; + if entry.file_type().is_some_and(|ft| ft.is_symlink()) { + continue; + } if is_key_file(entry.path()) && let Ok(rel) = entry.path().strip_prefix(root) { @@ -113,10 +116,13 @@ pub fn project_tree(root: &Path, max_depth: usize) -> String { let mut builder = WalkBuilder::new(root); builder .hidden(false) - .follow_links(true) + .follow_links(false) .max_depth(Some(max_depth + 1)); for entry in builder.build().flatten() { + if entry.file_type().is_some_and(|ft| ft.is_symlink()) { + continue; + } let depth = entry.depth(); if depth == 0 || depth > max_depth { continue; @@ -716,6 +722,22 @@ mod project_mapping_tests { assert_eq!(project_tree(root, 1), project_tree(root, 1)); } + #[test] + #[cfg(unix)] + fn project_mapping_does_not_follow_symlinked_key_files() { + let tmp = tempdir().expect("tempdir"); + let root = tmp.path().join("workspace"); + let outside = tmp.path().join("outside"); + fs::create_dir_all(&root).expect("mkdir workspace"); + fs::create_dir_all(&outside).expect("mkdir outside"); + let outside_file = outside.join("Cargo.toml"); + fs::write(&outside_file, "[package]\nname = \"outside\"\n").expect("write outside"); + std::os::unix::fs::symlink(&outside_file, root.join("Cargo.toml")).expect("symlink"); + + assert_eq!(summarize_project(&root), "Unknown project type"); + assert!(!project_tree(&root, 1).contains("Cargo.toml")); + } + #[test] fn summarize_project_sorts_key_files_in_fallback() { // When `summarize_project` can't classify a project type it falls