Detailed changes
@@ -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::<WorktreeSettings>(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::<WorktreeSettings>(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<String> {
+ results
+ .lines()
+ .filter(|line| line.starts_with("## Matches in "))
+ .map(|line| {
+ line.strip_prefix("## Matches in ")
+ .unwrap()
+ .trim()
+ .to_string()
+ })
+ .collect()
+ }
}
@@ -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::<WorktreeSettings>(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::<WorktreeSettings>(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");
+ }
}
@@ -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::<Vec<_>>().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::<WorktreeSettings>(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::<WorktreeSettings>(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"
+ );
+ }
}