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, LanguageModelImageExt, 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_thread::SelectedPermissionOutcome::new(
 900                acp::PermissionOptionId::new("allow"),
 901                acp::PermissionOptionKind::AllowOnce,
 902            ))
 903            .unwrap();
 904
 905        let result = read_task.await;
 906        assert!(result.is_ok());
 907    }
 908
 909    #[gpui::test]
 910    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 911        init_test(cx);
 912
 913        let fs = FakeFs::new(cx.executor());
 914
 915        // Create first worktree with its own private_files setting
 916        fs.insert_tree(
 917            path!("/worktree1"),
 918            json!({
 919                "src": {
 920                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 921                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 922                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 923                },
 924                "tests": {
 925                    "test.rs": "mod tests { fn test_it() {} }",
 926                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 927                },
 928                ".zed": {
 929                    "settings.json": r#"{
 930                        "file_scan_exclusions": ["**/fixture.*"],
 931                        "private_files": ["**/secret.rs", "**/config.toml"]
 932                    }"#
 933                }
 934            }),
 935        )
 936        .await;
 937
 938        // Create second worktree with different private_files setting
 939        fs.insert_tree(
 940            path!("/worktree2"),
 941            json!({
 942                "lib": {
 943                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 944                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 945                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 946                },
 947                "docs": {
 948                    "README.md": "# Public Documentation",
 949                    "internal.md": "# Internal Secrets and Configuration"
 950                },
 951                ".zed": {
 952                    "settings.json": r#"{
 953                        "file_scan_exclusions": ["**/internal.*"],
 954                        "private_files": ["**/private.js", "**/data.json"]
 955                    }"#
 956                }
 957            }),
 958        )
 959        .await;
 960
 961        // Set global settings
 962        cx.update(|cx| {
 963            SettingsStore::update_global(cx, |store, cx| {
 964                store.update_user_settings(cx, |settings| {
 965                    settings.project.worktree.file_scan_exclusions =
 966                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 967                    settings.project.worktree.private_files =
 968                        Some(vec!["**/.env".to_string()].into());
 969                });
 970            });
 971        });
 972
 973        let project = Project::test(
 974            fs.clone(),
 975            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 976            cx,
 977        )
 978        .await;
 979
 980        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 981        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone(), true));
 982
 983        // Test reading allowed files in worktree1
 984        let result = cx
 985            .update(|cx| {
 986                let input = ReadFileToolInput {
 987                    path: "worktree1/src/main.rs".to_string(),
 988                    start_line: None,
 989                    end_line: None,
 990                };
 991                tool.clone().run(
 992                    ToolInput::resolved(input),
 993                    ToolCallEventStream::test().0,
 994                    cx,
 995                )
 996            })
 997            .await
 998            .unwrap();
 999
1000        assert_eq!(
1001            result,
1002            "fn main() { println!(\"Hello from worktree1\"); }".into()
1003        );
1004
1005        // Test reading private file in worktree1 should fail
1006        let result = cx
1007            .update(|cx| {
1008                let input = ReadFileToolInput {
1009                    path: "worktree1/src/secret.rs".to_string(),
1010                    start_line: None,
1011                    end_line: None,
1012                };
1013                tool.clone().run(
1014                    ToolInput::resolved(input),
1015                    ToolCallEventStream::test().0,
1016                    cx,
1017                )
1018            })
1019            .await;
1020
1021        assert!(result.is_err());
1022        assert!(
1023            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1024            "Error should mention worktree private_files setting"
1025        );
1026
1027        // Test reading excluded file in worktree1 should fail
1028        let result = cx
1029            .update(|cx| {
1030                let input = ReadFileToolInput {
1031                    path: "worktree1/tests/fixture.sql".to_string(),
1032                    start_line: None,
1033                    end_line: None,
1034                };
1035                tool.clone().run(
1036                    ToolInput::resolved(input),
1037                    ToolCallEventStream::test().0,
1038                    cx,
1039                )
1040            })
1041            .await;
1042
1043        assert!(result.is_err());
1044        assert!(
1045            error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1046            "Error should mention worktree file_scan_exclusions setting"
1047        );
1048
1049        // Test reading allowed files in worktree2
1050        let result = cx
1051            .update(|cx| {
1052                let input = ReadFileToolInput {
1053                    path: "worktree2/lib/public.js".to_string(),
1054                    start_line: None,
1055                    end_line: None,
1056                };
1057                tool.clone().run(
1058                    ToolInput::resolved(input),
1059                    ToolCallEventStream::test().0,
1060                    cx,
1061                )
1062            })
1063            .await
1064            .unwrap();
1065
1066        assert_eq!(
1067            result,
1068            "export function greet() { return 'Hello from worktree2'; }".into()
1069        );
1070
1071        // Test reading private file in worktree2 should fail
1072        let result = cx
1073            .update(|cx| {
1074                let input = ReadFileToolInput {
1075                    path: "worktree2/lib/private.js".to_string(),
1076                    start_line: None,
1077                    end_line: None,
1078                };
1079                tool.clone().run(
1080                    ToolInput::resolved(input),
1081                    ToolCallEventStream::test().0,
1082                    cx,
1083                )
1084            })
1085            .await;
1086
1087        assert!(result.is_err());
1088        assert!(
1089            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1090            "Error should mention worktree private_files setting"
1091        );
1092
1093        // Test reading excluded file in worktree2 should fail
1094        let result = cx
1095            .update(|cx| {
1096                let input = ReadFileToolInput {
1097                    path: "worktree2/docs/internal.md".to_string(),
1098                    start_line: None,
1099                    end_line: None,
1100                };
1101                tool.clone().run(
1102                    ToolInput::resolved(input),
1103                    ToolCallEventStream::test().0,
1104                    cx,
1105                )
1106            })
1107            .await;
1108
1109        assert!(result.is_err());
1110        assert!(
1111            error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1112            "Error should mention worktree file_scan_exclusions setting"
1113        );
1114
1115        // Test that files allowed in one worktree but not in another are handled correctly
1116        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1117        let result = cx
1118            .update(|cx| {
1119                let input = ReadFileToolInput {
1120                    path: "worktree1/src/config.toml".to_string(),
1121                    start_line: None,
1122                    end_line: None,
1123                };
1124                tool.clone().run(
1125                    ToolInput::resolved(input),
1126                    ToolCallEventStream::test().0,
1127                    cx,
1128                )
1129            })
1130            .await;
1131
1132        assert!(result.is_err());
1133        assert!(
1134            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1135            "Config.toml should be blocked by worktree1's private_files setting"
1136        );
1137    }
1138
1139    #[gpui::test]
1140    async fn test_read_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
1141        init_test(cx);
1142
1143        let fs = FakeFs::new(cx.executor());
1144        fs.insert_tree(
1145            path!("/root"),
1146            json!({
1147                "project": {
1148                    "src": { "main.rs": "fn main() {}" }
1149                },
1150                "external": {
1151                    "secret.txt": "SECRET_KEY=abc123"
1152                }
1153            }),
1154        )
1155        .await;
1156
1157        fs.create_symlink(
1158            path!("/root/project/secret_link.txt").as_ref(),
1159            PathBuf::from("../external/secret.txt"),
1160        )
1161        .await
1162        .unwrap();
1163
1164        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1165        cx.executor().run_until_parked();
1166
1167        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1168        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1169
1170        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1171        let task = cx.update(|cx| {
1172            tool.clone().run(
1173                ToolInput::resolved(ReadFileToolInput {
1174                    path: "project/secret_link.txt".to_string(),
1175                    start_line: None,
1176                    end_line: None,
1177                }),
1178                event_stream,
1179                cx,
1180            )
1181        });
1182
1183        let auth = event_rx.expect_authorization().await;
1184        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
1185        assert!(
1186            title.contains("points outside the project"),
1187            "title: {title}"
1188        );
1189
1190        auth.response
1191            .send(acp_thread::SelectedPermissionOutcome::new(
1192                acp::PermissionOptionId::new("allow"),
1193                acp::PermissionOptionKind::AllowOnce,
1194            ))
1195            .unwrap();
1196
1197        let result = task.await;
1198        assert!(result.is_ok(), "should succeed after approval: {result:?}");
1199    }
1200
1201    #[gpui::test]
1202    async fn test_read_file_symlink_escape_denied(cx: &mut TestAppContext) {
1203        init_test(cx);
1204
1205        let fs = FakeFs::new(cx.executor());
1206        fs.insert_tree(
1207            path!("/root"),
1208            json!({
1209                "project": {
1210                    "src": { "main.rs": "fn main() {}" }
1211                },
1212                "external": {
1213                    "secret.txt": "SECRET_KEY=abc123"
1214                }
1215            }),
1216        )
1217        .await;
1218
1219        fs.create_symlink(
1220            path!("/root/project/secret_link.txt").as_ref(),
1221            PathBuf::from("../external/secret.txt"),
1222        )
1223        .await
1224        .unwrap();
1225
1226        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1227        cx.executor().run_until_parked();
1228
1229        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1230        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1231
1232        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1233        let task = cx.update(|cx| {
1234            tool.clone().run(
1235                ToolInput::resolved(ReadFileToolInput {
1236                    path: "project/secret_link.txt".to_string(),
1237                    start_line: None,
1238                    end_line: None,
1239                }),
1240                event_stream,
1241                cx,
1242            )
1243        });
1244
1245        let auth = event_rx.expect_authorization().await;
1246        drop(auth);
1247
1248        let result = task.await;
1249        assert!(
1250            result.is_err(),
1251            "Tool should fail when authorization is denied"
1252        );
1253    }
1254
1255    #[gpui::test]
1256    async fn test_read_file_symlink_escape_private_path_no_authorization(cx: &mut TestAppContext) {
1257        init_test(cx);
1258
1259        let fs = FakeFs::new(cx.executor());
1260        fs.insert_tree(
1261            path!("/root"),
1262            json!({
1263                "project": {
1264                    "src": { "main.rs": "fn main() {}" }
1265                },
1266                "external": {
1267                    "secret.txt": "SECRET_KEY=abc123"
1268                }
1269            }),
1270        )
1271        .await;
1272
1273        fs.create_symlink(
1274            path!("/root/project/secret_link.txt").as_ref(),
1275            PathBuf::from("../external/secret.txt"),
1276        )
1277        .await
1278        .unwrap();
1279
1280        cx.update(|cx| {
1281            settings::SettingsStore::update_global(cx, |store, cx| {
1282                store.update_user_settings(cx, |settings| {
1283                    settings.project.worktree.private_files =
1284                        Some(vec!["**/secret_link.txt".to_string()].into());
1285                });
1286            });
1287        });
1288
1289        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1290        cx.executor().run_until_parked();
1291
1292        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1293        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1294
1295        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1296        let result = cx
1297            .update(|cx| {
1298                tool.clone().run(
1299                    ToolInput::resolved(ReadFileToolInput {
1300                        path: "project/secret_link.txt".to_string(),
1301                        start_line: None,
1302                        end_line: None,
1303                    }),
1304                    event_stream,
1305                    cx,
1306                )
1307            })
1308            .await;
1309
1310        assert!(
1311            result.is_err(),
1312            "Expected read_file to fail on private path"
1313        );
1314        let error = error_text(result.unwrap_err());
1315        assert!(
1316            error.contains("private_files"),
1317            "Expected private-files validation error, got: {error}"
1318        );
1319
1320        let event = event_rx.try_recv();
1321        assert!(
1322            !matches!(
1323                event,
1324                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
1325            ),
1326            "No authorization should be requested when validation fails before read",
1327        );
1328    }
1329}