list_directory_tool.rs

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