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