list_directory_tool.rs

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