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:
parent
9d533f9d30
commit
8af984ae70
3 changed files with 1570 additions and 28 deletions
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue