diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs index 1b0c69b744..eb4c8d38e5 100644 --- a/crates/assistant_tools/src/grep_tool.rs +++ b/crates/assistant_tools/src/grep_tool.rs @@ -6,11 +6,12 @@ use gpui::{AnyWindowHandle, App, Entity, Task}; use language::{OffsetRangeExt, ParseStatus, Point}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; use project::{ - Project, + Project, WorktreeSettings, search::{SearchQuery, SearchResult}, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings; use std::{cmp, fmt::Write, sync::Arc}; use ui::IconName; use util::RangeExt; @@ -130,6 +131,23 @@ impl Tool for GrepTool { } }; + // Exclude global file_scan_exclusions and private_files settings + let exclude_matcher = { + let global_settings = WorktreeSettings::get_global(cx); + let exclude_patterns = global_settings + .file_scan_exclusions + .sources() + .iter() + .chain(global_settings.private_files.sources().iter()); + + match PathMatcher::new(exclude_patterns) { + Ok(matcher) => matcher, + Err(error) => { + return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))).into(); + } + } + }; + let query = match SearchQuery::regex( &input.regex, false, @@ -137,7 +155,7 @@ impl Tool for GrepTool { false, false, include_matcher, - PathMatcher::default(), // For now, keep it simple and don't enable an exclude pattern. + exclude_matcher, true, // Always match file include pattern against *full project paths* that start with a project root. None, ) { @@ -160,12 +178,24 @@ impl Tool for GrepTool { continue; } - let (Some(path), mut parse_status) = buffer.read_with(cx, |buffer, cx| { + let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| { (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status()) - })? else { + }) else { continue; }; + // Check if this file should be excluded based on its worktree settings + if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| { + project.find_project_path(&path, cx) + }) { + if cx.update(|cx| { + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + worktree_settings.is_path_excluded(&project_path.path) + || worktree_settings.is_path_private(&project_path.path) + }).unwrap_or(false) { + continue; + } + } while *parse_status.borrow() != ParseStatus::Idle { parse_status.changed().await?; @@ -284,10 +314,11 @@ impl Tool for GrepTool { mod tests { use super::*; use assistant_tool::Tool; - use gpui::{AppContext, TestAppContext}; + use gpui::{AppContext, TestAppContext, UpdateGlobal}; use language::{Language, LanguageConfig, LanguageMatcher}; use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project}; + use project::{FakeFs, Project, WorktreeSettings}; + use serde_json::json; use settings::SettingsStore; use unindent::Unindent; use util::path; @@ -299,7 +330,7 @@ mod tests { let fs = FakeFs::new(cx.executor().clone()); fs.insert_tree( - "/root", + path!("/root"), serde_json::json!({ "src": { "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}", @@ -387,7 +418,7 @@ mod tests { let fs = FakeFs::new(cx.executor().clone()); fs.insert_tree( - "/root", + path!("/root"), serde_json::json!({ "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true", }), @@ -468,7 +499,7 @@ mod tests { // Create test file with syntax structures fs.insert_tree( - "/root", + path!("/root"), serde_json::json!({ "test_syntax.rs": r#" fn top_level_function() { @@ -789,4 +820,488 @@ mod tests { .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) .unwrap() } + + #[gpui::test] + async fn test_grep_security_boundaries(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + path!("/"), + json!({ + "project_root": { + "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }", + ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }", + ".secretdir": { + "config": "fn special_configuration() { /* excluded */ }" + }, + ".mymetadata": "fn custom_metadata() { /* excluded */ }", + "subdir": { + "normal_file.rs": "fn normal_file_content() { /* Normal */ }", + "special.privatekey": "fn private_key_content() { /* private */ }", + "data.mysensitive": "fn sensitive_data() { /* private */ }" + } + }, + "outside_project": { + "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }" + } + }), + ) + .await; + + cx.update(|cx| { + use gpui::UpdateGlobal; + use project::WorktreeSettings; + use settings::SettingsStore; + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = Some(vec![ + "**/.secretdir".to_string(), + "**/.mymetadata".to_string(), + ]); + settings.private_files = Some(vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ]); + }); + }); + }); + + let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + + // Searching for files outside the project worktree should return no results + let result = cx + .update(|cx| { + let input = json!({ + "regex": "outside_function" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.is_empty(), + "grep_tool should not find files outside the project worktree" + ); + + // Searching within the project should succeed + let result = cx + .update(|cx| { + let input = json!({ + "regex": "main" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.iter().any(|p| p.contains("allowed_file.rs")), + "grep_tool should be able to search files inside worktrees" + ); + + // Searching files that match file_scan_exclusions should return no results + let result = cx + .update(|cx| { + let input = json!({ + "regex": "special_configuration" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.is_empty(), + "grep_tool should not search files in .secretdir (file_scan_exclusions)" + ); + + let result = cx + .update(|cx| { + let input = json!({ + "regex": "custom_metadata" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.is_empty(), + "grep_tool should not search .mymetadata files (file_scan_exclusions)" + ); + + // Searching private files should return no results + let result = cx + .update(|cx| { + let input = json!({ + "regex": "SECRET_KEY" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.is_empty(), + "grep_tool should not search .mysecrets (private_files)" + ); + + let result = cx + .update(|cx| { + let input = json!({ + "regex": "private_key_content" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.is_empty(), + "grep_tool should not search .privatekey files (private_files)" + ); + + let result = cx + .update(|cx| { + let input = json!({ + "regex": "sensitive_data" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.is_empty(), + "grep_tool should not search .mysensitive files (private_files)" + ); + + // Searching a normal file should still work, even with private_files configured + let result = cx + .update(|cx| { + let input = json!({ + "regex": "normal_file_content" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.iter().any(|p| p.contains("normal_file.rs")), + "Should be able to search normal files" + ); + + // Path traversal attempts with .. in include_pattern should not escape project + let result = cx + .update(|cx| { + let input = json!({ + "regex": "outside_function", + "include_pattern": "../outside_project/**/*.rs" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + let results = result.unwrap(); + let paths = extract_paths_from_results(&results.content.as_str().unwrap()); + assert!( + paths.is_empty(), + "grep_tool should not allow escaping project boundaries with relative paths" + ); + } + + #[gpui::test] + async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create first worktree with its own private files + fs.insert_tree( + path!("/worktree1"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/fixture.*"], + "private_files": ["**/secret.rs"] + }"# + }, + "src": { + "main.rs": "fn main() { let secret_key = \"hidden\"; }", + "secret.rs": "const API_KEY: &str = \"secret_value\";", + "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }" + }, + "tests": { + "test.rs": "fn test_secret() { assert!(true); }", + "fixture.sql": "SELECT * FROM secret_table;" + } + }), + ) + .await; + + // Create second worktree with different private files + fs.insert_tree( + path!("/worktree2"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/internal.*"], + "private_files": ["**/private.js", "**/data.json"] + }"# + }, + "lib": { + "public.js": "export function getSecret() { return 'public'; }", + "private.js": "const SECRET_KEY = \"private_value\";", + "data.json": "{\"secret_data\": \"hidden\"}" + }, + "docs": { + "README.md": "# Documentation with secret info", + "internal.md": "Internal secret documentation" + } + }), + ) + .await; + + // Set global settings + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); + settings.private_files = Some(vec!["**/.env".to_string()]); + }); + }); + }); + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + + // Wait for worktrees to be fully scanned + cx.executor().run_until_parked(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + + // Search for "secret" - should exclude files based on worktree-specific settings + let result = cx + .update(|cx| { + let input = json!({ + "regex": "secret", + "case_sensitive": false + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await + .unwrap(); + + let content = result.content.as_str().unwrap(); + let paths = extract_paths_from_results(&content); + + // Should find matches in non-private files + assert!( + paths.iter().any(|p| p.contains("main.rs")), + "Should find 'secret' in worktree1/src/main.rs" + ); + assert!( + paths.iter().any(|p| p.contains("test.rs")), + "Should find 'secret' in worktree1/tests/test.rs" + ); + assert!( + paths.iter().any(|p| p.contains("public.js")), + "Should find 'secret' in worktree2/lib/public.js" + ); + assert!( + paths.iter().any(|p| p.contains("README.md")), + "Should find 'secret' in worktree2/docs/README.md" + ); + + // Should NOT find matches in private/excluded files based on worktree settings + assert!( + !paths.iter().any(|p| p.contains("secret.rs")), + "Should not search in worktree1/src/secret.rs (local private_files)" + ); + assert!( + !paths.iter().any(|p| p.contains("fixture.sql")), + "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)" + ); + assert!( + !paths.iter().any(|p| p.contains("private.js")), + "Should not search in worktree2/lib/private.js (local private_files)" + ); + assert!( + !paths.iter().any(|p| p.contains("data.json")), + "Should not search in worktree2/lib/data.json (local private_files)" + ); + assert!( + !paths.iter().any(|p| p.contains("internal.md")), + "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)" + ); + + // Test with `include_pattern` specific to one worktree + let result = cx + .update(|cx| { + let input = json!({ + "regex": "secret", + "include_pattern": "worktree1/**/*.rs" + }); + Arc::new(GrepTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await + .unwrap(); + + let content = result.content.as_str().unwrap(); + let paths = extract_paths_from_results(&content); + + // Should only find matches in worktree1 *.rs files (excluding private ones) + assert!( + paths.iter().any(|p| p.contains("main.rs")), + "Should find match in worktree1/src/main.rs" + ); + assert!( + paths.iter().any(|p| p.contains("test.rs")), + "Should find match in worktree1/tests/test.rs" + ); + assert!( + !paths.iter().any(|p| p.contains("secret.rs")), + "Should not find match in excluded worktree1/src/secret.rs" + ); + assert!( + paths.iter().all(|p| !p.contains("worktree2")), + "Should not find any matches in worktree2" + ); + } + + // Helper function to extract file paths from grep results + fn extract_paths_from_results(results: &str) -> Vec { + results + .lines() + .filter(|line| line.starts_with("## Matches in ")) + .map(|line| { + line.strip_prefix("## Matches in ") + .unwrap() + .trim() + .to_string() + }) + .collect() + } } diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index 2c8bf0f6cf..aef186b9ae 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/crates/assistant_tools/src/list_directory_tool.rs @@ -3,9 +3,10 @@ use anyhow::{Result, anyhow}; use assistant_tool::{ActionLog, Tool, ToolResult}; use gpui::{AnyWindowHandle, App, Entity, Task}; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; -use project::Project; +use project::{Project, WorktreeSettings}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings; use std::{fmt::Write, path::Path, sync::Arc}; use ui::IconName; use util::markdown::MarkdownInlineCode; @@ -119,21 +120,80 @@ impl Tool for ListDirectoryTool { else { return Task::ready(Err(anyhow!("Worktree not found"))).into(); }; - let worktree = worktree.read(cx); - let Some(entry) = worktree.entry_for_path(&project_path.path) else { + // Check if the directory whose contents we're listing is itself excluded or private + let global_settings = WorktreeSettings::get_global(cx); + if global_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}", + &input.path + ))) + .into(); + } + + if global_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's global `private_files` setting: {}", + &input.path + ))) + .into(); + } + + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + if worktree_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}", + &input.path + ))) + .into(); + } + + if worktree_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}", + &input.path + ))) + .into(); + } + + let worktree_snapshot = worktree.read(cx).snapshot(); + let worktree_root_name = worktree.read(cx).root_name().to_string(); + + let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else { return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into(); }; if !entry.is_dir() { return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into(); } + let worktree_snapshot = worktree.read(cx).snapshot(); let mut folders = Vec::new(); let mut files = Vec::new(); - for entry in worktree.child_entries(&project_path.path) { - let full_path = Path::new(worktree.root_name()) + for entry in worktree_snapshot.child_entries(&project_path.path) { + // Skip private and excluded files and directories + if global_settings.is_path_private(&entry.path) + || global_settings.is_path_excluded(&entry.path) + { + continue; + } + + if project + .read(cx) + .find_project_path(&entry.path, cx) + .map(|project_path| { + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + + worktree_settings.is_path_excluded(&project_path.path) + || worktree_settings.is_path_private(&project_path.path) + }) + .unwrap_or(false) + { + continue; + } + + let full_path = Path::new(&worktree_root_name) .join(&entry.path) .display() .to_string(); @@ -166,10 +226,10 @@ impl Tool for ListDirectoryTool { mod tests { use super::*; use assistant_tool::Tool; - use gpui::{AppContext, TestAppContext}; + use gpui::{AppContext, TestAppContext, UpdateGlobal}; use indoc::indoc; use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project}; + use project::{FakeFs, Project, WorktreeSettings}; use serde_json::json; use settings::SettingsStore; use util::path; @@ -197,7 +257,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/project", + path!("/project"), json!({ "src": { "main.rs": "fn main() {}", @@ -327,7 +387,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/project", + path!("/project"), json!({ "empty_dir": {} }), @@ -359,7 +419,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/project", + path!("/project"), json!({ "file.txt": "content" }), @@ -412,4 +472,394 @@ mod tests { .contains("is not a directory") ); } + + #[gpui::test] + async fn test_list_directory_security(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "normal_dir": { + "file1.txt": "content", + "file2.txt": "content" + }, + ".mysecrets": "SECRET_KEY=abc123", + ".secretdir": { + "config": "special configuration", + "secret.txt": "secret content" + }, + ".mymetadata": "custom metadata", + "visible_dir": { + "normal.txt": "normal content", + "special.privatekey": "private key content", + "data.mysensitive": "sensitive data", + ".hidden_subdir": { + "hidden_file.txt": "hidden content" + } + } + }), + ) + .await; + + // Configure settings explicitly + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = Some(vec![ + "**/.secretdir".to_string(), + "**/.mymetadata".to_string(), + "**/.hidden_subdir".to_string(), + ]); + settings.private_files = Some(vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ]); + }); + }); + }); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let tool = Arc::new(ListDirectoryTool); + + // Listing root directory should exclude private and excluded files + let input = json!({ + "path": "project" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await + .unwrap(); + + let content = result.content.as_str().unwrap(); + + // Should include normal directories + assert!(content.contains("normal_dir"), "Should list normal_dir"); + assert!(content.contains("visible_dir"), "Should list visible_dir"); + + // Should NOT include excluded or private files + assert!( + !content.contains(".secretdir"), + "Should not list .secretdir (file_scan_exclusions)" + ); + assert!( + !content.contains(".mymetadata"), + "Should not list .mymetadata (file_scan_exclusions)" + ); + assert!( + !content.contains(".mysecrets"), + "Should not list .mysecrets (private_files)" + ); + + // Trying to list an excluded directory should fail + let input = json!({ + "path": "project/.secretdir" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await; + + assert!( + result.is_err(), + "Should not be able to list excluded directory" + ); + assert!( + result + .unwrap_err() + .to_string() + .contains("file_scan_exclusions"), + "Error should mention file_scan_exclusions" + ); + + // Listing a directory should exclude private files within it + let input = json!({ + "path": "project/visible_dir" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await + .unwrap(); + + let content = result.content.as_str().unwrap(); + + // Should include normal files + assert!(content.contains("normal.txt"), "Should list normal.txt"); + + // Should NOT include private files + assert!( + !content.contains("privatekey"), + "Should not list .privatekey files (private_files)" + ); + assert!( + !content.contains("mysensitive"), + "Should not list .mysensitive files (private_files)" + ); + + // Should NOT include subdirectories that match exclusions + assert!( + !content.contains(".hidden_subdir"), + "Should not list .hidden_subdir (file_scan_exclusions)" + ); + } + + #[gpui::test] + async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create first worktree with its own private files + fs.insert_tree( + path!("/worktree1"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/fixture.*"], + "private_files": ["**/secret.rs", "**/config.toml"] + }"# + }, + "src": { + "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", + "secret.rs": "const API_KEY: &str = \"secret_key_1\";", + "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" + }, + "tests": { + "test.rs": "mod tests { fn test_it() {} }", + "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" + } + }), + ) + .await; + + // Create second worktree with different private files + fs.insert_tree( + path!("/worktree2"), + json!({ + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/internal.*"], + "private_files": ["**/private.js", "**/data.json"] + }"# + }, + "lib": { + "public.js": "export function greet() { return 'Hello from worktree2'; }", + "private.js": "const SECRET_TOKEN = \"private_token_2\";", + "data.json": "{\"api_key\": \"json_secret_key\"}" + }, + "docs": { + "README.md": "# Public Documentation", + "internal.md": "# Internal Secrets and Configuration" + } + }), + ) + .await; + + // Set global settings + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); + settings.private_files = Some(vec!["**/.env".to_string()]); + }); + }); + }); + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + + // Wait for worktrees to be fully scanned + cx.executor().run_until_parked(); + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let tool = Arc::new(ListDirectoryTool); + + // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings + let input = json!({ + "path": "worktree1/src" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await + .unwrap(); + + let content = result.content.as_str().unwrap(); + assert!(content.contains("main.rs"), "Should list main.rs"); + assert!( + !content.contains("secret.rs"), + "Should not list secret.rs (local private_files)" + ); + assert!( + !content.contains("config.toml"), + "Should not list config.toml (local private_files)" + ); + + // Test listing worktree1/tests - should exclude fixture.sql based on local settings + let input = json!({ + "path": "worktree1/tests" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await + .unwrap(); + + let content = result.content.as_str().unwrap(); + assert!(content.contains("test.rs"), "Should list test.rs"); + assert!( + !content.contains("fixture.sql"), + "Should not list fixture.sql (local file_scan_exclusions)" + ); + + // Test listing worktree2/lib - should exclude private.js and data.json based on local settings + let input = json!({ + "path": "worktree2/lib" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await + .unwrap(); + + let content = result.content.as_str().unwrap(); + assert!(content.contains("public.js"), "Should list public.js"); + assert!( + !content.contains("private.js"), + "Should not list private.js (local private_files)" + ); + assert!( + !content.contains("data.json"), + "Should not list data.json (local private_files)" + ); + + // Test listing worktree2/docs - should exclude internal.md based on local settings + let input = json!({ + "path": "worktree2/docs" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await + .unwrap(); + + let content = result.content.as_str().unwrap(); + assert!(content.contains("README.md"), "Should list README.md"); + assert!( + !content.contains("internal.md"), + "Should not list internal.md (local file_scan_exclusions)" + ); + + // Test trying to list an excluded directory directly + let input = json!({ + "path": "worktree1/src/secret.rs" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await; + + // This should fail because we're trying to list a file, not a directory + assert!(result.is_err(), "Should fail when trying to list a file"); + } } diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index 39cc3165d8..33cbf9f557 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -12,9 +12,10 @@ use language::{Anchor, Point}; use language_model::{ LanguageModel, LanguageModelImage, LanguageModelRequest, LanguageModelToolSchemaFormat, }; -use project::{AgentLocation, Project}; +use project::{AgentLocation, Project, WorktreeSettings}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings; use std::sync::Arc; use ui::IconName; use util::markdown::MarkdownInlineCode; @@ -107,12 +108,48 @@ impl Tool for ReadFileTool { return Task::ready(Err(anyhow!("Path {} not found in project", &input.path))).into(); }; + // Error out if this path is either excluded or private in global settings + let global_settings = WorktreeSettings::get_global(cx); + if global_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}", + &input.path + ))) + .into(); + } + + if global_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot read file because its path matches the global `private_files` setting: {}", + &input.path + ))) + .into(); + } + + // Error out if this path is either excluded or private in worktree settings + let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx); + if worktree_settings.is_path_excluded(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}", + &input.path + ))) + .into(); + } + + if worktree_settings.is_path_private(&project_path.path) { + return Task::ready(Err(anyhow!( + "Cannot read file because its path matches the worktree `private_files` setting: {}", + &input.path + ))) + .into(); + } + let file_path = input.path.clone(); if image_store::is_image_file(&project, &project_path, cx) { if !model.supports_images() { return Task::ready(Err(anyhow!( - "Attempted to read an image, but Zed doesn't currently sending images to {}.", + "Attempted to read an image, but Zed doesn't currently support sending images to {}.", model.name().0 ))) .into(); @@ -252,10 +289,10 @@ impl Tool for ReadFileTool { #[cfg(test)] mod test { use super::*; - use gpui::{AppContext, TestAppContext}; + use gpui::{AppContext, TestAppContext, UpdateGlobal}; use language::{Language, LanguageConfig, LanguageMatcher}; use language_model::fake_provider::FakeLanguageModel; - use project::{FakeFs, Project}; + use project::{FakeFs, Project, WorktreeSettings}; use serde_json::json; use settings::SettingsStore; use util::path; @@ -265,7 +302,7 @@ mod test { init_test(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/root", json!({})).await; + fs.insert_tree(path!("/root"), json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); let model = Arc::new(FakeLanguageModel::default()); @@ -299,7 +336,7 @@ mod test { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/root", + path!("/root"), json!({ "small_file.txt": "This is a small file content" }), @@ -338,7 +375,7 @@ mod test { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/root", + path!("/root"), json!({ "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::>().join("\n") }), @@ -429,7 +466,7 @@ mod test { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/root", + path!("/root"), json!({ "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" }), @@ -470,7 +507,7 @@ mod test { let fs = FakeFs::new(cx.executor()); fs.insert_tree( - "/root", + path!("/root"), json!({ "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" }), @@ -601,4 +638,544 @@ mod test { ) .unwrap() } + + #[gpui::test] + async fn test_read_file_security(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + path!("/"), + json!({ + "project_root": { + "allowed_file.txt": "This file is in the project", + ".mysecrets": "SECRET_KEY=abc123", + ".secretdir": { + "config": "special configuration" + }, + ".mymetadata": "custom metadata", + "subdir": { + "normal_file.txt": "Normal file content", + "special.privatekey": "private key content", + "data.mysensitive": "sensitive data" + } + }, + "outside_project": { + "sensitive_file.txt": "This file is outside the project" + } + }), + ) + .await; + + cx.update(|cx| { + use gpui::UpdateGlobal; + use project::WorktreeSettings; + use settings::SettingsStore; + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = Some(vec![ + "**/.secretdir".to_string(), + "**/.mymetadata".to_string(), + ]); + settings.private_files = Some(vec![ + "**/.mysecrets".to_string(), + "**/*.privatekey".to_string(), + "**/*.mysensitive".to_string(), + ]); + }); + }); + }); + + let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + + // Reading a file outside the project worktree should fail + let result = cx + .update(|cx| { + let input = json!({ + "path": "/outside_project/sensitive_file.txt" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read an absolute path outside a worktree" + ); + + // Reading a file within the project should succeed + let result = cx + .update(|cx| { + let input = json!({ + "path": "project_root/allowed_file.txt" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!( + result.is_ok(), + "read_file_tool should be able to read files inside worktrees" + ); + + // Reading files that match file_scan_exclusions should fail + let result = cx + .update(|cx| { + let input = json!({ + "path": "project_root/.secretdir/config" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)" + ); + + let result = cx + .update(|cx| { + let input = json!({ + "path": "project_root/.mymetadata" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)" + ); + + // Reading private files should fail + let result = cx + .update(|cx| { + let input = json!({ + "path": "project_root/.mysecrets" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read .mysecrets (private_files)" + ); + + let result = cx + .update(|cx| { + let input = json!({ + "path": "project_root/subdir/special.privatekey" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read .privatekey files (private_files)" + ); + + let result = cx + .update(|cx| { + let input = json!({ + "path": "project_root/subdir/data.mysensitive" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read .mysensitive files (private_files)" + ); + + // Reading a normal file should still work, even with private_files configured + let result = cx + .update(|cx| { + let input = json!({ + "path": "project_root/subdir/normal_file.txt" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!(result.is_ok(), "Should be able to read normal files"); + assert_eq!( + result.unwrap().content.as_str().unwrap(), + "Normal file content" + ); + + // Path traversal attempts with .. should fail + let result = cx + .update(|cx| { + let input = json!({ + "path": "project_root/../outside_project/sensitive_file.txt" + }); + Arc::new(ReadFileTool) + .run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + .output + }) + .await; + assert!( + result.is_err(), + "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree" + ); + } + + #[gpui::test] + async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + // Create first worktree with its own private_files setting + fs.insert_tree( + path!("/worktree1"), + json!({ + "src": { + "main.rs": "fn main() { println!(\"Hello from worktree1\"); }", + "secret.rs": "const API_KEY: &str = \"secret_key_1\";", + "config.toml": "[database]\nurl = \"postgres://localhost/db1\"" + }, + "tests": { + "test.rs": "mod tests { fn test_it() {} }", + "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));" + }, + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/fixture.*"], + "private_files": ["**/secret.rs", "**/config.toml"] + }"# + } + }), + ) + .await; + + // Create second worktree with different private_files setting + fs.insert_tree( + path!("/worktree2"), + json!({ + "lib": { + "public.js": "export function greet() { return 'Hello from worktree2'; }", + "private.js": "const SECRET_TOKEN = \"private_token_2\";", + "data.json": "{\"api_key\": \"json_secret_key\"}" + }, + "docs": { + "README.md": "# Public Documentation", + "internal.md": "# Internal Secrets and Configuration" + }, + ".zed": { + "settings.json": r#"{ + "file_scan_exclusions": ["**/internal.*"], + "private_files": ["**/private.js", "**/data.json"] + }"# + } + }), + ) + .await; + + // Set global settings + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings::(cx, |settings| { + settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]); + settings.private_files = Some(vec!["**/.env".to_string()]); + }); + }); + }); + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let model = Arc::new(FakeLanguageModel::default()); + let tool = Arc::new(ReadFileTool); + + // Test reading allowed files in worktree1 + let input = json!({ + "path": "worktree1/src/main.rs" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await + .unwrap(); + + assert_eq!( + result.content.as_str().unwrap(), + "fn main() { println!(\"Hello from worktree1\"); }" + ); + + // Test reading private file in worktree1 should fail + let input = json!({ + "path": "worktree1/src/secret.rs" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `private_files` setting"), + "Error should mention worktree private_files setting" + ); + + // Test reading excluded file in worktree1 should fail + let input = json!({ + "path": "worktree1/tests/fixture.sql" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `file_scan_exclusions` setting"), + "Error should mention worktree file_scan_exclusions setting" + ); + + // Test reading allowed files in worktree2 + let input = json!({ + "path": "worktree2/lib/public.js" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await + .unwrap(); + + assert_eq!( + result.content.as_str().unwrap(), + "export function greet() { return 'Hello from worktree2'; }" + ); + + // Test reading private file in worktree2 should fail + let input = json!({ + "path": "worktree2/lib/private.js" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `private_files` setting"), + "Error should mention worktree private_files setting" + ); + + // Test reading excluded file in worktree2 should fail + let input = json!({ + "path": "worktree2/docs/internal.md" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `file_scan_exclusions` setting"), + "Error should mention worktree file_scan_exclusions setting" + ); + + // Test that files allowed in one worktree but not in another are handled correctly + // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2) + let input = json!({ + "path": "worktree1/src/config.toml" + }); + + let result = cx + .update(|cx| { + tool.clone().run( + input, + Arc::default(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }) + .output + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("worktree `private_files` setting"), + "Config.toml should be blocked by worktree1's private_files setting" + ); + } }