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