read_file_tool.rs

   1use action_log::ActionLog;
   2use agent_client_protocol::{self as acp, ToolCallUpdateFields};
   3use anyhow::{Context as _, Result, anyhow};
   4use futures::FutureExt as _;
   5use gpui::{App, Entity, SharedString, Task};
   6use indoc::formatdoc;
   7use language::Point;
   8use language_model::{LanguageModelImage, LanguageModelToolResultContent};
   9use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store};
  10use schemars::JsonSchema;
  11use serde::{Deserialize, Serialize};
  12use settings::Settings;
  13
  14use std::sync::Arc;
  15use util::markdown::MarkdownCodeBlock;
  16
  17fn tool_content_err(e: impl std::fmt::Display) -> LanguageModelToolResultContent {
  18    LanguageModelToolResultContent::from(e.to_string())
  19}
  20
  21use super::tool_permissions::{
  22    ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
  23    resolve_project_path,
  24};
  25use crate::{AgentTool, ToolCallEventStream, ToolInput, outline};
  26
  27/// Reads the content of the given file in the project.
  28///
  29/// - Never attempt to read a path that hasn't been previously mentioned.
  30/// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content.
  31///   This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line.
  32///   Do NOT retry reading the same file without line numbers if you receive an outline.
  33/// - This tool supports reading image files. Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF.
  34///   Image files are returned as visual content that you can analyze directly.
  35#[derive(Debug, Serialize, Deserialize, JsonSchema)]
  36pub struct ReadFileToolInput {
  37    /// The relative path of the file to read.
  38    ///
  39    /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
  40    ///
  41    /// <example>
  42    /// If the project has the following root directories:
  43    ///
  44    /// - /a/b/directory1
  45    /// - /c/d/directory2
  46    ///
  47    /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
  48    /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
  49    /// </example>
  50    pub path: String,
  51    /// Optional line number to start reading on (1-based index)
  52    #[serde(default)]
  53    pub start_line: Option<u32>,
  54    /// Optional line number to end reading on (1-based index, inclusive)
  55    #[serde(default)]
  56    pub end_line: Option<u32>,
  57}
  58
  59pub struct ReadFileTool {
  60    project: Entity<Project>,
  61    action_log: Entity<ActionLog>,
  62    update_agent_location: bool,
  63}
  64
  65impl ReadFileTool {
  66    pub fn new(
  67        project: Entity<Project>,
  68        action_log: Entity<ActionLog>,
  69        update_agent_location: bool,
  70    ) -> Self {
  71        Self {
  72            project,
  73            action_log,
  74            update_agent_location,
  75        }
  76    }
  77}
  78
  79impl AgentTool for ReadFileTool {
  80    type Input = ReadFileToolInput;
  81    type Output = LanguageModelToolResultContent;
  82
  83    const NAME: &'static str = "read_file";
  84
  85    fn kind() -> acp::ToolKind {
  86        acp::ToolKind::Read
  87    }
  88
  89    fn initial_title(
  90        &self,
  91        input: Result<Self::Input, serde_json::Value>,
  92        cx: &mut App,
  93    ) -> SharedString {
  94        if let Ok(input) = input
  95            && let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx)
  96            && let Some(path) = self
  97                .project
  98                .read(cx)
  99                .short_full_path_for_project_path(&project_path, cx)
 100        {
 101            match (input.start_line, input.end_line) {
 102                (Some(start), Some(end)) => {
 103                    format!("Read file `{path}` (lines {}-{})", start, end,)
 104                }
 105                (Some(start), None) => {
 106                    format!("Read file `{path}` (from line {})", start)
 107                }
 108                _ => format!("Read file `{path}`"),
 109            }
 110            .into()
 111        } else {
 112            "Read file".into()
 113        }
 114    }
 115
 116    fn run(
 117        self: Arc<Self>,
 118        input: ToolInput<Self::Input>,
 119        event_stream: ToolCallEventStream,
 120        cx: &mut App,
 121    ) -> Task<Result<LanguageModelToolResultContent, LanguageModelToolResultContent>> {
 122        let project = self.project.clone();
 123        let action_log = self.action_log.clone();
 124        cx.spawn(async move |cx| {
 125            let input = input
 126                .recv()
 127                .await
 128                .map_err(tool_content_err)?;
 129            let fs = project.read_with(cx, |project, _cx| project.fs().clone());
 130
 131            let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
 132
 133            if let Some(canonical_input) = crate::skills::is_skills_path(&input.path, &canonical_roots) {
 134                // Skills directory access - read directly via FS
 135                if !fs.is_file(&canonical_input).await {
 136                    return Err(tool_content_err(format!("{} not found", input.path)));
 137                }
 138
 139                cx.update(|_cx| {
 140                    event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![
 141                        acp::ToolCallLocation::new(&canonical_input)
 142                            .line(input.start_line.map(|line| line.saturating_sub(1))),
 143                    ]));
 144                });
 145
 146                // Read file directly
 147                let content = fs.load(&canonical_input).await.map_err(tool_content_err)?;
 148
 149                // Apply line range filtering if specified
 150                let content = if input.start_line.is_some() || input.end_line.is_some() {
 151                    let lines: Vec<&str> = content.lines().collect();
 152                    let start = input.start_line.unwrap_or(1).max(1) as usize;
 153                    let start_idx = start.saturating_sub(1);
 154                    let end = input.end_line.unwrap_or(u32::MAX) as usize;
 155                    if end <= start_idx {
 156                        lines.get(start_idx).copied().unwrap_or("").to_string()
 157                    } else {
 158                        lines[start_idx..end.min(lines.len())].join("\n")
 159                    }
 160                } else {
 161                    content
 162                };
 163
 164                return Ok(LanguageModelToolResultContent::Text(content.into()));
 165            }
 166
 167            let (project_path, symlink_canonical_target) =
 168                project.read_with(cx, |project, cx| {
 169                    let resolved =
 170                        resolve_project_path(project, &input.path, &canonical_roots, cx)?;
 171                    anyhow::Ok(match resolved {
 172                        ResolvedProjectPath::Safe(path) => (path, None),
 173                        ResolvedProjectPath::SymlinkEscape {
 174                            project_path,
 175                            canonical_target,
 176                        } => (project_path, Some(canonical_target)),
 177                    })
 178                }).map_err(tool_content_err)?;
 179
 180            let abs_path = project
 181                .read_with(cx, |project, cx| {
 182                    project.absolute_path(&project_path, cx)
 183                })
 184                .ok_or_else(|| {
 185                    anyhow!("Failed to convert {} to absolute path", &input.path)
 186                }).map_err(tool_content_err)?;
 187
 188            // Check settings exclusions synchronously
 189            project.read_with(cx, |_project, cx| {
 190                let global_settings = WorktreeSettings::get_global(cx);
 191                if global_settings.is_path_excluded(&project_path.path) {
 192                    anyhow::bail!(
 193                        "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
 194                        &input.path
 195                    );
 196                }
 197
 198                if global_settings.is_path_private(&project_path.path) {
 199                    anyhow::bail!(
 200                        "Cannot read file because its path matches the global `private_files` setting: {}",
 201                        &input.path
 202                    );
 203                }
 204
 205                let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
 206                if worktree_settings.is_path_excluded(&project_path.path) {
 207                    anyhow::bail!(
 208                        "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
 209                        &input.path
 210                    );
 211                }
 212
 213                if worktree_settings.is_path_private(&project_path.path) {
 214                    anyhow::bail!(
 215                        "Cannot read file because its path matches the worktree `private_files` setting: {}",
 216                        &input.path
 217                    );
 218                }
 219
 220                anyhow::Ok(())
 221            }).map_err(tool_content_err)?;
 222
 223            if let Some(canonical_target) = &symlink_canonical_target {
 224                let authorize = cx.update(|cx| {
 225                    authorize_symlink_access(
 226                        Self::NAME,
 227                        &input.path,
 228                        canonical_target,
 229                        &event_stream,
 230                        cx,
 231                    )
 232                });
 233                authorize.await.map_err(tool_content_err)?;
 234            }
 235
 236            let file_path = input.path.clone();
 237
 238            cx.update(|_cx| {
 239                event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![
 240                    acp::ToolCallLocation::new(&abs_path)
 241                        .line(input.start_line.map(|line| line.saturating_sub(1))),
 242                ]));
 243            });
 244
 245            let is_image = project.read_with(cx, |_project, cx| {
 246                image_store::is_image_file(&project, &project_path, cx)
 247            });
 248
 249            if is_image {
 250                let image_entity: Entity<ImageItem> = cx
 251                    .update(|cx| {
 252                        self.project.update(cx, |project, cx| {
 253                            project.open_image(project_path.clone(), cx)
 254                        })
 255                    })
 256                    .await.map_err(tool_content_err)?;
 257
 258                let image =
 259                    image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image));
 260
 261                let language_model_image = cx
 262                    .update(|cx| LanguageModelImage::from_image(image, cx))
 263                    .await
 264                    .context("processing image")
 265                    .map_err(tool_content_err)?;
 266
 267                event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
 268                    acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
 269                        acp::ImageContent::new(language_model_image.source.clone(), "image/png"),
 270                    ))),
 271                ]));
 272
 273                return Ok(language_model_image.into());
 274            }
 275
 276            let open_buffer_task = project.update(cx, |project, cx| {
 277                project.open_buffer(project_path.clone(), cx)
 278            });
 279
 280            let buffer = futures::select! {
 281                result = open_buffer_task.fuse() => result.map_err(tool_content_err)?,
 282                _ = event_stream.cancelled_by_user().fuse() => {
 283                    return Err(tool_content_err("File read cancelled by user"));
 284                }
 285            };
 286            if buffer.read_with(cx, |buffer, _| {
 287                buffer
 288                    .file()
 289                    .as_ref()
 290                    .is_none_or(|file| !file.disk_state().exists())
 291            }) {
 292                return Err(tool_content_err(format!("{file_path} not found")));
 293            }
 294
 295            let mut anchor = None;
 296
 297            // Check if specific line ranges are provided
 298            let result = if input.start_line.is_some() || input.end_line.is_some() {
 299                let result = buffer.read_with(cx, |buffer, _cx| {
 300                    // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
 301                    let start = input.start_line.unwrap_or(1).max(1);
 302                    let start_row = start - 1;
 303                    if start_row <= buffer.max_point().row {
 304                        let column = buffer.line_indent_for_row(start_row).raw_len();
 305                        anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
 306                    }
 307
 308                    let mut end_row = input.end_line.unwrap_or(u32::MAX);
 309                    if end_row <= start_row {
 310                        end_row = start_row + 1; // read at least one lines
 311                    }
 312                    let start = buffer.anchor_before(Point::new(start_row, 0));
 313                    let end = buffer.anchor_before(Point::new(end_row, 0));
 314                    buffer.text_for_range(start..end).collect::<String>()
 315                });
 316
 317                action_log.update(cx, |log, cx| {
 318                    log.buffer_read(buffer.clone(), cx);
 319                });
 320
 321                Ok(result.into())
 322            } else {
 323                // No line ranges specified, so check file size to see if it's too big.
 324                let buffer_content = outline::get_buffer_content_or_outline(
 325                    buffer.clone(),
 326                    Some(&abs_path.to_string_lossy()),
 327                    cx,
 328                )
 329                .await.map_err(tool_content_err)?;
 330
 331                action_log.update(cx, |log, cx| {
 332                    log.buffer_read(buffer.clone(), cx);
 333                });
 334
 335                if buffer_content.is_outline {
 336                    Ok(formatdoc! {"
 337                        SUCCESS: File outline retrieved. This file is too large to read all at once, so the outline below shows the file's structure with line numbers.
 338
 339                        IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline.
 340                        Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters.
 341
 342                        {}
 343
 344                        NEXT STEPS: To read a specific symbol's implementation, call read_file with the same path plus start_line and end_line from the outline above.
 345                        For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text
 346                    }
 347                    .into())
 348                } else {
 349                    Ok(buffer_content.text.into())
 350                }
 351            };
 352
 353            project.update(cx, |project, cx| {
 354                if self.update_agent_location {
 355                    project.set_agent_location(
 356                        Some(AgentLocation {
 357                            buffer: buffer.downgrade(),
 358                            position: anchor.unwrap_or_else(|| {
 359                                text::Anchor::min_for_buffer(buffer.read(cx).remote_id())
 360                            }),
 361                        }),
 362                        cx,
 363                    );
 364                }
 365                if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
 366                    let text: &str = text;
 367                    let markdown = MarkdownCodeBlock {
 368                        tag: &input.path,
 369                        text,
 370                    }
 371                    .to_string();
 372                    event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
 373                        acp::ToolCallContent::Content(acp::Content::new(markdown)),
 374                    ]));
 375                }
 376            });
 377
 378            result
 379        })
 380    }
 381}
 382
 383#[cfg(test)]
 384mod test {
 385    use super::*;
 386    use agent_client_protocol as acp;
 387    use fs::Fs as _;
 388    use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
 389    use project::{FakeFs, Project};
 390    use serde_json::json;
 391    use settings::SettingsStore;
 392    use std::path::PathBuf;
 393    use std::sync::Arc;
 394    use util::path;
 395
 396    #[gpui::test]
 397    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
 398        init_test(cx);
 399
 400        let fs = FakeFs::new(cx.executor());
 401        fs.insert_tree(path!("/root"), json!({})).await;
 402        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 403        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 404        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 405        let (event_stream, _) = ToolCallEventStream::test();
 406
 407        let result = cx
 408            .update(|cx| {
 409                let input = ReadFileToolInput {
 410                    path: "root/nonexistent_file.txt".to_string(),
 411                    start_line: None,
 412                    end_line: None,
 413                };
 414                tool.run(ToolInput::resolved(input), event_stream, cx)
 415            })
 416            .await;
 417        assert_eq!(
 418            error_text(result.unwrap_err()),
 419            "root/nonexistent_file.txt not found"
 420        );
 421    }
 422
 423    #[gpui::test]
 424    async fn test_read_small_file(cx: &mut TestAppContext) {
 425        init_test(cx);
 426
 427        let fs = FakeFs::new(cx.executor());
 428        fs.insert_tree(
 429            path!("/root"),
 430            json!({
 431                "small_file.txt": "This is a small file content"
 432            }),
 433        )
 434        .await;
 435        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 436        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 437        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 438        let result = cx
 439            .update(|cx| {
 440                let input = ReadFileToolInput {
 441                    path: "root/small_file.txt".into(),
 442                    start_line: None,
 443                    end_line: None,
 444                };
 445                tool.run(
 446                    ToolInput::resolved(input),
 447                    ToolCallEventStream::test().0,
 448                    cx,
 449                )
 450            })
 451            .await;
 452        assert_eq!(result.unwrap(), "This is a small file content".into());
 453    }
 454
 455    #[gpui::test]
 456    async fn test_read_large_file(cx: &mut TestAppContext) {
 457        init_test(cx);
 458
 459        let fs = FakeFs::new(cx.executor());
 460        fs.insert_tree(
 461            path!("/root"),
 462            json!({
 463                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
 464            }),
 465        )
 466        .await;
 467        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 468        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 469        language_registry.add(language::rust_lang());
 470        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 471        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 472        let result = cx
 473            .update(|cx| {
 474                let input = ReadFileToolInput {
 475                    path: "root/large_file.rs".into(),
 476                    start_line: None,
 477                    end_line: None,
 478                };
 479                tool.clone().run(
 480                    ToolInput::resolved(input),
 481                    ToolCallEventStream::test().0,
 482                    cx,
 483                )
 484            })
 485            .await
 486            .unwrap();
 487        let content = result.to_str().unwrap();
 488
 489        assert_eq!(
 490            content.lines().skip(7).take(6).collect::<Vec<_>>(),
 491            vec![
 492                "struct Test0 [L1-4]",
 493                " a [L2]",
 494                " b [L3]",
 495                "struct Test1 [L5-8]",
 496                " a [L6]",
 497                " b [L7]",
 498            ]
 499        );
 500
 501        let result = cx
 502            .update(|cx| {
 503                let input = ReadFileToolInput {
 504                    path: "root/large_file.rs".into(),
 505                    start_line: None,
 506                    end_line: None,
 507                };
 508                tool.run(
 509                    ToolInput::resolved(input),
 510                    ToolCallEventStream::test().0,
 511                    cx,
 512                )
 513            })
 514            .await
 515            .unwrap();
 516        let content = result.to_str().unwrap();
 517        let expected_content = (0..1000)
 518            .flat_map(|i| {
 519                vec![
 520                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
 521                    format!(" a [L{}]", i * 4 + 2),
 522                    format!(" b [L{}]", i * 4 + 3),
 523                ]
 524            })
 525            .collect::<Vec<_>>();
 526        pretty_assertions::assert_eq!(
 527            content
 528                .lines()
 529                .skip(7)
 530                .take(expected_content.len())
 531                .collect::<Vec<_>>(),
 532            expected_content
 533        );
 534    }
 535
 536    #[gpui::test]
 537    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
 538        init_test(cx);
 539
 540        let fs = FakeFs::new(cx.executor());
 541        fs.insert_tree(
 542            path!("/root"),
 543            json!({
 544                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 545            }),
 546        )
 547        .await;
 548        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 549
 550        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 551        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 552        let result = cx
 553            .update(|cx| {
 554                let input = ReadFileToolInput {
 555                    path: "root/multiline.txt".to_string(),
 556                    start_line: Some(2),
 557                    end_line: Some(4),
 558                };
 559                tool.run(
 560                    ToolInput::resolved(input),
 561                    ToolCallEventStream::test().0,
 562                    cx,
 563                )
 564            })
 565            .await;
 566        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
 567    }
 568
 569    #[gpui::test]
 570    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
 571        init_test(cx);
 572
 573        let fs = FakeFs::new(cx.executor());
 574        fs.insert_tree(
 575            path!("/root"),
 576            json!({
 577                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 578            }),
 579        )
 580        .await;
 581        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 582        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 583        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 584
 585        // start_line of 0 should be treated as 1
 586        let result = cx
 587            .update(|cx| {
 588                let input = ReadFileToolInput {
 589                    path: "root/multiline.txt".to_string(),
 590                    start_line: Some(0),
 591                    end_line: Some(2),
 592                };
 593                tool.clone().run(
 594                    ToolInput::resolved(input),
 595                    ToolCallEventStream::test().0,
 596                    cx,
 597                )
 598            })
 599            .await;
 600        assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
 601
 602        // end_line of 0 should result in at least 1 line
 603        let result = cx
 604            .update(|cx| {
 605                let input = ReadFileToolInput {
 606                    path: "root/multiline.txt".to_string(),
 607                    start_line: Some(1),
 608                    end_line: Some(0),
 609                };
 610                tool.clone().run(
 611                    ToolInput::resolved(input),
 612                    ToolCallEventStream::test().0,
 613                    cx,
 614                )
 615            })
 616            .await;
 617        assert_eq!(result.unwrap(), "Line 1\n".into());
 618
 619        // when start_line > end_line, should still return at least 1 line
 620        let result = cx
 621            .update(|cx| {
 622                let input = ReadFileToolInput {
 623                    path: "root/multiline.txt".to_string(),
 624                    start_line: Some(3),
 625                    end_line: Some(2),
 626                };
 627                tool.clone().run(
 628                    ToolInput::resolved(input),
 629                    ToolCallEventStream::test().0,
 630                    cx,
 631                )
 632            })
 633            .await;
 634        assert_eq!(result.unwrap(), "Line 3\n".into());
 635    }
 636
 637    fn error_text(content: LanguageModelToolResultContent) -> String {
 638        match content {
 639            LanguageModelToolResultContent::Text(text) => text.to_string(),
 640            other => panic!("Expected text error, got: {other:?}"),
 641        }
 642    }
 643
 644    fn init_test(cx: &mut TestAppContext) {
 645        cx.update(|cx| {
 646            let settings_store = SettingsStore::test(cx);
 647            cx.set_global(settings_store);
 648        });
 649    }
 650
 651    fn single_pixel_png() -> Vec<u8> {
 652        vec![
 653            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
 654            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
 655            0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
 656            0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
 657            0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
 658        ]
 659    }
 660
 661    #[gpui::test]
 662    async fn test_read_file_security(cx: &mut TestAppContext) {
 663        init_test(cx);
 664
 665        let fs = FakeFs::new(cx.executor());
 666
 667        fs.insert_tree(
 668            path!("/"),
 669            json!({
 670                "project_root": {
 671                    "allowed_file.txt": "This file is in the project",
 672                    ".mysecrets": "SECRET_KEY=abc123",
 673                    ".secretdir": {
 674                        "config": "special configuration"
 675                    },
 676                    ".mymetadata": "custom metadata",
 677                    "subdir": {
 678                        "normal_file.txt": "Normal file content",
 679                        "special.privatekey": "private key content",
 680                        "data.mysensitive": "sensitive data"
 681                    }
 682                },
 683                "outside_project": {
 684                    "sensitive_file.txt": "This file is outside the project"
 685                }
 686            }),
 687        )
 688        .await;
 689
 690        cx.update(|cx| {
 691            use gpui::UpdateGlobal;
 692            use settings::SettingsStore;
 693            SettingsStore::update_global(cx, |store, cx| {
 694                store.update_user_settings(cx, |settings| {
 695                    settings.project.worktree.file_scan_exclusions = Some(vec![
 696                        "**/.secretdir".to_string(),
 697                        "**/.mymetadata".to_string(),
 698                    ]);
 699                    settings.project.worktree.private_files = Some(
 700                        vec![
 701                            "**/.mysecrets".to_string(),
 702                            "**/*.privatekey".to_string(),
 703                            "**/*.mysensitive".to_string(),
 704                        ]
 705                        .into(),
 706                    );
 707                });
 708            });
 709        });
 710
 711        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 712        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 713        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 714
 715        // Reading a file outside the project worktree should fail
 716        let result = cx
 717            .update(|cx| {
 718                let input = ReadFileToolInput {
 719                    path: "/outside_project/sensitive_file.txt".to_string(),
 720                    start_line: None,
 721                    end_line: None,
 722                };
 723                tool.clone().run(
 724                    ToolInput::resolved(input),
 725                    ToolCallEventStream::test().0,
 726                    cx,
 727                )
 728            })
 729            .await;
 730        assert!(
 731            result.is_err(),
 732            "read_file_tool should error when attempting to read an absolute path outside a worktree"
 733        );
 734
 735        // Reading a file within the project should succeed
 736        let result = cx
 737            .update(|cx| {
 738                let input = ReadFileToolInput {
 739                    path: "project_root/allowed_file.txt".to_string(),
 740                    start_line: None,
 741                    end_line: None,
 742                };
 743                tool.clone().run(
 744                    ToolInput::resolved(input),
 745                    ToolCallEventStream::test().0,
 746                    cx,
 747                )
 748            })
 749            .await;
 750        assert!(
 751            result.is_ok(),
 752            "read_file_tool should be able to read files inside worktrees"
 753        );
 754
 755        // Reading files that match file_scan_exclusions should fail
 756        let result = cx
 757            .update(|cx| {
 758                let input = ReadFileToolInput {
 759                    path: "project_root/.secretdir/config".to_string(),
 760                    start_line: None,
 761                    end_line: None,
 762                };
 763                tool.clone().run(
 764                    ToolInput::resolved(input),
 765                    ToolCallEventStream::test().0,
 766                    cx,
 767                )
 768            })
 769            .await;
 770        assert!(
 771            result.is_err(),
 772            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
 773        );
 774
 775        let result = cx
 776            .update(|cx| {
 777                let input = ReadFileToolInput {
 778                    path: "project_root/.mymetadata".to_string(),
 779                    start_line: None,
 780                    end_line: None,
 781                };
 782                tool.clone().run(
 783                    ToolInput::resolved(input),
 784                    ToolCallEventStream::test().0,
 785                    cx,
 786                )
 787            })
 788            .await;
 789        assert!(
 790            result.is_err(),
 791            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
 792        );
 793
 794        // Reading private files should fail
 795        let result = cx
 796            .update(|cx| {
 797                let input = ReadFileToolInput {
 798                    path: "project_root/.mysecrets".to_string(),
 799                    start_line: None,
 800                    end_line: None,
 801                };
 802                tool.clone().run(
 803                    ToolInput::resolved(input),
 804                    ToolCallEventStream::test().0,
 805                    cx,
 806                )
 807            })
 808            .await;
 809        assert!(
 810            result.is_err(),
 811            "read_file_tool should error when attempting to read .mysecrets (private_files)"
 812        );
 813
 814        let result = cx
 815            .update(|cx| {
 816                let input = ReadFileToolInput {
 817                    path: "project_root/subdir/special.privatekey".to_string(),
 818                    start_line: None,
 819                    end_line: None,
 820                };
 821                tool.clone().run(
 822                    ToolInput::resolved(input),
 823                    ToolCallEventStream::test().0,
 824                    cx,
 825                )
 826            })
 827            .await;
 828        assert!(
 829            result.is_err(),
 830            "read_file_tool should error when attempting to read .privatekey files (private_files)"
 831        );
 832
 833        let result = cx
 834            .update(|cx| {
 835                let input = ReadFileToolInput {
 836                    path: "project_root/subdir/data.mysensitive".to_string(),
 837                    start_line: None,
 838                    end_line: None,
 839                };
 840                tool.clone().run(
 841                    ToolInput::resolved(input),
 842                    ToolCallEventStream::test().0,
 843                    cx,
 844                )
 845            })
 846            .await;
 847        assert!(
 848            result.is_err(),
 849            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
 850        );
 851
 852        // Reading a normal file should still work, even with private_files configured
 853        let result = cx
 854            .update(|cx| {
 855                let input = ReadFileToolInput {
 856                    path: "project_root/subdir/normal_file.txt".to_string(),
 857                    start_line: None,
 858                    end_line: None,
 859                };
 860                tool.clone().run(
 861                    ToolInput::resolved(input),
 862                    ToolCallEventStream::test().0,
 863                    cx,
 864                )
 865            })
 866            .await;
 867        assert!(result.is_ok(), "Should be able to read normal files");
 868        assert_eq!(result.unwrap(), "Normal file content".into());
 869
 870        // Path traversal attempts with .. should fail
 871        let result = cx
 872            .update(|cx| {
 873                let input = ReadFileToolInput {
 874                    path: "project_root/../outside_project/sensitive_file.txt".to_string(),
 875                    start_line: None,
 876                    end_line: None,
 877                };
 878                tool.run(
 879                    ToolInput::resolved(input),
 880                    ToolCallEventStream::test().0,
 881                    cx,
 882                )
 883            })
 884            .await;
 885        assert!(
 886            result.is_err(),
 887            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
 888        );
 889    }
 890
 891    #[gpui::test]
 892    async fn test_read_image_symlink_requires_authorization(cx: &mut TestAppContext) {
 893        init_test(cx);
 894
 895        let fs = FakeFs::new(cx.executor());
 896        fs.insert_tree(path!("/root"), json!({})).await;
 897        fs.insert_tree(path!("/outside"), json!({})).await;
 898        fs.insert_file(path!("/outside/secret.png"), single_pixel_png())
 899            .await;
 900        fs.insert_symlink(
 901            path!("/root/secret.png"),
 902            PathBuf::from("/outside/secret.png"),
 903        )
 904        .await;
 905
 906        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 907        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 908        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 909
 910        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 911        let read_task = cx.update(|cx| {
 912            tool.run(
 913                ToolInput::resolved(ReadFileToolInput {
 914                    path: "root/secret.png".to_string(),
 915                    start_line: None,
 916                    end_line: None,
 917                }),
 918                event_stream,
 919                cx,
 920            )
 921        });
 922
 923        let authorization = event_rx.expect_authorization().await;
 924        assert!(
 925            authorization
 926                .tool_call
 927                .fields
 928                .title
 929                .as_deref()
 930                .is_some_and(|title| title.contains("points outside the project")),
 931            "Expected symlink escape authorization before reading the image"
 932        );
 933        authorization
 934            .response
 935            .send(acp_thread::SelectedPermissionOutcome::new(
 936                acp::PermissionOptionId::new("allow"),
 937                acp::PermissionOptionKind::AllowOnce,
 938            ))
 939            .unwrap();
 940
 941        let result = read_task.await;
 942        assert!(result.is_ok());
 943    }
 944
 945    #[gpui::test]
 946    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 947        init_test(cx);
 948
 949        let fs = FakeFs::new(cx.executor());
 950
 951        // Create first worktree with its own private_files setting
 952        fs.insert_tree(
 953            path!("/worktree1"),
 954            json!({
 955                "src": {
 956                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 957                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 958                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 959                },
 960                "tests": {
 961                    "test.rs": "mod tests { fn test_it() {} }",
 962                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 963                },
 964                ".zed": {
 965                    "settings.json": r#"{
 966                        "file_scan_exclusions": ["**/fixture.*"],
 967                        "private_files": ["**/secret.rs", "**/config.toml"]
 968                    }"#
 969                }
 970            }),
 971        )
 972        .await;
 973
 974        // Create second worktree with different private_files setting
 975        fs.insert_tree(
 976            path!("/worktree2"),
 977            json!({
 978                "lib": {
 979                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 980                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 981                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 982                },
 983                "docs": {
 984                    "README.md": "# Public Documentation",
 985                    "internal.md": "# Internal Secrets and Configuration"
 986                },
 987                ".zed": {
 988                    "settings.json": r#"{
 989                        "file_scan_exclusions": ["**/internal.*"],
 990                        "private_files": ["**/private.js", "**/data.json"]
 991                    }"#
 992                }
 993            }),
 994        )
 995        .await;
 996
 997        // Set global settings
 998        cx.update(|cx| {
 999            SettingsStore::update_global(cx, |store, cx| {
1000                store.update_user_settings(cx, |settings| {
1001                    settings.project.worktree.file_scan_exclusions =
1002                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1003                    settings.project.worktree.private_files =
1004                        Some(vec!["**/.env".to_string()].into());
1005                });
1006            });
1007        });
1008
1009        let project = Project::test(
1010            fs.clone(),
1011            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1012            cx,
1013        )
1014        .await;
1015
1016        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1017        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone(), true));
1018
1019        // Test reading allowed files in worktree1
1020        let result = cx
1021            .update(|cx| {
1022                let input = ReadFileToolInput {
1023                    path: "worktree1/src/main.rs".to_string(),
1024                    start_line: None,
1025                    end_line: None,
1026                };
1027                tool.clone().run(
1028                    ToolInput::resolved(input),
1029                    ToolCallEventStream::test().0,
1030                    cx,
1031                )
1032            })
1033            .await
1034            .unwrap();
1035
1036        assert_eq!(
1037            result,
1038            "fn main() { println!(\"Hello from worktree1\"); }".into()
1039        );
1040
1041        // Test reading private file in worktree1 should fail
1042        let result = cx
1043            .update(|cx| {
1044                let input = ReadFileToolInput {
1045                    path: "worktree1/src/secret.rs".to_string(),
1046                    start_line: None,
1047                    end_line: None,
1048                };
1049                tool.clone().run(
1050                    ToolInput::resolved(input),
1051                    ToolCallEventStream::test().0,
1052                    cx,
1053                )
1054            })
1055            .await;
1056
1057        assert!(result.is_err());
1058        assert!(
1059            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1060            "Error should mention worktree private_files setting"
1061        );
1062
1063        // Test reading excluded file in worktree1 should fail
1064        let result = cx
1065            .update(|cx| {
1066                let input = ReadFileToolInput {
1067                    path: "worktree1/tests/fixture.sql".to_string(),
1068                    start_line: None,
1069                    end_line: None,
1070                };
1071                tool.clone().run(
1072                    ToolInput::resolved(input),
1073                    ToolCallEventStream::test().0,
1074                    cx,
1075                )
1076            })
1077            .await;
1078
1079        assert!(result.is_err());
1080        assert!(
1081            error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1082            "Error should mention worktree file_scan_exclusions setting"
1083        );
1084
1085        // Test reading allowed files in worktree2
1086        let result = cx
1087            .update(|cx| {
1088                let input = ReadFileToolInput {
1089                    path: "worktree2/lib/public.js".to_string(),
1090                    start_line: None,
1091                    end_line: None,
1092                };
1093                tool.clone().run(
1094                    ToolInput::resolved(input),
1095                    ToolCallEventStream::test().0,
1096                    cx,
1097                )
1098            })
1099            .await
1100            .unwrap();
1101
1102        assert_eq!(
1103            result,
1104            "export function greet() { return 'Hello from worktree2'; }".into()
1105        );
1106
1107        // Test reading private file in worktree2 should fail
1108        let result = cx
1109            .update(|cx| {
1110                let input = ReadFileToolInput {
1111                    path: "worktree2/lib/private.js".to_string(),
1112                    start_line: None,
1113                    end_line: None,
1114                };
1115                tool.clone().run(
1116                    ToolInput::resolved(input),
1117                    ToolCallEventStream::test().0,
1118                    cx,
1119                )
1120            })
1121            .await;
1122
1123        assert!(result.is_err());
1124        assert!(
1125            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1126            "Error should mention worktree private_files setting"
1127        );
1128
1129        // Test reading excluded file in worktree2 should fail
1130        let result = cx
1131            .update(|cx| {
1132                let input = ReadFileToolInput {
1133                    path: "worktree2/docs/internal.md".to_string(),
1134                    start_line: None,
1135                    end_line: None,
1136                };
1137                tool.clone().run(
1138                    ToolInput::resolved(input),
1139                    ToolCallEventStream::test().0,
1140                    cx,
1141                )
1142            })
1143            .await;
1144
1145        assert!(result.is_err());
1146        assert!(
1147            error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1148            "Error should mention worktree file_scan_exclusions setting"
1149        );
1150
1151        // Test that files allowed in one worktree but not in another are handled correctly
1152        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1153        let result = cx
1154            .update(|cx| {
1155                let input = ReadFileToolInput {
1156                    path: "worktree1/src/config.toml".to_string(),
1157                    start_line: None,
1158                    end_line: None,
1159                };
1160                tool.clone().run(
1161                    ToolInput::resolved(input),
1162                    ToolCallEventStream::test().0,
1163                    cx,
1164                )
1165            })
1166            .await;
1167
1168        assert!(result.is_err());
1169        assert!(
1170            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1171            "Config.toml should be blocked by worktree1's private_files setting"
1172        );
1173    }
1174
1175    #[gpui::test]
1176    async fn test_read_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
1177        init_test(cx);
1178
1179        let fs = FakeFs::new(cx.executor());
1180        fs.insert_tree(
1181            path!("/root"),
1182            json!({
1183                "project": {
1184                    "src": { "main.rs": "fn main() {}" }
1185                },
1186                "external": {
1187                    "secret.txt": "SECRET_KEY=abc123"
1188                }
1189            }),
1190        )
1191        .await;
1192
1193        fs.create_symlink(
1194            path!("/root/project/secret_link.txt").as_ref(),
1195            PathBuf::from("../external/secret.txt"),
1196        )
1197        .await
1198        .unwrap();
1199
1200        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1201        cx.executor().run_until_parked();
1202
1203        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1204        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1205
1206        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1207        let task = cx.update(|cx| {
1208            tool.clone().run(
1209                ToolInput::resolved(ReadFileToolInput {
1210                    path: "project/secret_link.txt".to_string(),
1211                    start_line: None,
1212                    end_line: None,
1213                }),
1214                event_stream,
1215                cx,
1216            )
1217        });
1218
1219        let auth = event_rx.expect_authorization().await;
1220        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
1221        assert!(
1222            title.contains("points outside the project"),
1223            "title: {title}"
1224        );
1225
1226        auth.response
1227            .send(acp_thread::SelectedPermissionOutcome::new(
1228                acp::PermissionOptionId::new("allow"),
1229                acp::PermissionOptionKind::AllowOnce,
1230            ))
1231            .unwrap();
1232
1233        let result = task.await;
1234        assert!(result.is_ok(), "should succeed after approval: {result:?}");
1235    }
1236
1237    #[gpui::test]
1238    async fn test_read_file_symlink_escape_denied(cx: &mut TestAppContext) {
1239        init_test(cx);
1240
1241        let fs = FakeFs::new(cx.executor());
1242        fs.insert_tree(
1243            path!("/root"),
1244            json!({
1245                "project": {
1246                    "src": { "main.rs": "fn main() {}" }
1247                },
1248                "external": {
1249                    "secret.txt": "SECRET_KEY=abc123"
1250                }
1251            }),
1252        )
1253        .await;
1254
1255        fs.create_symlink(
1256            path!("/root/project/secret_link.txt").as_ref(),
1257            PathBuf::from("../external/secret.txt"),
1258        )
1259        .await
1260        .unwrap();
1261
1262        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1263        cx.executor().run_until_parked();
1264
1265        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1266        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1267
1268        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1269        let task = cx.update(|cx| {
1270            tool.clone().run(
1271                ToolInput::resolved(ReadFileToolInput {
1272                    path: "project/secret_link.txt".to_string(),
1273                    start_line: None,
1274                    end_line: None,
1275                }),
1276                event_stream,
1277                cx,
1278            )
1279        });
1280
1281        let auth = event_rx.expect_authorization().await;
1282        drop(auth);
1283
1284        let result = task.await;
1285        assert!(
1286            result.is_err(),
1287            "Tool should fail when authorization is denied"
1288        );
1289    }
1290
1291    #[gpui::test]
1292    async fn test_read_file_symlink_escape_private_path_no_authorization(cx: &mut TestAppContext) {
1293        init_test(cx);
1294
1295        let fs = FakeFs::new(cx.executor());
1296        fs.insert_tree(
1297            path!("/root"),
1298            json!({
1299                "project": {
1300                    "src": { "main.rs": "fn main() {}" }
1301                },
1302                "external": {
1303                    "secret.txt": "SECRET_KEY=abc123"
1304                }
1305            }),
1306        )
1307        .await;
1308
1309        fs.create_symlink(
1310            path!("/root/project/secret_link.txt").as_ref(),
1311            PathBuf::from("../external/secret.txt"),
1312        )
1313        .await
1314        .unwrap();
1315
1316        cx.update(|cx| {
1317            settings::SettingsStore::update_global(cx, |store, cx| {
1318                store.update_user_settings(cx, |settings| {
1319                    settings.project.worktree.private_files =
1320                        Some(vec!["**/secret_link.txt".to_string()].into());
1321                });
1322            });
1323        });
1324
1325        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1326        cx.executor().run_until_parked();
1327
1328        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1329        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1330
1331        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1332        let result = cx
1333            .update(|cx| {
1334                tool.clone().run(
1335                    ToolInput::resolved(ReadFileToolInput {
1336                        path: "project/secret_link.txt".to_string(),
1337                        start_line: None,
1338                        end_line: None,
1339                    }),
1340                    event_stream,
1341                    cx,
1342                )
1343            })
1344            .await;
1345
1346        assert!(
1347            result.is_err(),
1348            "Expected read_file to fail on private path"
1349        );
1350        let error = error_text(result.unwrap_err());
1351        assert!(
1352            error.contains("private_files"),
1353            "Expected private-files validation error, got: {error}"
1354        );
1355
1356        let event = event_rx.try_recv();
1357        assert!(
1358            !matches!(
1359                event,
1360                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
1361            ),
1362            "No authorization should be requested when validation fails before read",
1363        );
1364    }
1365}