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>> {
 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                })?;
 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            })?;
 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?;
 252            }
 253
 254            let list_path = input.path;
 255            cx.update(|cx| {
 256                Self::build_directory_output(&project, &project_path, &list_path, cx)
 257            })
 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().to_string().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!(
 435            output
 436                .unwrap_err()
 437                .to_string()
 438                .contains("is not a directory")
 439        );
 440    }
 441
 442    #[gpui::test]
 443    async fn test_list_directory_security(cx: &mut TestAppContext) {
 444        init_test(cx);
 445
 446        let fs = FakeFs::new(cx.executor());
 447        fs.insert_tree(
 448            path!("/project"),
 449            json!({
 450                "normal_dir": {
 451                    "file1.txt": "content",
 452                    "file2.txt": "content"
 453                },
 454                ".mysecrets": "SECRET_KEY=abc123",
 455                ".secretdir": {
 456                    "config": "special configuration",
 457                    "secret.txt": "secret content"
 458                },
 459                ".mymetadata": "custom metadata",
 460                "visible_dir": {
 461                    "normal.txt": "normal content",
 462                    "special.privatekey": "private key content",
 463                    "data.mysensitive": "sensitive data",
 464                    ".hidden_subdir": {
 465                        "hidden_file.txt": "hidden content"
 466                    }
 467                }
 468            }),
 469        )
 470        .await;
 471
 472        // Configure settings explicitly
 473        cx.update(|cx| {
 474            SettingsStore::update_global(cx, |store, cx| {
 475                store.update_user_settings(cx, |settings| {
 476                    settings.project.worktree.file_scan_exclusions = Some(vec![
 477                        "**/.secretdir".to_string(),
 478                        "**/.mymetadata".to_string(),
 479                        "**/.hidden_subdir".to_string(),
 480                    ]);
 481                    settings.project.worktree.private_files = Some(
 482                        vec![
 483                            "**/.mysecrets".to_string(),
 484                            "**/*.privatekey".to_string(),
 485                            "**/*.mysensitive".to_string(),
 486                        ]
 487                        .into(),
 488                    );
 489                });
 490            });
 491        });
 492
 493        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 494        let tool = Arc::new(ListDirectoryTool::new(project));
 495
 496        // Listing root directory should exclude private and excluded files
 497        let input = ListDirectoryToolInput {
 498            path: "project".into(),
 499        };
 500        let output = cx
 501            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
 502            .await
 503            .unwrap();
 504
 505        // Should include normal directories
 506        assert!(output.contains("normal_dir"), "Should list normal_dir");
 507        assert!(output.contains("visible_dir"), "Should list visible_dir");
 508
 509        // Should NOT include excluded or private files
 510        assert!(
 511            !output.contains(".secretdir"),
 512            "Should not list .secretdir (file_scan_exclusions)"
 513        );
 514        assert!(
 515            !output.contains(".mymetadata"),
 516            "Should not list .mymetadata (file_scan_exclusions)"
 517        );
 518        assert!(
 519            !output.contains(".mysecrets"),
 520            "Should not list .mysecrets (private_files)"
 521        );
 522
 523        // Trying to list an excluded directory should fail
 524        let input = ListDirectoryToolInput {
 525            path: "project/.secretdir".into(),
 526        };
 527        let output = cx
 528            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
 529            .await;
 530        assert!(
 531            output
 532                .unwrap_err()
 533                .to_string()
 534                .contains("file_scan_exclusions"),
 535            "Error should mention file_scan_exclusions"
 536        );
 537
 538        // Listing a directory should exclude private files within it
 539        let input = ListDirectoryToolInput {
 540            path: "project/visible_dir".into(),
 541        };
 542        let output = cx
 543            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
 544            .await
 545            .unwrap();
 546
 547        // Should include normal files
 548        assert!(output.contains("normal.txt"), "Should list normal.txt");
 549
 550        // Should NOT include private files
 551        assert!(
 552            !output.contains("privatekey"),
 553            "Should not list .privatekey files (private_files)"
 554        );
 555        assert!(
 556            !output.contains("mysensitive"),
 557            "Should not list .mysensitive files (private_files)"
 558        );
 559
 560        // Should NOT include subdirectories that match exclusions
 561        assert!(
 562            !output.contains(".hidden_subdir"),
 563            "Should not list .hidden_subdir (file_scan_exclusions)"
 564        );
 565    }
 566
 567    #[gpui::test]
 568    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 569        init_test(cx);
 570
 571        let fs = FakeFs::new(cx.executor());
 572
 573        // Create first worktree with its own private files
 574        fs.insert_tree(
 575            path!("/worktree1"),
 576            json!({
 577                ".zed": {
 578                    "settings.json": r#"{
 579                        "file_scan_exclusions": ["**/fixture.*"],
 580                        "private_files": ["**/secret.rs", "**/config.toml"]
 581                    }"#
 582                },
 583                "src": {
 584                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 585                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 586                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 587                },
 588                "tests": {
 589                    "test.rs": "mod tests { fn test_it() {} }",
 590                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 591                }
 592            }),
 593        )
 594        .await;
 595
 596        // Create second worktree with different private files
 597        fs.insert_tree(
 598            path!("/worktree2"),
 599            json!({
 600                ".zed": {
 601                    "settings.json": r#"{
 602                        "file_scan_exclusions": ["**/internal.*"],
 603                        "private_files": ["**/private.js", "**/data.json"]
 604                    }"#
 605                },
 606                "lib": {
 607                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 608                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 609                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 610                },
 611                "docs": {
 612                    "README.md": "# Public Documentation",
 613                    "internal.md": "# Internal Secrets and Configuration"
 614                }
 615            }),
 616        )
 617        .await;
 618
 619        // Set global settings
 620        cx.update(|cx| {
 621            SettingsStore::update_global(cx, |store, cx| {
 622                store.update_user_settings(cx, |settings| {
 623                    settings.project.worktree.file_scan_exclusions =
 624                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 625                    settings.project.worktree.private_files =
 626                        Some(vec!["**/.env".to_string()].into());
 627                });
 628            });
 629        });
 630
 631        let project = Project::test(
 632            fs.clone(),
 633            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 634            cx,
 635        )
 636        .await;
 637
 638        // Wait for worktrees to be fully scanned
 639        cx.executor().run_until_parked();
 640
 641        let tool = Arc::new(ListDirectoryTool::new(project));
 642
 643        // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
 644        let input = ListDirectoryToolInput {
 645            path: "worktree1/src".into(),
 646        };
 647        let output = cx
 648            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
 649            .await
 650            .unwrap();
 651        assert!(output.contains("main.rs"), "Should list main.rs");
 652        assert!(
 653            !output.contains("secret.rs"),
 654            "Should not list secret.rs (local private_files)"
 655        );
 656        assert!(
 657            !output.contains("config.toml"),
 658            "Should not list config.toml (local private_files)"
 659        );
 660
 661        // Test listing worktree1/tests - should exclude fixture.sql based on local settings
 662        let input = ListDirectoryToolInput {
 663            path: "worktree1/tests".into(),
 664        };
 665        let output = cx
 666            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
 667            .await
 668            .unwrap();
 669        assert!(output.contains("test.rs"), "Should list test.rs");
 670        assert!(
 671            !output.contains("fixture.sql"),
 672            "Should not list fixture.sql (local file_scan_exclusions)"
 673        );
 674
 675        // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
 676        let input = ListDirectoryToolInput {
 677            path: "worktree2/lib".into(),
 678        };
 679        let output = cx
 680            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
 681            .await
 682            .unwrap();
 683        assert!(output.contains("public.js"), "Should list public.js");
 684        assert!(
 685            !output.contains("private.js"),
 686            "Should not list private.js (local private_files)"
 687        );
 688        assert!(
 689            !output.contains("data.json"),
 690            "Should not list data.json (local private_files)"
 691        );
 692
 693        // Test listing worktree2/docs - should exclude internal.md based on local settings
 694        let input = ListDirectoryToolInput {
 695            path: "worktree2/docs".into(),
 696        };
 697        let output = cx
 698            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
 699            .await
 700            .unwrap();
 701        assert!(output.contains("README.md"), "Should list README.md");
 702        assert!(
 703            !output.contains("internal.md"),
 704            "Should not list internal.md (local file_scan_exclusions)"
 705        );
 706
 707        // Test trying to list an excluded directory directly
 708        let input = ListDirectoryToolInput {
 709            path: "worktree1/src/secret.rs".into(),
 710        };
 711        let output = cx
 712            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
 713            .await;
 714        assert!(
 715            output
 716                .unwrap_err()
 717                .to_string()
 718                .contains("Cannot list directory"),
 719        );
 720    }
 721
 722    #[gpui::test]
 723    async fn test_list_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
 724        init_test(cx);
 725
 726        let fs = FakeFs::new(cx.executor());
 727        fs.insert_tree(
 728            path!("/root"),
 729            json!({
 730                "project": {
 731                    "src": {
 732                        "main.rs": "fn main() {}"
 733                    }
 734                },
 735                "external": {
 736                    "secrets": {
 737                        "key.txt": "SECRET_KEY=abc123"
 738                    }
 739                }
 740            }),
 741        )
 742        .await;
 743
 744        fs.create_symlink(
 745            path!("/root/project/link_to_external").as_ref(),
 746            PathBuf::from("../external"),
 747        )
 748        .await
 749        .unwrap();
 750
 751        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
 752        cx.executor().run_until_parked();
 753
 754        let tool = Arc::new(ListDirectoryTool::new(project));
 755
 756        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 757        let task = cx.update(|cx| {
 758            tool.clone().run(
 759                ListDirectoryToolInput {
 760                    path: "project/link_to_external".into(),
 761                },
 762                event_stream,
 763                cx,
 764            )
 765        });
 766
 767        let auth = event_rx.expect_authorization().await;
 768        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
 769        assert!(
 770            title.contains("points outside the project"),
 771            "Authorization title should mention symlink escape, got: {title}",
 772        );
 773
 774        auth.response
 775            .send(acp::PermissionOptionId::new("allow"))
 776            .unwrap();
 777
 778        let result = task.await;
 779        assert!(
 780            result.is_ok(),
 781            "Tool should succeed after authorization: {result:?}"
 782        );
 783    }
 784
 785    #[gpui::test]
 786    async fn test_list_directory_symlink_escape_denied(cx: &mut TestAppContext) {
 787        init_test(cx);
 788
 789        let fs = FakeFs::new(cx.executor());
 790        fs.insert_tree(
 791            path!("/root"),
 792            json!({
 793                "project": {
 794                    "src": {
 795                        "main.rs": "fn main() {}"
 796                    }
 797                },
 798                "external": {
 799                    "secrets": {}
 800                }
 801            }),
 802        )
 803        .await;
 804
 805        fs.create_symlink(
 806            path!("/root/project/link_to_external").as_ref(),
 807            PathBuf::from("../external"),
 808        )
 809        .await
 810        .unwrap();
 811
 812        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
 813        cx.executor().run_until_parked();
 814
 815        let tool = Arc::new(ListDirectoryTool::new(project));
 816
 817        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 818        let task = cx.update(|cx| {
 819            tool.clone().run(
 820                ListDirectoryToolInput {
 821                    path: "project/link_to_external".into(),
 822                },
 823                event_stream,
 824                cx,
 825            )
 826        });
 827
 828        let auth = event_rx.expect_authorization().await;
 829
 830        // Deny by dropping the response sender without sending
 831        drop(auth);
 832
 833        let result = task.await;
 834        assert!(
 835            result.is_err(),
 836            "Tool should fail when authorization is denied"
 837        );
 838    }
 839
 840    #[gpui::test]
 841    async fn test_list_directory_symlink_escape_private_path_no_authorization(
 842        cx: &mut TestAppContext,
 843    ) {
 844        init_test(cx);
 845
 846        let fs = FakeFs::new(cx.executor());
 847        fs.insert_tree(
 848            path!("/root"),
 849            json!({
 850                "project": {
 851                    "src": {
 852                        "main.rs": "fn main() {}"
 853                    }
 854                },
 855                "external": {
 856                    "secrets": {}
 857                }
 858            }),
 859        )
 860        .await;
 861
 862        fs.create_symlink(
 863            path!("/root/project/link_to_external").as_ref(),
 864            PathBuf::from("../external"),
 865        )
 866        .await
 867        .unwrap();
 868
 869        cx.update(|cx| {
 870            SettingsStore::update_global(cx, |store, cx| {
 871                store.update_user_settings(cx, |settings| {
 872                    settings.project.worktree.private_files =
 873                        Some(vec!["**/link_to_external".to_string()].into());
 874                });
 875            });
 876        });
 877
 878        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
 879        cx.executor().run_until_parked();
 880
 881        let tool = Arc::new(ListDirectoryTool::new(project));
 882
 883        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 884        let result = cx
 885            .update(|cx| {
 886                tool.clone().run(
 887                    ListDirectoryToolInput {
 888                        path: "project/link_to_external".into(),
 889                    },
 890                    event_stream,
 891                    cx,
 892                )
 893            })
 894            .await;
 895
 896        assert!(
 897            result.is_err(),
 898            "Expected list_directory to fail on private path"
 899        );
 900        let error = result.unwrap_err().to_string();
 901        assert!(
 902            error.contains("private"),
 903            "Expected private path validation error, got: {error}"
 904        );
 905
 906        let event = event_rx.try_next();
 907        assert!(
 908            !matches!(
 909                event,
 910                Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
 911                    _
 912                ))))
 913            ),
 914            "No authorization should be requested when validation fails before listing",
 915        );
 916    }
 917
 918    #[gpui::test]
 919    async fn test_list_directory_no_authorization_for_normal_paths(cx: &mut TestAppContext) {
 920        init_test(cx);
 921
 922        let fs = FakeFs::new(cx.executor());
 923        fs.insert_tree(
 924            path!("/project"),
 925            json!({
 926                "src": {
 927                    "main.rs": "fn main() {}"
 928                }
 929            }),
 930        )
 931        .await;
 932
 933        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 934        let tool = Arc::new(ListDirectoryTool::new(project));
 935
 936        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 937        let result = cx
 938            .update(|cx| {
 939                tool.clone().run(
 940                    ListDirectoryToolInput {
 941                        path: "project/src".into(),
 942                    },
 943                    event_stream,
 944                    cx,
 945                )
 946            })
 947            .await;
 948
 949        assert!(
 950            result.is_ok(),
 951            "Normal path should succeed without authorization"
 952        );
 953
 954        let event = event_rx.try_next();
 955        assert!(
 956            !matches!(
 957                event,
 958                Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
 959                    _
 960                ))))
 961            ),
 962            "No authorization should be requested for normal paths",
 963        );
 964    }
 965
 966    #[gpui::test]
 967    async fn test_list_directory_intra_project_symlink_no_authorization(cx: &mut TestAppContext) {
 968        init_test(cx);
 969
 970        let fs = FakeFs::new(cx.executor());
 971        fs.insert_tree(
 972            path!("/project"),
 973            json!({
 974                "real_dir": {
 975                    "file.txt": "content"
 976                }
 977            }),
 978        )
 979        .await;
 980
 981        fs.create_symlink(
 982            path!("/project/link_dir").as_ref(),
 983            PathBuf::from("real_dir"),
 984        )
 985        .await
 986        .unwrap();
 987
 988        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 989        cx.executor().run_until_parked();
 990
 991        let tool = Arc::new(ListDirectoryTool::new(project));
 992
 993        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 994        let result = cx
 995            .update(|cx| {
 996                tool.clone().run(
 997                    ListDirectoryToolInput {
 998                        path: "project/link_dir".into(),
 999                    },
1000                    event_stream,
1001                    cx,
1002                )
1003            })
1004            .await;
1005
1006        assert!(
1007            result.is_ok(),
1008            "Intra-project symlink should succeed without authorization: {result:?}",
1009        );
1010
1011        let event = event_rx.try_next();
1012        assert!(
1013            !matches!(
1014                event,
1015                Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
1016                    _
1017                ))))
1018            ),
1019            "No authorization should be requested for intra-project symlinks",
1020        );
1021    }
1022}