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