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