Have tools respect private and excluded file settings (#32036)

Based on a Slack conversation with @notpeter - this prevents secrets in
private/excluded files from being sent by the agent to third parties for
tools that don't require confirmation.

Of course, the agent can still use the terminal tool or MCP to access
these, but those require confirmation before they run (unlike these
tools).

This change doesn't seem to cause any trouble for evals:

<img width="730" alt="Screenshot 2025-06-03 at 8 48 33 PM"
src="https://github.com/user-attachments/assets/d90221be-f946-4af2-b57b-4aa047e86853"
/>


Release Notes:

- N/A
This commit is contained in:
Richard Feldman 2025-06-05 04:02:11 -04:00 committed by GitHub
parent 9d533f9d30
commit 8af984ae70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1570 additions and 28 deletions

View file

@ -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()
}
}

View file

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

View 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"
);
}
}