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, 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 }).map_err(|e| e.to_string())?;
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 }).map_err(|e| e.to_string())?;
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.map_err(|e| e.to_string())?;
252 }
253
254 let list_path = input.path;
255 cx.update(|cx| {
256 Self::build_directory_output(&project, &project_path, &list_path, cx)
257 }).map_err(|e| e.to_string())
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().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!(output.unwrap_err().contains("is not a directory"));
435 }
436
437 #[gpui::test]
438 async fn test_list_directory_security(cx: &mut TestAppContext) {
439 init_test(cx);
440
441 let fs = FakeFs::new(cx.executor());
442 fs.insert_tree(
443 path!("/project"),
444 json!({
445 "normal_dir": {
446 "file1.txt": "content",
447 "file2.txt": "content"
448 },
449 ".mysecrets": "SECRET_KEY=abc123",
450 ".secretdir": {
451 "config": "special configuration",
452 "secret.txt": "secret content"
453 },
454 ".mymetadata": "custom metadata",
455 "visible_dir": {
456 "normal.txt": "normal content",
457 "special.privatekey": "private key content",
458 "data.mysensitive": "sensitive data",
459 ".hidden_subdir": {
460 "hidden_file.txt": "hidden content"
461 }
462 }
463 }),
464 )
465 .await;
466
467 // Configure settings explicitly
468 cx.update(|cx| {
469 SettingsStore::update_global(cx, |store, cx| {
470 store.update_user_settings(cx, |settings| {
471 settings.project.worktree.file_scan_exclusions = Some(vec![
472 "**/.secretdir".to_string(),
473 "**/.mymetadata".to_string(),
474 "**/.hidden_subdir".to_string(),
475 ]);
476 settings.project.worktree.private_files = Some(
477 vec![
478 "**/.mysecrets".to_string(),
479 "**/*.privatekey".to_string(),
480 "**/*.mysensitive".to_string(),
481 ]
482 .into(),
483 );
484 });
485 });
486 });
487
488 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
489 let tool = Arc::new(ListDirectoryTool::new(project));
490
491 // Listing root directory should exclude private and excluded files
492 let input = ListDirectoryToolInput {
493 path: "project".into(),
494 };
495 let output = cx
496 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
497 .await
498 .unwrap();
499
500 // Should include normal directories
501 assert!(output.contains("normal_dir"), "Should list normal_dir");
502 assert!(output.contains("visible_dir"), "Should list visible_dir");
503
504 // Should NOT include excluded or private files
505 assert!(
506 !output.contains(".secretdir"),
507 "Should not list .secretdir (file_scan_exclusions)"
508 );
509 assert!(
510 !output.contains(".mymetadata"),
511 "Should not list .mymetadata (file_scan_exclusions)"
512 );
513 assert!(
514 !output.contains(".mysecrets"),
515 "Should not list .mysecrets (private_files)"
516 );
517
518 // Trying to list an excluded directory should fail
519 let input = ListDirectoryToolInput {
520 path: "project/.secretdir".into(),
521 };
522 let output = cx
523 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
524 .await;
525 assert!(
526 output.unwrap_err().contains("file_scan_exclusions"),
527 "Error should mention file_scan_exclusions"
528 );
529
530 // Listing a directory should exclude private files within it
531 let input = ListDirectoryToolInput {
532 path: "project/visible_dir".into(),
533 };
534 let output = cx
535 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
536 .await
537 .unwrap();
538
539 // Should include normal files
540 assert!(output.contains("normal.txt"), "Should list normal.txt");
541
542 // Should NOT include private files
543 assert!(
544 !output.contains("privatekey"),
545 "Should not list .privatekey files (private_files)"
546 );
547 assert!(
548 !output.contains("mysensitive"),
549 "Should not list .mysensitive files (private_files)"
550 );
551
552 // Should NOT include subdirectories that match exclusions
553 assert!(
554 !output.contains(".hidden_subdir"),
555 "Should not list .hidden_subdir (file_scan_exclusions)"
556 );
557 }
558
559 #[gpui::test]
560 async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
561 init_test(cx);
562
563 let fs = FakeFs::new(cx.executor());
564
565 // Create first worktree with its own private files
566 fs.insert_tree(
567 path!("/worktree1"),
568 json!({
569 ".zed": {
570 "settings.json": r#"{
571 "file_scan_exclusions": ["**/fixture.*"],
572 "private_files": ["**/secret.rs", "**/config.toml"]
573 }"#
574 },
575 "src": {
576 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
577 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
578 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
579 },
580 "tests": {
581 "test.rs": "mod tests { fn test_it() {} }",
582 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
583 }
584 }),
585 )
586 .await;
587
588 // Create second worktree with different private files
589 fs.insert_tree(
590 path!("/worktree2"),
591 json!({
592 ".zed": {
593 "settings.json": r#"{
594 "file_scan_exclusions": ["**/internal.*"],
595 "private_files": ["**/private.js", "**/data.json"]
596 }"#
597 },
598 "lib": {
599 "public.js": "export function greet() { return 'Hello from worktree2'; }",
600 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
601 "data.json": "{\"api_key\": \"json_secret_key\"}"
602 },
603 "docs": {
604 "README.md": "# Public Documentation",
605 "internal.md": "# Internal Secrets and Configuration"
606 }
607 }),
608 )
609 .await;
610
611 // Set global settings
612 cx.update(|cx| {
613 SettingsStore::update_global(cx, |store, cx| {
614 store.update_user_settings(cx, |settings| {
615 settings.project.worktree.file_scan_exclusions =
616 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
617 settings.project.worktree.private_files =
618 Some(vec!["**/.env".to_string()].into());
619 });
620 });
621 });
622
623 let project = Project::test(
624 fs.clone(),
625 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
626 cx,
627 )
628 .await;
629
630 // Wait for worktrees to be fully scanned
631 cx.executor().run_until_parked();
632
633 let tool = Arc::new(ListDirectoryTool::new(project));
634
635 // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
636 let input = ListDirectoryToolInput {
637 path: "worktree1/src".into(),
638 };
639 let output = cx
640 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
641 .await
642 .unwrap();
643 assert!(output.contains("main.rs"), "Should list main.rs");
644 assert!(
645 !output.contains("secret.rs"),
646 "Should not list secret.rs (local private_files)"
647 );
648 assert!(
649 !output.contains("config.toml"),
650 "Should not list config.toml (local private_files)"
651 );
652
653 // Test listing worktree1/tests - should exclude fixture.sql based on local settings
654 let input = ListDirectoryToolInput {
655 path: "worktree1/tests".into(),
656 };
657 let output = cx
658 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
659 .await
660 .unwrap();
661 assert!(output.contains("test.rs"), "Should list test.rs");
662 assert!(
663 !output.contains("fixture.sql"),
664 "Should not list fixture.sql (local file_scan_exclusions)"
665 );
666
667 // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
668 let input = ListDirectoryToolInput {
669 path: "worktree2/lib".into(),
670 };
671 let output = cx
672 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
673 .await
674 .unwrap();
675 assert!(output.contains("public.js"), "Should list public.js");
676 assert!(
677 !output.contains("private.js"),
678 "Should not list private.js (local private_files)"
679 );
680 assert!(
681 !output.contains("data.json"),
682 "Should not list data.json (local private_files)"
683 );
684
685 // Test listing worktree2/docs - should exclude internal.md based on local settings
686 let input = ListDirectoryToolInput {
687 path: "worktree2/docs".into(),
688 };
689 let output = cx
690 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
691 .await
692 .unwrap();
693 assert!(output.contains("README.md"), "Should list README.md");
694 assert!(
695 !output.contains("internal.md"),
696 "Should not list internal.md (local file_scan_exclusions)"
697 );
698
699 // Test trying to list an excluded directory directly
700 let input = ListDirectoryToolInput {
701 path: "worktree1/src/secret.rs".into(),
702 };
703 let output = cx
704 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
705 .await;
706 assert!(output.unwrap_err().contains("Cannot list directory"),);
707 }
708
709 #[gpui::test]
710 async fn test_list_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
711 init_test(cx);
712
713 let fs = FakeFs::new(cx.executor());
714 fs.insert_tree(
715 path!("/root"),
716 json!({
717 "project": {
718 "src": {
719 "main.rs": "fn main() {}"
720 }
721 },
722 "external": {
723 "secrets": {
724 "key.txt": "SECRET_KEY=abc123"
725 }
726 }
727 }),
728 )
729 .await;
730
731 fs.create_symlink(
732 path!("/root/project/link_to_external").as_ref(),
733 PathBuf::from("../external"),
734 )
735 .await
736 .unwrap();
737
738 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
739 cx.executor().run_until_parked();
740
741 let tool = Arc::new(ListDirectoryTool::new(project));
742
743 let (event_stream, mut event_rx) = ToolCallEventStream::test();
744 let task = cx.update(|cx| {
745 tool.clone().run(
746 ListDirectoryToolInput {
747 path: "project/link_to_external".into(),
748 },
749 event_stream,
750 cx,
751 )
752 });
753
754 let auth = event_rx.expect_authorization().await;
755 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
756 assert!(
757 title.contains("points outside the project"),
758 "Authorization title should mention symlink escape, got: {title}",
759 );
760
761 auth.response
762 .send(acp::PermissionOptionId::new("allow"))
763 .unwrap();
764
765 let result = task.await;
766 assert!(
767 result.is_ok(),
768 "Tool should succeed after authorization: {result:?}"
769 );
770 }
771
772 #[gpui::test]
773 async fn test_list_directory_symlink_escape_denied(cx: &mut TestAppContext) {
774 init_test(cx);
775
776 let fs = FakeFs::new(cx.executor());
777 fs.insert_tree(
778 path!("/root"),
779 json!({
780 "project": {
781 "src": {
782 "main.rs": "fn main() {}"
783 }
784 },
785 "external": {
786 "secrets": {}
787 }
788 }),
789 )
790 .await;
791
792 fs.create_symlink(
793 path!("/root/project/link_to_external").as_ref(),
794 PathBuf::from("../external"),
795 )
796 .await
797 .unwrap();
798
799 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
800 cx.executor().run_until_parked();
801
802 let tool = Arc::new(ListDirectoryTool::new(project));
803
804 let (event_stream, mut event_rx) = ToolCallEventStream::test();
805 let task = cx.update(|cx| {
806 tool.clone().run(
807 ListDirectoryToolInput {
808 path: "project/link_to_external".into(),
809 },
810 event_stream,
811 cx,
812 )
813 });
814
815 let auth = event_rx.expect_authorization().await;
816
817 // Deny by dropping the response sender without sending
818 drop(auth);
819
820 let result = task.await;
821 assert!(
822 result.is_err(),
823 "Tool should fail when authorization is denied"
824 );
825 }
826
827 #[gpui::test]
828 async fn test_list_directory_symlink_escape_private_path_no_authorization(
829 cx: &mut TestAppContext,
830 ) {
831 init_test(cx);
832
833 let fs = FakeFs::new(cx.executor());
834 fs.insert_tree(
835 path!("/root"),
836 json!({
837 "project": {
838 "src": {
839 "main.rs": "fn main() {}"
840 }
841 },
842 "external": {
843 "secrets": {}
844 }
845 }),
846 )
847 .await;
848
849 fs.create_symlink(
850 path!("/root/project/link_to_external").as_ref(),
851 PathBuf::from("../external"),
852 )
853 .await
854 .unwrap();
855
856 cx.update(|cx| {
857 SettingsStore::update_global(cx, |store, cx| {
858 store.update_user_settings(cx, |settings| {
859 settings.project.worktree.private_files =
860 Some(vec!["**/link_to_external".to_string()].into());
861 });
862 });
863 });
864
865 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
866 cx.executor().run_until_parked();
867
868 let tool = Arc::new(ListDirectoryTool::new(project));
869
870 let (event_stream, mut event_rx) = ToolCallEventStream::test();
871 let result = cx
872 .update(|cx| {
873 tool.clone().run(
874 ListDirectoryToolInput {
875 path: "project/link_to_external".into(),
876 },
877 event_stream,
878 cx,
879 )
880 })
881 .await;
882
883 assert!(
884 result.is_err(),
885 "Expected list_directory to fail on private path"
886 );
887 let error = result.unwrap_err();
888 assert!(
889 error.contains("private"),
890 "Expected private path validation error, got: {error}"
891 );
892
893 let event = event_rx.try_next();
894 assert!(
895 !matches!(
896 event,
897 Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
898 _
899 ))))
900 ),
901 "No authorization should be requested when validation fails before listing",
902 );
903 }
904
905 #[gpui::test]
906 async fn test_list_directory_no_authorization_for_normal_paths(cx: &mut TestAppContext) {
907 init_test(cx);
908
909 let fs = FakeFs::new(cx.executor());
910 fs.insert_tree(
911 path!("/project"),
912 json!({
913 "src": {
914 "main.rs": "fn main() {}"
915 }
916 }),
917 )
918 .await;
919
920 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
921 let tool = Arc::new(ListDirectoryTool::new(project));
922
923 let (event_stream, mut event_rx) = ToolCallEventStream::test();
924 let result = cx
925 .update(|cx| {
926 tool.clone().run(
927 ListDirectoryToolInput {
928 path: "project/src".into(),
929 },
930 event_stream,
931 cx,
932 )
933 })
934 .await;
935
936 assert!(
937 result.is_ok(),
938 "Normal path should succeed without authorization"
939 );
940
941 let event = event_rx.try_next();
942 assert!(
943 !matches!(
944 event,
945 Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
946 _
947 ))))
948 ),
949 "No authorization should be requested for normal paths",
950 );
951 }
952
953 #[gpui::test]
954 async fn test_list_directory_intra_project_symlink_no_authorization(cx: &mut TestAppContext) {
955 init_test(cx);
956
957 let fs = FakeFs::new(cx.executor());
958 fs.insert_tree(
959 path!("/project"),
960 json!({
961 "real_dir": {
962 "file.txt": "content"
963 }
964 }),
965 )
966 .await;
967
968 fs.create_symlink(
969 path!("/project/link_dir").as_ref(),
970 PathBuf::from("real_dir"),
971 )
972 .await
973 .unwrap();
974
975 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
976 cx.executor().run_until_parked();
977
978 let tool = Arc::new(ListDirectoryTool::new(project));
979
980 let (event_stream, mut event_rx) = ToolCallEventStream::test();
981 let result = cx
982 .update(|cx| {
983 tool.clone().run(
984 ListDirectoryToolInput {
985 path: "project/link_dir".into(),
986 },
987 event_stream,
988 cx,
989 )
990 })
991 .await;
992
993 assert!(
994 result.is_ok(),
995 "Intra-project symlink should succeed without authorization: {result:?}",
996 );
997
998 let event = event_rx.try_next();
999 assert!(
1000 !matches!(
1001 event,
1002 Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
1003 _
1004 ))))
1005 ),
1006 "No authorization should be requested for intra-project symlinks",
1007 );
1008 }
1009}