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_thread::SelectedPermissionOutcome::new(
 852                acp::PermissionOptionId::new("allow"),
 853                acp::PermissionOptionKind::AllowOnce,
 854            ))
 855            .unwrap();
 856
 857        let result = task.await;
 858        assert!(
 859            result.is_ok(),
 860            "Tool should succeed after authorization: {result:?}"
 861        );
 862    }
 863
 864    #[gpui::test]
 865    async fn test_list_directory_symlink_escape_denied(cx: &mut TestAppContext) {
 866        init_test(cx);
 867
 868        let fs = FakeFs::new(cx.executor());
 869        fs.insert_tree(
 870            path!("/root"),
 871            json!({
 872                "project": {
 873                    "src": {
 874                        "main.rs": "fn main() {}"
 875                    }
 876                },
 877                "external": {
 878                    "secrets": {}
 879                }
 880            }),
 881        )
 882        .await;
 883
 884        fs.create_symlink(
 885            path!("/root/project/link_to_external").as_ref(),
 886            PathBuf::from("../external"),
 887        )
 888        .await
 889        .unwrap();
 890
 891        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
 892        cx.executor().run_until_parked();
 893
 894        let tool = Arc::new(ListDirectoryTool::new(project));
 895
 896        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 897        let task = cx.update(|cx| {
 898            tool.clone().run(
 899                ToolInput::resolved(ListDirectoryToolInput {
 900                    path: "project/link_to_external".into(),
 901                }),
 902                event_stream,
 903                cx,
 904            )
 905        });
 906
 907        let auth = event_rx.expect_authorization().await;
 908
 909        // Deny by dropping the response sender without sending
 910        drop(auth);
 911
 912        let result = task.await;
 913        assert!(
 914            result.is_err(),
 915            "Tool should fail when authorization is denied"
 916        );
 917    }
 918
 919    #[gpui::test]
 920    async fn test_list_directory_symlink_escape_private_path_no_authorization(
 921        cx: &mut TestAppContext,
 922    ) {
 923        init_test(cx);
 924
 925        let fs = FakeFs::new(cx.executor());
 926        fs.insert_tree(
 927            path!("/root"),
 928            json!({
 929                "project": {
 930                    "src": {
 931                        "main.rs": "fn main() {}"
 932                    }
 933                },
 934                "external": {
 935                    "secrets": {}
 936                }
 937            }),
 938        )
 939        .await;
 940
 941        fs.create_symlink(
 942            path!("/root/project/link_to_external").as_ref(),
 943            PathBuf::from("../external"),
 944        )
 945        .await
 946        .unwrap();
 947
 948        cx.update(|cx| {
 949            SettingsStore::update_global(cx, |store, cx| {
 950                store.update_user_settings(cx, |settings| {
 951                    settings.project.worktree.private_files =
 952                        Some(vec!["**/link_to_external".to_string()].into());
 953                });
 954            });
 955        });
 956
 957        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
 958        cx.executor().run_until_parked();
 959
 960        let tool = Arc::new(ListDirectoryTool::new(project));
 961
 962        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 963        let result = cx
 964            .update(|cx| {
 965                tool.clone().run(
 966                    ToolInput::resolved(ListDirectoryToolInput {
 967                        path: "project/link_to_external".into(),
 968                    }),
 969                    event_stream,
 970                    cx,
 971                )
 972            })
 973            .await;
 974
 975        assert!(
 976            result.is_err(),
 977            "Expected list_directory to fail on private path"
 978        );
 979        let error = result.unwrap_err();
 980        assert!(
 981            error.contains("private"),
 982            "Expected private path validation error, got: {error}"
 983        );
 984
 985        let event = event_rx.try_recv();
 986        assert!(
 987            !matches!(
 988                event,
 989                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
 990            ),
 991            "No authorization should be requested when validation fails before listing",
 992        );
 993    }
 994
 995    #[gpui::test]
 996    async fn test_list_directory_no_authorization_for_normal_paths(cx: &mut TestAppContext) {
 997        init_test(cx);
 998
 999        let fs = FakeFs::new(cx.executor());
1000        fs.insert_tree(
1001            path!("/project"),
1002            json!({
1003                "src": {
1004                    "main.rs": "fn main() {}"
1005                }
1006            }),
1007        )
1008        .await;
1009
1010        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1011        let tool = Arc::new(ListDirectoryTool::new(project));
1012
1013        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1014        let result = cx
1015            .update(|cx| {
1016                tool.clone().run(
1017                    ToolInput::resolved(ListDirectoryToolInput {
1018                        path: "project/src".into(),
1019                    }),
1020                    event_stream,
1021                    cx,
1022                )
1023            })
1024            .await;
1025
1026        assert!(
1027            result.is_ok(),
1028            "Normal path should succeed without authorization"
1029        );
1030
1031        let event = event_rx.try_recv();
1032        assert!(
1033            !matches!(
1034                event,
1035                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
1036            ),
1037            "No authorization should be requested for normal paths",
1038        );
1039    }
1040
1041    #[gpui::test]
1042    async fn test_list_directory_intra_project_symlink_no_authorization(cx: &mut TestAppContext) {
1043        init_test(cx);
1044
1045        let fs = FakeFs::new(cx.executor());
1046        fs.insert_tree(
1047            path!("/project"),
1048            json!({
1049                "real_dir": {
1050                    "file.txt": "content"
1051                }
1052            }),
1053        )
1054        .await;
1055
1056        fs.create_symlink(
1057            path!("/project/link_dir").as_ref(),
1058            PathBuf::from("real_dir"),
1059        )
1060        .await
1061        .unwrap();
1062
1063        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1064        cx.executor().run_until_parked();
1065
1066        let tool = Arc::new(ListDirectoryTool::new(project));
1067
1068        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1069        let result = cx
1070            .update(|cx| {
1071                tool.clone().run(
1072                    ToolInput::resolved(ListDirectoryToolInput {
1073                        path: "project/link_dir".into(),
1074                    }),
1075                    event_stream,
1076                    cx,
1077                )
1078            })
1079            .await;
1080
1081        assert!(
1082            result.is_ok(),
1083            "Intra-project symlink should succeed without authorization: {result:?}",
1084        );
1085
1086        let event = event_rx.try_recv();
1087        assert!(
1088            !matches!(
1089                event,
1090                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
1091            ),
1092            "No authorization should be requested for intra-project symlinks",
1093        );
1094    }
1095}