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::schema as acp;
   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() -> acp::ToolKind {
 131        acp::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 fs::Fs as _;
 271    use gpui::{TestAppContext, UpdateGlobal};
 272    use indoc::indoc;
 273    use project::{FakeFs, Project};
 274    use serde_json::json;
 275    use settings::SettingsStore;
 276    use std::path::PathBuf;
 277    use util::path;
 278
 279    fn platform_paths(path_str: &str) -> String {
 280        if cfg!(target_os = "windows") {
 281            path_str.replace("/", "\\")
 282        } else {
 283            path_str.to_string()
 284        }
 285    }
 286
 287    fn init_test(cx: &mut TestAppContext) {
 288        cx.update(|cx| {
 289            let settings_store = SettingsStore::test(cx);
 290            cx.set_global(settings_store);
 291        });
 292    }
 293
 294    #[gpui::test]
 295    async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
 296        init_test(cx);
 297
 298        let fs = FakeFs::new(cx.executor());
 299        fs.insert_tree(
 300            path!("/project"),
 301            json!({
 302                "src": {
 303                    "main.rs": "fn main() {}",
 304                    "lib.rs": "pub fn hello() {}",
 305                    "models": {
 306                        "user.rs": "struct User {}",
 307                        "post.rs": "struct Post {}"
 308                    },
 309                    "utils": {
 310                        "helper.rs": "pub fn help() {}"
 311                    }
 312                },
 313                "tests": {
 314                    "integration_test.rs": "#[test] fn test() {}"
 315                },
 316                "README.md": "# Project",
 317                "Cargo.toml": "[package]"
 318            }),
 319        )
 320        .await;
 321
 322        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 323        let tool = Arc::new(ListDirectoryTool::new(project));
 324
 325        // Test listing root directory
 326        let input = ListDirectoryToolInput {
 327            path: "project".into(),
 328        };
 329        let output = cx
 330            .update(|cx| {
 331                tool.clone().run(
 332                    ToolInput::resolved(input),
 333                    ToolCallEventStream::test().0,
 334                    cx,
 335                )
 336            })
 337            .await
 338            .unwrap();
 339        assert_eq!(
 340            output,
 341            platform_paths(indoc! {"
 342                # Folders:
 343                project/src
 344                project/tests
 345
 346                # Files:
 347                project/Cargo.toml
 348                project/README.md
 349            "})
 350        );
 351
 352        // Test listing src directory
 353        let input = ListDirectoryToolInput {
 354            path: "project/src".into(),
 355        };
 356        let output = cx
 357            .update(|cx| {
 358                tool.clone().run(
 359                    ToolInput::resolved(input),
 360                    ToolCallEventStream::test().0,
 361                    cx,
 362                )
 363            })
 364            .await
 365            .unwrap();
 366        assert_eq!(
 367            output,
 368            platform_paths(indoc! {"
 369                # Folders:
 370                project/src/models
 371                project/src/utils
 372
 373                # Files:
 374                project/src/lib.rs
 375                project/src/main.rs
 376            "})
 377        );
 378
 379        // Test listing directory with only files
 380        let input = ListDirectoryToolInput {
 381            path: "project/tests".into(),
 382        };
 383        let output = cx
 384            .update(|cx| {
 385                tool.clone().run(
 386                    ToolInput::resolved(input),
 387                    ToolCallEventStream::test().0,
 388                    cx,
 389                )
 390            })
 391            .await
 392            .unwrap();
 393        assert!(!output.contains("# Folders:"));
 394        assert!(output.contains("# Files:"));
 395        assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
 396    }
 397
 398    #[gpui::test]
 399    async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
 400        init_test(cx);
 401
 402        let fs = FakeFs::new(cx.executor());
 403        fs.insert_tree(
 404            path!("/project"),
 405            json!({
 406                "empty_dir": {}
 407            }),
 408        )
 409        .await;
 410
 411        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 412        let tool = Arc::new(ListDirectoryTool::new(project));
 413
 414        let input = ListDirectoryToolInput {
 415            path: "project/empty_dir".into(),
 416        };
 417        let output = cx
 418            .update(|cx| {
 419                tool.clone().run(
 420                    ToolInput::resolved(input),
 421                    ToolCallEventStream::test().0,
 422                    cx,
 423                )
 424            })
 425            .await
 426            .unwrap();
 427        assert_eq!(output, "project/empty_dir is empty.\n");
 428    }
 429
 430    #[gpui::test]
 431    async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
 432        init_test(cx);
 433
 434        let fs = FakeFs::new(cx.executor());
 435        fs.insert_tree(
 436            path!("/project"),
 437            json!({
 438                "file.txt": "content"
 439            }),
 440        )
 441        .await;
 442
 443        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 444        let tool = Arc::new(ListDirectoryTool::new(project));
 445
 446        // Test non-existent path
 447        let input = ListDirectoryToolInput {
 448            path: "project/nonexistent".into(),
 449        };
 450        let output = cx
 451            .update(|cx| {
 452                tool.clone().run(
 453                    ToolInput::resolved(input),
 454                    ToolCallEventStream::test().0,
 455                    cx,
 456                )
 457            })
 458            .await;
 459        assert!(output.unwrap_err().contains("Path not found"));
 460
 461        // Test trying to list a file instead of directory
 462        let input = ListDirectoryToolInput {
 463            path: "project/file.txt".into(),
 464        };
 465        let output = cx
 466            .update(|cx| {
 467                tool.run(
 468                    ToolInput::resolved(input),
 469                    ToolCallEventStream::test().0,
 470                    cx,
 471                )
 472            })
 473            .await;
 474        assert!(output.unwrap_err().contains("is not a directory"));
 475    }
 476
 477    #[gpui::test]
 478    async fn test_list_directory_security(cx: &mut TestAppContext) {
 479        init_test(cx);
 480
 481        let fs = FakeFs::new(cx.executor());
 482        fs.insert_tree(
 483            path!("/project"),
 484            json!({
 485                "normal_dir": {
 486                    "file1.txt": "content",
 487                    "file2.txt": "content"
 488                },
 489                ".mysecrets": "SECRET_KEY=abc123",
 490                ".secretdir": {
 491                    "config": "special configuration",
 492                    "secret.txt": "secret content"
 493                },
 494                ".mymetadata": "custom metadata",
 495                "visible_dir": {
 496                    "normal.txt": "normal content",
 497                    "special.privatekey": "private key content",
 498                    "data.mysensitive": "sensitive data",
 499                    ".hidden_subdir": {
 500                        "hidden_file.txt": "hidden content"
 501                    }
 502                }
 503            }),
 504        )
 505        .await;
 506
 507        // Configure settings explicitly
 508        cx.update(|cx| {
 509            SettingsStore::update_global(cx, |store, cx| {
 510                store.update_user_settings(cx, |settings| {
 511                    settings.project.worktree.file_scan_exclusions = Some(vec![
 512                        "**/.secretdir".to_string(),
 513                        "**/.mymetadata".to_string(),
 514                        "**/.hidden_subdir".to_string(),
 515                    ]);
 516                    settings.project.worktree.private_files = Some(
 517                        vec![
 518                            "**/.mysecrets".to_string(),
 519                            "**/*.privatekey".to_string(),
 520                            "**/*.mysensitive".to_string(),
 521                        ]
 522                        .into(),
 523                    );
 524                });
 525            });
 526        });
 527
 528        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 529        let tool = Arc::new(ListDirectoryTool::new(project));
 530
 531        // Listing root directory should exclude private and excluded files
 532        let input = ListDirectoryToolInput {
 533            path: "project".into(),
 534        };
 535        let output = cx
 536            .update(|cx| {
 537                tool.clone().run(
 538                    ToolInput::resolved(input),
 539                    ToolCallEventStream::test().0,
 540                    cx,
 541                )
 542            })
 543            .await
 544            .unwrap();
 545
 546        // Should include normal directories
 547        assert!(output.contains("normal_dir"), "Should list normal_dir");
 548        assert!(output.contains("visible_dir"), "Should list visible_dir");
 549
 550        // Should NOT include excluded or private files
 551        assert!(
 552            !output.contains(".secretdir"),
 553            "Should not list .secretdir (file_scan_exclusions)"
 554        );
 555        assert!(
 556            !output.contains(".mymetadata"),
 557            "Should not list .mymetadata (file_scan_exclusions)"
 558        );
 559        assert!(
 560            !output.contains(".mysecrets"),
 561            "Should not list .mysecrets (private_files)"
 562        );
 563
 564        // Trying to list an excluded directory should fail
 565        let input = ListDirectoryToolInput {
 566            path: "project/.secretdir".into(),
 567        };
 568        let output = cx
 569            .update(|cx| {
 570                tool.clone().run(
 571                    ToolInput::resolved(input),
 572                    ToolCallEventStream::test().0,
 573                    cx,
 574                )
 575            })
 576            .await;
 577        assert!(
 578            output.unwrap_err().contains("file_scan_exclusions"),
 579            "Error should mention file_scan_exclusions"
 580        );
 581
 582        // Listing a directory should exclude private files within it
 583        let input = ListDirectoryToolInput {
 584            path: "project/visible_dir".into(),
 585        };
 586        let output = cx
 587            .update(|cx| {
 588                tool.clone().run(
 589                    ToolInput::resolved(input),
 590                    ToolCallEventStream::test().0,
 591                    cx,
 592                )
 593            })
 594            .await
 595            .unwrap();
 596
 597        // Should include normal files
 598        assert!(output.contains("normal.txt"), "Should list normal.txt");
 599
 600        // Should NOT include private files
 601        assert!(
 602            !output.contains("privatekey"),
 603            "Should not list .privatekey files (private_files)"
 604        );
 605        assert!(
 606            !output.contains("mysensitive"),
 607            "Should not list .mysensitive files (private_files)"
 608        );
 609
 610        // Should NOT include subdirectories that match exclusions
 611        assert!(
 612            !output.contains(".hidden_subdir"),
 613            "Should not list .hidden_subdir (file_scan_exclusions)"
 614        );
 615    }
 616
 617    #[gpui::test]
 618    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 619        init_test(cx);
 620
 621        let fs = FakeFs::new(cx.executor());
 622
 623        // Create first worktree with its own private files
 624        fs.insert_tree(
 625            path!("/worktree1"),
 626            json!({
 627                ".zed": {
 628                    "settings.json": r#"{
 629                        "file_scan_exclusions": ["**/fixture.*"],
 630                        "private_files": ["**/secret.rs", "**/config.toml"]
 631                    }"#
 632                },
 633                "src": {
 634                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 635                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 636                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 637                },
 638                "tests": {
 639                    "test.rs": "mod tests { fn test_it() {} }",
 640                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 641                }
 642            }),
 643        )
 644        .await;
 645
 646        // Create second worktree with different private files
 647        fs.insert_tree(
 648            path!("/worktree2"),
 649            json!({
 650                ".zed": {
 651                    "settings.json": r#"{
 652                        "file_scan_exclusions": ["**/internal.*"],
 653                        "private_files": ["**/private.js", "**/data.json"]
 654                    }"#
 655                },
 656                "lib": {
 657                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 658                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 659                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 660                },
 661                "docs": {
 662                    "README.md": "# Public Documentation",
 663                    "internal.md": "# Internal Secrets and Configuration"
 664                }
 665            }),
 666        )
 667        .await;
 668
 669        // Set global settings
 670        cx.update(|cx| {
 671            SettingsStore::update_global(cx, |store, cx| {
 672                store.update_user_settings(cx, |settings| {
 673                    settings.project.worktree.file_scan_exclusions =
 674                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 675                    settings.project.worktree.private_files =
 676                        Some(vec!["**/.env".to_string()].into());
 677                });
 678            });
 679        });
 680
 681        let project = Project::test(
 682            fs.clone(),
 683            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 684            cx,
 685        )
 686        .await;
 687
 688        // Wait for worktrees to be fully scanned
 689        cx.executor().run_until_parked();
 690
 691        let tool = Arc::new(ListDirectoryTool::new(project));
 692
 693        // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
 694        let input = ListDirectoryToolInput {
 695            path: "worktree1/src".into(),
 696        };
 697        let output = cx
 698            .update(|cx| {
 699                tool.clone().run(
 700                    ToolInput::resolved(input),
 701                    ToolCallEventStream::test().0,
 702                    cx,
 703                )
 704            })
 705            .await
 706            .unwrap();
 707        assert!(output.contains("main.rs"), "Should list main.rs");
 708        assert!(
 709            !output.contains("secret.rs"),
 710            "Should not list secret.rs (local private_files)"
 711        );
 712        assert!(
 713            !output.contains("config.toml"),
 714            "Should not list config.toml (local private_files)"
 715        );
 716
 717        // Test listing worktree1/tests - should exclude fixture.sql based on local settings
 718        let input = ListDirectoryToolInput {
 719            path: "worktree1/tests".into(),
 720        };
 721        let output = cx
 722            .update(|cx| {
 723                tool.clone().run(
 724                    ToolInput::resolved(input),
 725                    ToolCallEventStream::test().0,
 726                    cx,
 727                )
 728            })
 729            .await
 730            .unwrap();
 731        assert!(output.contains("test.rs"), "Should list test.rs");
 732        assert!(
 733            !output.contains("fixture.sql"),
 734            "Should not list fixture.sql (local file_scan_exclusions)"
 735        );
 736
 737        // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
 738        let input = ListDirectoryToolInput {
 739            path: "worktree2/lib".into(),
 740        };
 741        let output = cx
 742            .update(|cx| {
 743                tool.clone().run(
 744                    ToolInput::resolved(input),
 745                    ToolCallEventStream::test().0,
 746                    cx,
 747                )
 748            })
 749            .await
 750            .unwrap();
 751        assert!(output.contains("public.js"), "Should list public.js");
 752        assert!(
 753            !output.contains("private.js"),
 754            "Should not list private.js (local private_files)"
 755        );
 756        assert!(
 757            !output.contains("data.json"),
 758            "Should not list data.json (local private_files)"
 759        );
 760
 761        // Test listing worktree2/docs - should exclude internal.md based on local settings
 762        let input = ListDirectoryToolInput {
 763            path: "worktree2/docs".into(),
 764        };
 765        let output = cx
 766            .update(|cx| {
 767                tool.clone().run(
 768                    ToolInput::resolved(input),
 769                    ToolCallEventStream::test().0,
 770                    cx,
 771                )
 772            })
 773            .await
 774            .unwrap();
 775        assert!(output.contains("README.md"), "Should list README.md");
 776        assert!(
 777            !output.contains("internal.md"),
 778            "Should not list internal.md (local file_scan_exclusions)"
 779        );
 780
 781        // Test trying to list an excluded directory directly
 782        let input = ListDirectoryToolInput {
 783            path: "worktree1/src/secret.rs".into(),
 784        };
 785        let output = cx
 786            .update(|cx| {
 787                tool.clone().run(
 788                    ToolInput::resolved(input),
 789                    ToolCallEventStream::test().0,
 790                    cx,
 791                )
 792            })
 793            .await;
 794        assert!(output.unwrap_err().contains("Cannot list directory"),);
 795    }
 796
 797    #[gpui::test]
 798    async fn test_list_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
 799        init_test(cx);
 800
 801        let fs = FakeFs::new(cx.executor());
 802        fs.insert_tree(
 803            path!("/root"),
 804            json!({
 805                "project": {
 806                    "src": {
 807                        "main.rs": "fn main() {}"
 808                    }
 809                },
 810                "external": {
 811                    "secrets": {
 812                        "key.txt": "SECRET_KEY=abc123"
 813                    }
 814                }
 815            }),
 816        )
 817        .await;
 818
 819        fs.create_symlink(
 820            path!("/root/project/link_to_external").as_ref(),
 821            PathBuf::from("../external"),
 822        )
 823        .await
 824        .unwrap();
 825
 826        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
 827        cx.executor().run_until_parked();
 828
 829        let tool = Arc::new(ListDirectoryTool::new(project));
 830
 831        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 832        let task = cx.update(|cx| {
 833            tool.clone().run(
 834                ToolInput::resolved(ListDirectoryToolInput {
 835                    path: "project/link_to_external".into(),
 836                }),
 837                event_stream,
 838                cx,
 839            )
 840        });
 841
 842        let auth = event_rx.expect_authorization().await;
 843        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
 844        assert!(
 845            title.contains("points outside the project"),
 846            "Authorization title should mention symlink escape, got: {title}",
 847        );
 848
 849        auth.response
 850            .send(acp_thread::SelectedPermissionOutcome::new(
 851                acp::PermissionOptionId::new("allow"),
 852                acp::PermissionOptionKind::AllowOnce,
 853            ))
 854            .unwrap();
 855
 856        let result = task.await;
 857        assert!(
 858            result.is_ok(),
 859            "Tool should succeed after authorization: {result:?}"
 860        );
 861    }
 862
 863    #[gpui::test]
 864    async fn test_list_directory_symlink_escape_denied(cx: &mut TestAppContext) {
 865        init_test(cx);
 866
 867        let fs = FakeFs::new(cx.executor());
 868        fs.insert_tree(
 869            path!("/root"),
 870            json!({
 871                "project": {
 872                    "src": {
 873                        "main.rs": "fn main() {}"
 874                    }
 875                },
 876                "external": {
 877                    "secrets": {}
 878                }
 879            }),
 880        )
 881        .await;
 882
 883        fs.create_symlink(
 884            path!("/root/project/link_to_external").as_ref(),
 885            PathBuf::from("../external"),
 886        )
 887        .await
 888        .unwrap();
 889
 890        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
 891        cx.executor().run_until_parked();
 892
 893        let tool = Arc::new(ListDirectoryTool::new(project));
 894
 895        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 896        let task = cx.update(|cx| {
 897            tool.clone().run(
 898                ToolInput::resolved(ListDirectoryToolInput {
 899                    path: "project/link_to_external".into(),
 900                }),
 901                event_stream,
 902                cx,
 903            )
 904        });
 905
 906        let auth = event_rx.expect_authorization().await;
 907
 908        // Deny by dropping the response sender without sending
 909        drop(auth);
 910
 911        let result = task.await;
 912        assert!(
 913            result.is_err(),
 914            "Tool should fail when authorization is denied"
 915        );
 916    }
 917
 918    #[gpui::test]
 919    async fn test_list_directory_symlink_escape_private_path_no_authorization(
 920        cx: &mut TestAppContext,
 921    ) {
 922        init_test(cx);
 923
 924        let fs = FakeFs::new(cx.executor());
 925        fs.insert_tree(
 926            path!("/root"),
 927            json!({
 928                "project": {
 929                    "src": {
 930                        "main.rs": "fn main() {}"
 931                    }
 932                },
 933                "external": {
 934                    "secrets": {}
 935                }
 936            }),
 937        )
 938        .await;
 939
 940        fs.create_symlink(
 941            path!("/root/project/link_to_external").as_ref(),
 942            PathBuf::from("../external"),
 943        )
 944        .await
 945        .unwrap();
 946
 947        cx.update(|cx| {
 948            SettingsStore::update_global(cx, |store, cx| {
 949                store.update_user_settings(cx, |settings| {
 950                    settings.project.worktree.private_files =
 951                        Some(vec!["**/link_to_external".to_string()].into());
 952                });
 953            });
 954        });
 955
 956        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
 957        cx.executor().run_until_parked();
 958
 959        let tool = Arc::new(ListDirectoryTool::new(project));
 960
 961        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 962        let result = cx
 963            .update(|cx| {
 964                tool.clone().run(
 965                    ToolInput::resolved(ListDirectoryToolInput {
 966                        path: "project/link_to_external".into(),
 967                    }),
 968                    event_stream,
 969                    cx,
 970                )
 971            })
 972            .await;
 973
 974        assert!(
 975            result.is_err(),
 976            "Expected list_directory to fail on private path"
 977        );
 978        let error = result.unwrap_err();
 979        assert!(
 980            error.contains("private"),
 981            "Expected private path validation error, got: {error}"
 982        );
 983
 984        let event = event_rx.try_recv();
 985        assert!(
 986            !matches!(
 987                event,
 988                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
 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_recv();
1031        assert!(
1032            !matches!(
1033                event,
1034                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
1035            ),
1036            "No authorization should be requested for normal paths",
1037        );
1038    }
1039
1040    #[gpui::test]
1041    async fn test_list_directory_intra_project_symlink_no_authorization(cx: &mut TestAppContext) {
1042        init_test(cx);
1043
1044        let fs = FakeFs::new(cx.executor());
1045        fs.insert_tree(
1046            path!("/project"),
1047            json!({
1048                "real_dir": {
1049                    "file.txt": "content"
1050                }
1051            }),
1052        )
1053        .await;
1054
1055        fs.create_symlink(
1056            path!("/project/link_dir").as_ref(),
1057            PathBuf::from("real_dir"),
1058        )
1059        .await
1060        .unwrap();
1061
1062        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1063        cx.executor().run_until_parked();
1064
1065        let tool = Arc::new(ListDirectoryTool::new(project));
1066
1067        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1068        let result = cx
1069            .update(|cx| {
1070                tool.clone().run(
1071                    ToolInput::resolved(ListDirectoryToolInput {
1072                        path: "project/link_dir".into(),
1073                    }),
1074                    event_stream,
1075                    cx,
1076                )
1077            })
1078            .await;
1079
1080        assert!(
1081            result.is_ok(),
1082            "Intra-project symlink should succeed without authorization: {result:?}",
1083        );
1084
1085        let event = event_rx.try_recv();
1086        assert!(
1087            !matches!(
1088                event,
1089                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
1090            ),
1091            "No authorization should be requested for intra-project symlinks",
1092        );
1093    }
1094}