Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:35:13.308Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

list_directory_tool.rs

1254 lines · 41.4 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.