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