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