1use super::tool_permissions::{
2 ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
3 resolve_project_path,
4};
5use crate::{AgentTool, ToolCallEventStream};
6use agent_client_protocol::ToolKind;
7use anyhow::{Context as _, Result, anyhow};
8use gpui::{App, Entity, SharedString, Task};
9use project::{Project, ProjectPath, WorktreeSettings};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use settings::Settings;
13use std::fmt::Write;
14use std::sync::Arc;
15use util::markdown::MarkdownInlineCode;
16
17/// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase.
18#[derive(Debug, Serialize, Deserialize, JsonSchema)]
19pub struct ListDirectoryToolInput {
20 /// The fully-qualified path of the directory to list in the project.
21 ///
22 /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
23 ///
24 /// <example>
25 /// If the project has the following root directories:
26 ///
27 /// - directory1
28 /// - directory2
29 ///
30 /// You can list the contents of `directory1` by using the path `directory1`.
31 /// </example>
32 ///
33 /// <example>
34 /// If the project has the following root directories:
35 ///
36 /// - foo
37 /// - bar
38 ///
39 /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
40 /// </example>
41 pub path: String,
42}
43
44pub struct ListDirectoryTool {
45 project: Entity<Project>,
46}
47
48impl ListDirectoryTool {
49 pub fn new(project: Entity<Project>) -> Self {
50 Self { project }
51 }
52
53 fn build_directory_output(
54 project: &Entity<Project>,
55 project_path: &ProjectPath,
56 input_path: &str,
57 cx: &App,
58 ) -> Result<String> {
59 let worktree = project
60 .read(cx)
61 .worktree_for_id(project_path.worktree_id, cx)
62 .with_context(|| format!("{input_path} is not in a known worktree"))?;
63
64 let global_settings = WorktreeSettings::get_global(cx);
65 let worktree_settings = WorktreeSettings::get(Some(project_path.into()), cx);
66 let worktree_snapshot = worktree.read(cx).snapshot();
67 let worktree_root_name = worktree.read(cx).root_name();
68
69 let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
70 return Err(anyhow!("Path not found: {}", input_path));
71 };
72
73 if !entry.is_dir() {
74 return Err(anyhow!("{input_path} is not a directory."));
75 }
76
77 let mut folders = Vec::new();
78 let mut files = Vec::new();
79
80 for entry in worktree_snapshot.child_entries(&project_path.path) {
81 // Skip private and excluded files and directories
82 if global_settings.is_path_private(&entry.path)
83 || global_settings.is_path_excluded(&entry.path)
84 {
85 continue;
86 }
87
88 let project_path: ProjectPath = (worktree_snapshot.id(), entry.path.clone()).into();
89 if worktree_settings.is_path_excluded(&project_path.path)
90 || worktree_settings.is_path_private(&project_path.path)
91 {
92 continue;
93 }
94
95 let full_path = worktree_root_name
96 .join(&entry.path)
97 .display(worktree_snapshot.path_style())
98 .into_owned();
99 if entry.is_dir() {
100 folders.push(full_path);
101 } else {
102 files.push(full_path);
103 }
104 }
105
106 let mut output = String::new();
107
108 if !folders.is_empty() {
109 writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
110 }
111
112 if !files.is_empty() {
113 writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
114 }
115
116 if output.is_empty() {
117 writeln!(output, "{input_path} is empty.").unwrap();
118 }
119
120 Ok(output)
121 }
122}
123
124impl AgentTool for ListDirectoryTool {
125 type Input = ListDirectoryToolInput;
126 type Output = String;
127
128 const NAME: &'static str = "list_directory";
129
130 fn kind() -> ToolKind {
131 ToolKind::Read
132 }
133
134 fn initial_title(
135 &self,
136 input: Result<Self::Input, serde_json::Value>,
137 _cx: &mut App,
138 ) -> SharedString {
139 if let Ok(input) = input {
140 let path = MarkdownInlineCode(&input.path);
141 format!("List the {path} directory's contents").into()
142 } else {
143 "List directory".into()
144 }
145 }
146
147 fn run(
148 self: Arc<Self>,
149 input: Self::Input,
150 event_stream: ToolCallEventStream,
151 cx: &mut App,
152 ) -> Task<Result<Self::Output>> {
153 // Sometimes models will return these even though we tell it to give a path and not a glob.
154 // When this happens, just list the root worktree directories.
155 if matches!(input.path.as_str(), "." | "" | "./" | "*") {
156 let output = self
157 .project
158 .read(cx)
159 .worktrees(cx)
160 .filter_map(|worktree| {
161 let worktree = worktree.read(cx);
162 let root_entry = worktree.root_entry()?;
163 if root_entry.is_dir() {
164 Some(root_entry.path.display(worktree.path_style()))
165 } else {
166 None
167 }
168 })
169 .collect::<Vec<_>>()
170 .join("\n");
171
172 return Task::ready(Ok(output));
173 }
174
175 let project = self.project.clone();
176 cx.spawn(async move |cx| {
177 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
178 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
179
180 let (project_path, symlink_canonical_target) =
181 project.read_with(cx, |project, cx| -> anyhow::Result<_> {
182 let resolved = resolve_project_path(project, &input.path, &canonical_roots, cx)?;
183 Ok(match resolved {
184 ResolvedProjectPath::Safe(path) => (path, None),
185 ResolvedProjectPath::SymlinkEscape {
186 project_path,
187 canonical_target,
188 } => (project_path, Some(canonical_target)),
189 })
190 })?;
191
192 // Check settings exclusions synchronously
193 project.read_with(cx, |project, cx| {
194 let worktree = project
195 .worktree_for_id(project_path.worktree_id, cx)
196 .with_context(|| {
197 format!("{} is not in a known worktree", &input.path)
198 })?;
199
200 let global_settings = WorktreeSettings::get_global(cx);
201 if global_settings.is_path_excluded(&project_path.path) {
202 anyhow::bail!(
203 "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
204 &input.path
205 );
206 }
207
208 if global_settings.is_path_private(&project_path.path) {
209 anyhow::bail!(
210 "Cannot list directory because its path matches the user's global `private_files` setting: {}",
211 &input.path
212 );
213 }
214
215 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
216 if worktree_settings.is_path_excluded(&project_path.path) {
217 anyhow::bail!(
218 "Cannot list directory because its path matches the user's worktree `file_scan_exclusions` setting: {}",
219 &input.path
220 );
221 }
222
223 if worktree_settings.is_path_private(&project_path.path) {
224 anyhow::bail!(
225 "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
226 &input.path
227 );
228 }
229
230 let worktree_snapshot = worktree.read(cx).snapshot();
231 let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
232 anyhow::bail!("Path not found: {}", input.path);
233 };
234 if !entry.is_dir() {
235 anyhow::bail!("{} is not a directory.", input.path);
236 }
237
238 anyhow::Ok(())
239 })?;
240
241 if let Some(canonical_target) = &symlink_canonical_target {
242 let authorize = cx.update(|cx| {
243 authorize_symlink_access(
244 Self::NAME,
245 &input.path,
246 canonical_target,
247 &event_stream,
248 cx,
249 )
250 });
251 authorize.await?;
252 }
253
254 let list_path = input.path;
255 cx.update(|cx| {
256 Self::build_directory_output(&project, &project_path, &list_path, cx)
257 })
258 })
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use agent_client_protocol as acp;
266 use fs::Fs as _;
267 use gpui::{TestAppContext, UpdateGlobal};
268 use indoc::indoc;
269 use project::{FakeFs, Project};
270 use serde_json::json;
271 use settings::SettingsStore;
272 use std::path::PathBuf;
273 use util::path;
274
275 fn platform_paths(path_str: &str) -> String {
276 if cfg!(target_os = "windows") {
277 path_str.replace("/", "\\")
278 } else {
279 path_str.to_string()
280 }
281 }
282
283 fn init_test(cx: &mut TestAppContext) {
284 cx.update(|cx| {
285 let settings_store = SettingsStore::test(cx);
286 cx.set_global(settings_store);
287 });
288 }
289
290 #[gpui::test]
291 async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
292 init_test(cx);
293
294 let fs = FakeFs::new(cx.executor());
295 fs.insert_tree(
296 path!("/project"),
297 json!({
298 "src": {
299 "main.rs": "fn main() {}",
300 "lib.rs": "pub fn hello() {}",
301 "models": {
302 "user.rs": "struct User {}",
303 "post.rs": "struct Post {}"
304 },
305 "utils": {
306 "helper.rs": "pub fn help() {}"
307 }
308 },
309 "tests": {
310 "integration_test.rs": "#[test] fn test() {}"
311 },
312 "README.md": "# Project",
313 "Cargo.toml": "[package]"
314 }),
315 )
316 .await;
317
318 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
319 let tool = Arc::new(ListDirectoryTool::new(project));
320
321 // Test listing root directory
322 let input = ListDirectoryToolInput {
323 path: "project".into(),
324 };
325 let output = cx
326 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
327 .await
328 .unwrap();
329 assert_eq!(
330 output,
331 platform_paths(indoc! {"
332 # Folders:
333 project/src
334 project/tests
335
336 # Files:
337 project/Cargo.toml
338 project/README.md
339 "})
340 );
341
342 // Test listing src directory
343 let input = ListDirectoryToolInput {
344 path: "project/src".into(),
345 };
346 let output = cx
347 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
348 .await
349 .unwrap();
350 assert_eq!(
351 output,
352 platform_paths(indoc! {"
353 # Folders:
354 project/src/models
355 project/src/utils
356
357 # Files:
358 project/src/lib.rs
359 project/src/main.rs
360 "})
361 );
362
363 // Test listing directory with only files
364 let input = ListDirectoryToolInput {
365 path: "project/tests".into(),
366 };
367 let output = cx
368 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
369 .await
370 .unwrap();
371 assert!(!output.contains("# Folders:"));
372 assert!(output.contains("# Files:"));
373 assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
374 }
375
376 #[gpui::test]
377 async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
378 init_test(cx);
379
380 let fs = FakeFs::new(cx.executor());
381 fs.insert_tree(
382 path!("/project"),
383 json!({
384 "empty_dir": {}
385 }),
386 )
387 .await;
388
389 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
390 let tool = Arc::new(ListDirectoryTool::new(project));
391
392 let input = ListDirectoryToolInput {
393 path: "project/empty_dir".into(),
394 };
395 let output = cx
396 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
397 .await
398 .unwrap();
399 assert_eq!(output, "project/empty_dir is empty.\n");
400 }
401
402 #[gpui::test]
403 async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
404 init_test(cx);
405
406 let fs = FakeFs::new(cx.executor());
407 fs.insert_tree(
408 path!("/project"),
409 json!({
410 "file.txt": "content"
411 }),
412 )
413 .await;
414
415 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
416 let tool = Arc::new(ListDirectoryTool::new(project));
417
418 // Test non-existent path
419 let input = ListDirectoryToolInput {
420 path: "project/nonexistent".into(),
421 };
422 let output = cx
423 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
424 .await;
425 assert!(output.unwrap_err().to_string().contains("Path not found"));
426
427 // Test trying to list a file instead of directory
428 let input = ListDirectoryToolInput {
429 path: "project/file.txt".into(),
430 };
431 let output = cx
432 .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
433 .await;
434 assert!(
435 output
436 .unwrap_err()
437 .to_string()
438 .contains("is not a directory")
439 );
440 }
441
442 #[gpui::test]
443 async fn test_list_directory_security(cx: &mut TestAppContext) {
444 init_test(cx);
445
446 let fs = FakeFs::new(cx.executor());
447 fs.insert_tree(
448 path!("/project"),
449 json!({
450 "normal_dir": {
451 "file1.txt": "content",
452 "file2.txt": "content"
453 },
454 ".mysecrets": "SECRET_KEY=abc123",
455 ".secretdir": {
456 "config": "special configuration",
457 "secret.txt": "secret content"
458 },
459 ".mymetadata": "custom metadata",
460 "visible_dir": {
461 "normal.txt": "normal content",
462 "special.privatekey": "private key content",
463 "data.mysensitive": "sensitive data",
464 ".hidden_subdir": {
465 "hidden_file.txt": "hidden content"
466 }
467 }
468 }),
469 )
470 .await;
471
472 // Configure settings explicitly
473 cx.update(|cx| {
474 SettingsStore::update_global(cx, |store, cx| {
475 store.update_user_settings(cx, |settings| {
476 settings.project.worktree.file_scan_exclusions = Some(vec![
477 "**/.secretdir".to_string(),
478 "**/.mymetadata".to_string(),
479 "**/.hidden_subdir".to_string(),
480 ]);
481 settings.project.worktree.private_files = Some(
482 vec![
483 "**/.mysecrets".to_string(),
484 "**/*.privatekey".to_string(),
485 "**/*.mysensitive".to_string(),
486 ]
487 .into(),
488 );
489 });
490 });
491 });
492
493 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
494 let tool = Arc::new(ListDirectoryTool::new(project));
495
496 // Listing root directory should exclude private and excluded files
497 let input = ListDirectoryToolInput {
498 path: "project".into(),
499 };
500 let output = cx
501 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
502 .await
503 .unwrap();
504
505 // Should include normal directories
506 assert!(output.contains("normal_dir"), "Should list normal_dir");
507 assert!(output.contains("visible_dir"), "Should list visible_dir");
508
509 // Should NOT include excluded or private files
510 assert!(
511 !output.contains(".secretdir"),
512 "Should not list .secretdir (file_scan_exclusions)"
513 );
514 assert!(
515 !output.contains(".mymetadata"),
516 "Should not list .mymetadata (file_scan_exclusions)"
517 );
518 assert!(
519 !output.contains(".mysecrets"),
520 "Should not list .mysecrets (private_files)"
521 );
522
523 // Trying to list an excluded directory should fail
524 let input = ListDirectoryToolInput {
525 path: "project/.secretdir".into(),
526 };
527 let output = cx
528 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
529 .await;
530 assert!(
531 output
532 .unwrap_err()
533 .to_string()
534 .contains("file_scan_exclusions"),
535 "Error should mention file_scan_exclusions"
536 );
537
538 // Listing a directory should exclude private files within it
539 let input = ListDirectoryToolInput {
540 path: "project/visible_dir".into(),
541 };
542 let output = cx
543 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
544 .await
545 .unwrap();
546
547 // Should include normal files
548 assert!(output.contains("normal.txt"), "Should list normal.txt");
549
550 // Should NOT include private files
551 assert!(
552 !output.contains("privatekey"),
553 "Should not list .privatekey files (private_files)"
554 );
555 assert!(
556 !output.contains("mysensitive"),
557 "Should not list .mysensitive files (private_files)"
558 );
559
560 // Should NOT include subdirectories that match exclusions
561 assert!(
562 !output.contains(".hidden_subdir"),
563 "Should not list .hidden_subdir (file_scan_exclusions)"
564 );
565 }
566
567 #[gpui::test]
568 async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
569 init_test(cx);
570
571 let fs = FakeFs::new(cx.executor());
572
573 // Create first worktree with its own private files
574 fs.insert_tree(
575 path!("/worktree1"),
576 json!({
577 ".zed": {
578 "settings.json": r#"{
579 "file_scan_exclusions": ["**/fixture.*"],
580 "private_files": ["**/secret.rs", "**/config.toml"]
581 }"#
582 },
583 "src": {
584 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
585 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
586 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
587 },
588 "tests": {
589 "test.rs": "mod tests { fn test_it() {} }",
590 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
591 }
592 }),
593 )
594 .await;
595
596 // Create second worktree with different private files
597 fs.insert_tree(
598 path!("/worktree2"),
599 json!({
600 ".zed": {
601 "settings.json": r#"{
602 "file_scan_exclusions": ["**/internal.*"],
603 "private_files": ["**/private.js", "**/data.json"]
604 }"#
605 },
606 "lib": {
607 "public.js": "export function greet() { return 'Hello from worktree2'; }",
608 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
609 "data.json": "{\"api_key\": \"json_secret_key\"}"
610 },
611 "docs": {
612 "README.md": "# Public Documentation",
613 "internal.md": "# Internal Secrets and Configuration"
614 }
615 }),
616 )
617 .await;
618
619 // Set global settings
620 cx.update(|cx| {
621 SettingsStore::update_global(cx, |store, cx| {
622 store.update_user_settings(cx, |settings| {
623 settings.project.worktree.file_scan_exclusions =
624 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
625 settings.project.worktree.private_files =
626 Some(vec!["**/.env".to_string()].into());
627 });
628 });
629 });
630
631 let project = Project::test(
632 fs.clone(),
633 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
634 cx,
635 )
636 .await;
637
638 // Wait for worktrees to be fully scanned
639 cx.executor().run_until_parked();
640
641 let tool = Arc::new(ListDirectoryTool::new(project));
642
643 // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
644 let input = ListDirectoryToolInput {
645 path: "worktree1/src".into(),
646 };
647 let output = cx
648 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
649 .await
650 .unwrap();
651 assert!(output.contains("main.rs"), "Should list main.rs");
652 assert!(
653 !output.contains("secret.rs"),
654 "Should not list secret.rs (local private_files)"
655 );
656 assert!(
657 !output.contains("config.toml"),
658 "Should not list config.toml (local private_files)"
659 );
660
661 // Test listing worktree1/tests - should exclude fixture.sql based on local settings
662 let input = ListDirectoryToolInput {
663 path: "worktree1/tests".into(),
664 };
665 let output = cx
666 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
667 .await
668 .unwrap();
669 assert!(output.contains("test.rs"), "Should list test.rs");
670 assert!(
671 !output.contains("fixture.sql"),
672 "Should not list fixture.sql (local file_scan_exclusions)"
673 );
674
675 // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
676 let input = ListDirectoryToolInput {
677 path: "worktree2/lib".into(),
678 };
679 let output = cx
680 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
681 .await
682 .unwrap();
683 assert!(output.contains("public.js"), "Should list public.js");
684 assert!(
685 !output.contains("private.js"),
686 "Should not list private.js (local private_files)"
687 );
688 assert!(
689 !output.contains("data.json"),
690 "Should not list data.json (local private_files)"
691 );
692
693 // Test listing worktree2/docs - should exclude internal.md based on local settings
694 let input = ListDirectoryToolInput {
695 path: "worktree2/docs".into(),
696 };
697 let output = cx
698 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
699 .await
700 .unwrap();
701 assert!(output.contains("README.md"), "Should list README.md");
702 assert!(
703 !output.contains("internal.md"),
704 "Should not list internal.md (local file_scan_exclusions)"
705 );
706
707 // Test trying to list an excluded directory directly
708 let input = ListDirectoryToolInput {
709 path: "worktree1/src/secret.rs".into(),
710 };
711 let output = cx
712 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
713 .await;
714 assert!(
715 output
716 .unwrap_err()
717 .to_string()
718 .contains("Cannot list directory"),
719 );
720 }
721
722 #[gpui::test]
723 async fn test_list_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
724 init_test(cx);
725
726 let fs = FakeFs::new(cx.executor());
727 fs.insert_tree(
728 path!("/root"),
729 json!({
730 "project": {
731 "src": {
732 "main.rs": "fn main() {}"
733 }
734 },
735 "external": {
736 "secrets": {
737 "key.txt": "SECRET_KEY=abc123"
738 }
739 }
740 }),
741 )
742 .await;
743
744 fs.create_symlink(
745 path!("/root/project/link_to_external").as_ref(),
746 PathBuf::from("../external"),
747 )
748 .await
749 .unwrap();
750
751 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
752 cx.executor().run_until_parked();
753
754 let tool = Arc::new(ListDirectoryTool::new(project));
755
756 let (event_stream, mut event_rx) = ToolCallEventStream::test();
757 let task = cx.update(|cx| {
758 tool.clone().run(
759 ListDirectoryToolInput {
760 path: "project/link_to_external".into(),
761 },
762 event_stream,
763 cx,
764 )
765 });
766
767 let auth = event_rx.expect_authorization().await;
768 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
769 assert!(
770 title.contains("points outside the project"),
771 "Authorization title should mention symlink escape, got: {title}",
772 );
773
774 auth.response
775 .send(acp::PermissionOptionId::new("allow"))
776 .unwrap();
777
778 let result = task.await;
779 assert!(
780 result.is_ok(),
781 "Tool should succeed after authorization: {result:?}"
782 );
783 }
784
785 #[gpui::test]
786 async fn test_list_directory_symlink_escape_denied(cx: &mut TestAppContext) {
787 init_test(cx);
788
789 let fs = FakeFs::new(cx.executor());
790 fs.insert_tree(
791 path!("/root"),
792 json!({
793 "project": {
794 "src": {
795 "main.rs": "fn main() {}"
796 }
797 },
798 "external": {
799 "secrets": {}
800 }
801 }),
802 )
803 .await;
804
805 fs.create_symlink(
806 path!("/root/project/link_to_external").as_ref(),
807 PathBuf::from("../external"),
808 )
809 .await
810 .unwrap();
811
812 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
813 cx.executor().run_until_parked();
814
815 let tool = Arc::new(ListDirectoryTool::new(project));
816
817 let (event_stream, mut event_rx) = ToolCallEventStream::test();
818 let task = cx.update(|cx| {
819 tool.clone().run(
820 ListDirectoryToolInput {
821 path: "project/link_to_external".into(),
822 },
823 event_stream,
824 cx,
825 )
826 });
827
828 let auth = event_rx.expect_authorization().await;
829
830 // Deny by dropping the response sender without sending
831 drop(auth);
832
833 let result = task.await;
834 assert!(
835 result.is_err(),
836 "Tool should fail when authorization is denied"
837 );
838 }
839
840 #[gpui::test]
841 async fn test_list_directory_symlink_escape_private_path_no_authorization(
842 cx: &mut TestAppContext,
843 ) {
844 init_test(cx);
845
846 let fs = FakeFs::new(cx.executor());
847 fs.insert_tree(
848 path!("/root"),
849 json!({
850 "project": {
851 "src": {
852 "main.rs": "fn main() {}"
853 }
854 },
855 "external": {
856 "secrets": {}
857 }
858 }),
859 )
860 .await;
861
862 fs.create_symlink(
863 path!("/root/project/link_to_external").as_ref(),
864 PathBuf::from("../external"),
865 )
866 .await
867 .unwrap();
868
869 cx.update(|cx| {
870 SettingsStore::update_global(cx, |store, cx| {
871 store.update_user_settings(cx, |settings| {
872 settings.project.worktree.private_files =
873 Some(vec!["**/link_to_external".to_string()].into());
874 });
875 });
876 });
877
878 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
879 cx.executor().run_until_parked();
880
881 let tool = Arc::new(ListDirectoryTool::new(project));
882
883 let (event_stream, mut event_rx) = ToolCallEventStream::test();
884 let result = cx
885 .update(|cx| {
886 tool.clone().run(
887 ListDirectoryToolInput {
888 path: "project/link_to_external".into(),
889 },
890 event_stream,
891 cx,
892 )
893 })
894 .await;
895
896 assert!(
897 result.is_err(),
898 "Expected list_directory to fail on private path"
899 );
900 let error = result.unwrap_err().to_string();
901 assert!(
902 error.contains("private"),
903 "Expected private path validation error, got: {error}"
904 );
905
906 let event = event_rx.try_next();
907 assert!(
908 !matches!(
909 event,
910 Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
911 _
912 ))))
913 ),
914 "No authorization should be requested when validation fails before listing",
915 );
916 }
917
918 #[gpui::test]
919 async fn test_list_directory_no_authorization_for_normal_paths(cx: &mut TestAppContext) {
920 init_test(cx);
921
922 let fs = FakeFs::new(cx.executor());
923 fs.insert_tree(
924 path!("/project"),
925 json!({
926 "src": {
927 "main.rs": "fn main() {}"
928 }
929 }),
930 )
931 .await;
932
933 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
934 let tool = Arc::new(ListDirectoryTool::new(project));
935
936 let (event_stream, mut event_rx) = ToolCallEventStream::test();
937 let result = cx
938 .update(|cx| {
939 tool.clone().run(
940 ListDirectoryToolInput {
941 path: "project/src".into(),
942 },
943 event_stream,
944 cx,
945 )
946 })
947 .await;
948
949 assert!(
950 result.is_ok(),
951 "Normal path should succeed without authorization"
952 );
953
954 let event = event_rx.try_next();
955 assert!(
956 !matches!(
957 event,
958 Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
959 _
960 ))))
961 ),
962 "No authorization should be requested for normal paths",
963 );
964 }
965
966 #[gpui::test]
967 async fn test_list_directory_intra_project_symlink_no_authorization(cx: &mut TestAppContext) {
968 init_test(cx);
969
970 let fs = FakeFs::new(cx.executor());
971 fs.insert_tree(
972 path!("/project"),
973 json!({
974 "real_dir": {
975 "file.txt": "content"
976 }
977 }),
978 )
979 .await;
980
981 fs.create_symlink(
982 path!("/project/link_dir").as_ref(),
983 PathBuf::from("real_dir"),
984 )
985 .await
986 .unwrap();
987
988 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
989 cx.executor().run_until_parked();
990
991 let tool = Arc::new(ListDirectoryTool::new(project));
992
993 let (event_stream, mut event_rx) = ToolCallEventStream::test();
994 let result = cx
995 .update(|cx| {
996 tool.clone().run(
997 ListDirectoryToolInput {
998 path: "project/link_dir".into(),
999 },
1000 event_stream,
1001 cx,
1002 )
1003 })
1004 .await;
1005
1006 assert!(
1007 result.is_ok(),
1008 "Intra-project symlink should succeed without authorization: {result:?}",
1009 );
1010
1011 let event = event_rx.try_next();
1012 assert!(
1013 !matches!(
1014 event,
1015 Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
1016 _
1017 ))))
1018 ),
1019 "No authorization should be requested for intra-project symlinks",
1020 );
1021 }
1022}