read_file_tool.rs

   1use action_log::ActionLog;
   2use agent_client_protocol::schema as acp;
   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(acp::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(acp::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(acp::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 fs::Fs as _;
 351    use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
 352    use project::{FakeFs, Project};
 353    use serde_json::json;
 354    use settings::SettingsStore;
 355    use std::path::PathBuf;
 356    use std::sync::Arc;
 357    use util::path;
 358
 359    #[gpui::test]
 360    async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
 361        init_test(cx);
 362
 363        let fs = FakeFs::new(cx.executor());
 364        fs.insert_tree(path!("/root"), json!({})).await;
 365        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 366        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 367        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 368        let (event_stream, _) = ToolCallEventStream::test();
 369
 370        let result = cx
 371            .update(|cx| {
 372                let input = ReadFileToolInput {
 373                    path: "root/nonexistent_file.txt".to_string(),
 374                    start_line: None,
 375                    end_line: None,
 376                };
 377                tool.run(ToolInput::resolved(input), event_stream, cx)
 378            })
 379            .await;
 380        assert_eq!(
 381            error_text(result.unwrap_err()),
 382            "root/nonexistent_file.txt not found"
 383        );
 384    }
 385
 386    #[gpui::test]
 387    async fn test_read_small_file(cx: &mut TestAppContext) {
 388        init_test(cx);
 389
 390        let fs = FakeFs::new(cx.executor());
 391        fs.insert_tree(
 392            path!("/root"),
 393            json!({
 394                "small_file.txt": "This is a small file content"
 395            }),
 396        )
 397        .await;
 398        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 399        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 400        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 401        let result = cx
 402            .update(|cx| {
 403                let input = ReadFileToolInput {
 404                    path: "root/small_file.txt".into(),
 405                    start_line: None,
 406                    end_line: None,
 407                };
 408                tool.run(
 409                    ToolInput::resolved(input),
 410                    ToolCallEventStream::test().0,
 411                    cx,
 412                )
 413            })
 414            .await;
 415        assert_eq!(result.unwrap(), "This is a small file content".into());
 416    }
 417
 418    #[gpui::test]
 419    async fn test_read_large_file(cx: &mut TestAppContext) {
 420        init_test(cx);
 421
 422        let fs = FakeFs::new(cx.executor());
 423        fs.insert_tree(
 424            path!("/root"),
 425            json!({
 426                "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n    a: u32,\n    b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
 427            }),
 428        )
 429        .await;
 430        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 431        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 432        language_registry.add(language::rust_lang());
 433        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 434        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 435        let result = cx
 436            .update(|cx| {
 437                let input = ReadFileToolInput {
 438                    path: "root/large_file.rs".into(),
 439                    start_line: None,
 440                    end_line: None,
 441                };
 442                tool.clone().run(
 443                    ToolInput::resolved(input),
 444                    ToolCallEventStream::test().0,
 445                    cx,
 446                )
 447            })
 448            .await
 449            .unwrap();
 450        let content = result.to_str().unwrap();
 451
 452        assert_eq!(
 453            content.lines().skip(7).take(6).collect::<Vec<_>>(),
 454            vec![
 455                "struct Test0 [L1-4]",
 456                " a [L2]",
 457                " b [L3]",
 458                "struct Test1 [L5-8]",
 459                " a [L6]",
 460                " b [L7]",
 461            ]
 462        );
 463
 464        let result = cx
 465            .update(|cx| {
 466                let input = ReadFileToolInput {
 467                    path: "root/large_file.rs".into(),
 468                    start_line: None,
 469                    end_line: None,
 470                };
 471                tool.run(
 472                    ToolInput::resolved(input),
 473                    ToolCallEventStream::test().0,
 474                    cx,
 475                )
 476            })
 477            .await
 478            .unwrap();
 479        let content = result.to_str().unwrap();
 480        let expected_content = (0..1000)
 481            .flat_map(|i| {
 482                vec![
 483                    format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
 484                    format!(" a [L{}]", i * 4 + 2),
 485                    format!(" b [L{}]", i * 4 + 3),
 486                ]
 487            })
 488            .collect::<Vec<_>>();
 489        pretty_assertions::assert_eq!(
 490            content
 491                .lines()
 492                .skip(7)
 493                .take(expected_content.len())
 494                .collect::<Vec<_>>(),
 495            expected_content
 496        );
 497    }
 498
 499    #[gpui::test]
 500    async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
 501        init_test(cx);
 502
 503        let fs = FakeFs::new(cx.executor());
 504        fs.insert_tree(
 505            path!("/root"),
 506            json!({
 507                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 508            }),
 509        )
 510        .await;
 511        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 512
 513        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 514        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 515        let result = cx
 516            .update(|cx| {
 517                let input = ReadFileToolInput {
 518                    path: "root/multiline.txt".to_string(),
 519                    start_line: Some(2),
 520                    end_line: Some(4),
 521                };
 522                tool.run(
 523                    ToolInput::resolved(input),
 524                    ToolCallEventStream::test().0,
 525                    cx,
 526                )
 527            })
 528            .await;
 529        assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
 530    }
 531
 532    #[gpui::test]
 533    async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
 534        init_test(cx);
 535
 536        let fs = FakeFs::new(cx.executor());
 537        fs.insert_tree(
 538            path!("/root"),
 539            json!({
 540                "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
 541            }),
 542        )
 543        .await;
 544        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 545        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 546        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 547
 548        // start_line of 0 should be treated as 1
 549        let result = cx
 550            .update(|cx| {
 551                let input = ReadFileToolInput {
 552                    path: "root/multiline.txt".to_string(),
 553                    start_line: Some(0),
 554                    end_line: Some(2),
 555                };
 556                tool.clone().run(
 557                    ToolInput::resolved(input),
 558                    ToolCallEventStream::test().0,
 559                    cx,
 560                )
 561            })
 562            .await;
 563        assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
 564
 565        // end_line of 0 should result in at least 1 line
 566        let result = cx
 567            .update(|cx| {
 568                let input = ReadFileToolInput {
 569                    path: "root/multiline.txt".to_string(),
 570                    start_line: Some(1),
 571                    end_line: Some(0),
 572                };
 573                tool.clone().run(
 574                    ToolInput::resolved(input),
 575                    ToolCallEventStream::test().0,
 576                    cx,
 577                )
 578            })
 579            .await;
 580        assert_eq!(result.unwrap(), "Line 1\n".into());
 581
 582        // when start_line > end_line, should still return at least 1 line
 583        let result = cx
 584            .update(|cx| {
 585                let input = ReadFileToolInput {
 586                    path: "root/multiline.txt".to_string(),
 587                    start_line: Some(3),
 588                    end_line: Some(2),
 589                };
 590                tool.clone().run(
 591                    ToolInput::resolved(input),
 592                    ToolCallEventStream::test().0,
 593                    cx,
 594                )
 595            })
 596            .await;
 597        assert_eq!(result.unwrap(), "Line 3\n".into());
 598    }
 599
 600    fn error_text(content: LanguageModelToolResultContent) -> String {
 601        match content {
 602            LanguageModelToolResultContent::Text(text) => text.to_string(),
 603            other => panic!("Expected text error, got: {other:?}"),
 604        }
 605    }
 606
 607    fn init_test(cx: &mut TestAppContext) {
 608        cx.update(|cx| {
 609            let settings_store = SettingsStore::test(cx);
 610            cx.set_global(settings_store);
 611        });
 612    }
 613
 614    fn single_pixel_png() -> Vec<u8> {
 615        vec![
 616            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
 617            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
 618            0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
 619            0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
 620            0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
 621        ]
 622    }
 623
 624    #[gpui::test]
 625    async fn test_read_file_security(cx: &mut TestAppContext) {
 626        init_test(cx);
 627
 628        let fs = FakeFs::new(cx.executor());
 629
 630        fs.insert_tree(
 631            path!("/"),
 632            json!({
 633                "project_root": {
 634                    "allowed_file.txt": "This file is in the project",
 635                    ".mysecrets": "SECRET_KEY=abc123",
 636                    ".secretdir": {
 637                        "config": "special configuration"
 638                    },
 639                    ".mymetadata": "custom metadata",
 640                    "subdir": {
 641                        "normal_file.txt": "Normal file content",
 642                        "special.privatekey": "private key content",
 643                        "data.mysensitive": "sensitive data"
 644                    }
 645                },
 646                "outside_project": {
 647                    "sensitive_file.txt": "This file is outside the project"
 648                }
 649            }),
 650        )
 651        .await;
 652
 653        cx.update(|cx| {
 654            use gpui::UpdateGlobal;
 655            use settings::SettingsStore;
 656            SettingsStore::update_global(cx, |store, cx| {
 657                store.update_user_settings(cx, |settings| {
 658                    settings.project.worktree.file_scan_exclusions = Some(vec![
 659                        "**/.secretdir".to_string(),
 660                        "**/.mymetadata".to_string(),
 661                    ]);
 662                    settings.project.worktree.private_files = Some(
 663                        vec![
 664                            "**/.mysecrets".to_string(),
 665                            "**/*.privatekey".to_string(),
 666                            "**/*.mysensitive".to_string(),
 667                        ]
 668                        .into(),
 669                    );
 670                });
 671            });
 672        });
 673
 674        let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
 675        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 676        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 677
 678        // Reading a file outside the project worktree should fail
 679        let result = cx
 680            .update(|cx| {
 681                let input = ReadFileToolInput {
 682                    path: "/outside_project/sensitive_file.txt".to_string(),
 683                    start_line: None,
 684                    end_line: None,
 685                };
 686                tool.clone().run(
 687                    ToolInput::resolved(input),
 688                    ToolCallEventStream::test().0,
 689                    cx,
 690                )
 691            })
 692            .await;
 693        assert!(
 694            result.is_err(),
 695            "read_file_tool should error when attempting to read an absolute path outside a worktree"
 696        );
 697
 698        // Reading a file within the project should succeed
 699        let result = cx
 700            .update(|cx| {
 701                let input = ReadFileToolInput {
 702                    path: "project_root/allowed_file.txt".to_string(),
 703                    start_line: None,
 704                    end_line: None,
 705                };
 706                tool.clone().run(
 707                    ToolInput::resolved(input),
 708                    ToolCallEventStream::test().0,
 709                    cx,
 710                )
 711            })
 712            .await;
 713        assert!(
 714            result.is_ok(),
 715            "read_file_tool should be able to read files inside worktrees"
 716        );
 717
 718        // Reading files that match file_scan_exclusions should fail
 719        let result = cx
 720            .update(|cx| {
 721                let input = ReadFileToolInput {
 722                    path: "project_root/.secretdir/config".to_string(),
 723                    start_line: None,
 724                    end_line: None,
 725                };
 726                tool.clone().run(
 727                    ToolInput::resolved(input),
 728                    ToolCallEventStream::test().0,
 729                    cx,
 730                )
 731            })
 732            .await;
 733        assert!(
 734            result.is_err(),
 735            "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
 736        );
 737
 738        let result = cx
 739            .update(|cx| {
 740                let input = ReadFileToolInput {
 741                    path: "project_root/.mymetadata".to_string(),
 742                    start_line: None,
 743                    end_line: None,
 744                };
 745                tool.clone().run(
 746                    ToolInput::resolved(input),
 747                    ToolCallEventStream::test().0,
 748                    cx,
 749                )
 750            })
 751            .await;
 752        assert!(
 753            result.is_err(),
 754            "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
 755        );
 756
 757        // Reading private files should fail
 758        let result = cx
 759            .update(|cx| {
 760                let input = ReadFileToolInput {
 761                    path: "project_root/.mysecrets".to_string(),
 762                    start_line: None,
 763                    end_line: None,
 764                };
 765                tool.clone().run(
 766                    ToolInput::resolved(input),
 767                    ToolCallEventStream::test().0,
 768                    cx,
 769                )
 770            })
 771            .await;
 772        assert!(
 773            result.is_err(),
 774            "read_file_tool should error when attempting to read .mysecrets (private_files)"
 775        );
 776
 777        let result = cx
 778            .update(|cx| {
 779                let input = ReadFileToolInput {
 780                    path: "project_root/subdir/special.privatekey".to_string(),
 781                    start_line: None,
 782                    end_line: None,
 783                };
 784                tool.clone().run(
 785                    ToolInput::resolved(input),
 786                    ToolCallEventStream::test().0,
 787                    cx,
 788                )
 789            })
 790            .await;
 791        assert!(
 792            result.is_err(),
 793            "read_file_tool should error when attempting to read .privatekey files (private_files)"
 794        );
 795
 796        let result = cx
 797            .update(|cx| {
 798                let input = ReadFileToolInput {
 799                    path: "project_root/subdir/data.mysensitive".to_string(),
 800                    start_line: None,
 801                    end_line: None,
 802                };
 803                tool.clone().run(
 804                    ToolInput::resolved(input),
 805                    ToolCallEventStream::test().0,
 806                    cx,
 807                )
 808            })
 809            .await;
 810        assert!(
 811            result.is_err(),
 812            "read_file_tool should error when attempting to read .mysensitive files (private_files)"
 813        );
 814
 815        // Reading a normal file should still work, even with private_files configured
 816        let result = cx
 817            .update(|cx| {
 818                let input = ReadFileToolInput {
 819                    path: "project_root/subdir/normal_file.txt".to_string(),
 820                    start_line: None,
 821                    end_line: None,
 822                };
 823                tool.clone().run(
 824                    ToolInput::resolved(input),
 825                    ToolCallEventStream::test().0,
 826                    cx,
 827                )
 828            })
 829            .await;
 830        assert!(result.is_ok(), "Should be able to read normal files");
 831        assert_eq!(result.unwrap(), "Normal file content".into());
 832
 833        // Path traversal attempts with .. should fail
 834        let result = cx
 835            .update(|cx| {
 836                let input = ReadFileToolInput {
 837                    path: "project_root/../outside_project/sensitive_file.txt".to_string(),
 838                    start_line: None,
 839                    end_line: None,
 840                };
 841                tool.run(
 842                    ToolInput::resolved(input),
 843                    ToolCallEventStream::test().0,
 844                    cx,
 845                )
 846            })
 847            .await;
 848        assert!(
 849            result.is_err(),
 850            "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
 851        );
 852    }
 853
 854    #[gpui::test]
 855    async fn test_read_image_symlink_requires_authorization(cx: &mut TestAppContext) {
 856        init_test(cx);
 857
 858        let fs = FakeFs::new(cx.executor());
 859        fs.insert_tree(path!("/root"), json!({})).await;
 860        fs.insert_tree(path!("/outside"), json!({})).await;
 861        fs.insert_file(path!("/outside/secret.png"), single_pixel_png())
 862            .await;
 863        fs.insert_symlink(
 864            path!("/root/secret.png"),
 865            PathBuf::from("/outside/secret.png"),
 866        )
 867        .await;
 868
 869        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 870        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 871        let tool = Arc::new(ReadFileTool::new(project, action_log, true));
 872
 873        let (event_stream, mut event_rx) = ToolCallEventStream::test();
 874        let read_task = cx.update(|cx| {
 875            tool.run(
 876                ToolInput::resolved(ReadFileToolInput {
 877                    path: "root/secret.png".to_string(),
 878                    start_line: None,
 879                    end_line: None,
 880                }),
 881                event_stream,
 882                cx,
 883            )
 884        });
 885
 886        let authorization = event_rx.expect_authorization().await;
 887        assert!(
 888            authorization
 889                .tool_call
 890                .fields
 891                .title
 892                .as_deref()
 893                .is_some_and(|title| title.contains("points outside the project")),
 894            "Expected symlink escape authorization before reading the image"
 895        );
 896        authorization
 897            .response
 898            .send(acp_thread::SelectedPermissionOutcome::new(
 899                acp::PermissionOptionId::new("allow"),
 900                acp::PermissionOptionKind::AllowOnce,
 901            ))
 902            .unwrap();
 903
 904        let result = read_task.await;
 905        assert!(result.is_ok());
 906    }
 907
 908    #[gpui::test]
 909    async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
 910        init_test(cx);
 911
 912        let fs = FakeFs::new(cx.executor());
 913
 914        // Create first worktree with its own private_files setting
 915        fs.insert_tree(
 916            path!("/worktree1"),
 917            json!({
 918                "src": {
 919                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
 920                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
 921                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
 922                },
 923                "tests": {
 924                    "test.rs": "mod tests { fn test_it() {} }",
 925                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
 926                },
 927                ".zed": {
 928                    "settings.json": r#"{
 929                        "file_scan_exclusions": ["**/fixture.*"],
 930                        "private_files": ["**/secret.rs", "**/config.toml"]
 931                    }"#
 932                }
 933            }),
 934        )
 935        .await;
 936
 937        // Create second worktree with different private_files setting
 938        fs.insert_tree(
 939            path!("/worktree2"),
 940            json!({
 941                "lib": {
 942                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
 943                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
 944                    "data.json": "{\"api_key\": \"json_secret_key\"}"
 945                },
 946                "docs": {
 947                    "README.md": "# Public Documentation",
 948                    "internal.md": "# Internal Secrets and Configuration"
 949                },
 950                ".zed": {
 951                    "settings.json": r#"{
 952                        "file_scan_exclusions": ["**/internal.*"],
 953                        "private_files": ["**/private.js", "**/data.json"]
 954                    }"#
 955                }
 956            }),
 957        )
 958        .await;
 959
 960        // Set global settings
 961        cx.update(|cx| {
 962            SettingsStore::update_global(cx, |store, cx| {
 963                store.update_user_settings(cx, |settings| {
 964                    settings.project.worktree.file_scan_exclusions =
 965                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
 966                    settings.project.worktree.private_files =
 967                        Some(vec!["**/.env".to_string()].into());
 968                });
 969            });
 970        });
 971
 972        let project = Project::test(
 973            fs.clone(),
 974            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
 975            cx,
 976        )
 977        .await;
 978
 979        let action_log = cx.new(|_| ActionLog::new(project.clone()));
 980        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone(), true));
 981
 982        // Test reading allowed files in worktree1
 983        let result = cx
 984            .update(|cx| {
 985                let input = ReadFileToolInput {
 986                    path: "worktree1/src/main.rs".to_string(),
 987                    start_line: None,
 988                    end_line: None,
 989                };
 990                tool.clone().run(
 991                    ToolInput::resolved(input),
 992                    ToolCallEventStream::test().0,
 993                    cx,
 994                )
 995            })
 996            .await
 997            .unwrap();
 998
 999        assert_eq!(
1000            result,
1001            "fn main() { println!(\"Hello from worktree1\"); }".into()
1002        );
1003
1004        // Test reading private file in worktree1 should fail
1005        let result = cx
1006            .update(|cx| {
1007                let input = ReadFileToolInput {
1008                    path: "worktree1/src/secret.rs".to_string(),
1009                    start_line: None,
1010                    end_line: None,
1011                };
1012                tool.clone().run(
1013                    ToolInput::resolved(input),
1014                    ToolCallEventStream::test().0,
1015                    cx,
1016                )
1017            })
1018            .await;
1019
1020        assert!(result.is_err());
1021        assert!(
1022            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1023            "Error should mention worktree private_files setting"
1024        );
1025
1026        // Test reading excluded file in worktree1 should fail
1027        let result = cx
1028            .update(|cx| {
1029                let input = ReadFileToolInput {
1030                    path: "worktree1/tests/fixture.sql".to_string(),
1031                    start_line: None,
1032                    end_line: None,
1033                };
1034                tool.clone().run(
1035                    ToolInput::resolved(input),
1036                    ToolCallEventStream::test().0,
1037                    cx,
1038                )
1039            })
1040            .await;
1041
1042        assert!(result.is_err());
1043        assert!(
1044            error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1045            "Error should mention worktree file_scan_exclusions setting"
1046        );
1047
1048        // Test reading allowed files in worktree2
1049        let result = cx
1050            .update(|cx| {
1051                let input = ReadFileToolInput {
1052                    path: "worktree2/lib/public.js".to_string(),
1053                    start_line: None,
1054                    end_line: None,
1055                };
1056                tool.clone().run(
1057                    ToolInput::resolved(input),
1058                    ToolCallEventStream::test().0,
1059                    cx,
1060                )
1061            })
1062            .await
1063            .unwrap();
1064
1065        assert_eq!(
1066            result,
1067            "export function greet() { return 'Hello from worktree2'; }".into()
1068        );
1069
1070        // Test reading private file in worktree2 should fail
1071        let result = cx
1072            .update(|cx| {
1073                let input = ReadFileToolInput {
1074                    path: "worktree2/lib/private.js".to_string(),
1075                    start_line: None,
1076                    end_line: None,
1077                };
1078                tool.clone().run(
1079                    ToolInput::resolved(input),
1080                    ToolCallEventStream::test().0,
1081                    cx,
1082                )
1083            })
1084            .await;
1085
1086        assert!(result.is_err());
1087        assert!(
1088            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1089            "Error should mention worktree private_files setting"
1090        );
1091
1092        // Test reading excluded file in worktree2 should fail
1093        let result = cx
1094            .update(|cx| {
1095                let input = ReadFileToolInput {
1096                    path: "worktree2/docs/internal.md".to_string(),
1097                    start_line: None,
1098                    end_line: None,
1099                };
1100                tool.clone().run(
1101                    ToolInput::resolved(input),
1102                    ToolCallEventStream::test().0,
1103                    cx,
1104                )
1105            })
1106            .await;
1107
1108        assert!(result.is_err());
1109        assert!(
1110            error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1111            "Error should mention worktree file_scan_exclusions setting"
1112        );
1113
1114        // Test that files allowed in one worktree but not in another are handled correctly
1115        // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1116        let result = cx
1117            .update(|cx| {
1118                let input = ReadFileToolInput {
1119                    path: "worktree1/src/config.toml".to_string(),
1120                    start_line: None,
1121                    end_line: None,
1122                };
1123                tool.clone().run(
1124                    ToolInput::resolved(input),
1125                    ToolCallEventStream::test().0,
1126                    cx,
1127                )
1128            })
1129            .await;
1130
1131        assert!(result.is_err());
1132        assert!(
1133            error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1134            "Config.toml should be blocked by worktree1's private_files setting"
1135        );
1136    }
1137
1138    #[gpui::test]
1139    async fn test_read_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
1140        init_test(cx);
1141
1142        let fs = FakeFs::new(cx.executor());
1143        fs.insert_tree(
1144            path!("/root"),
1145            json!({
1146                "project": {
1147                    "src": { "main.rs": "fn main() {}" }
1148                },
1149                "external": {
1150                    "secret.txt": "SECRET_KEY=abc123"
1151                }
1152            }),
1153        )
1154        .await;
1155
1156        fs.create_symlink(
1157            path!("/root/project/secret_link.txt").as_ref(),
1158            PathBuf::from("../external/secret.txt"),
1159        )
1160        .await
1161        .unwrap();
1162
1163        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1164        cx.executor().run_until_parked();
1165
1166        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1167        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1168
1169        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1170        let task = cx.update(|cx| {
1171            tool.clone().run(
1172                ToolInput::resolved(ReadFileToolInput {
1173                    path: "project/secret_link.txt".to_string(),
1174                    start_line: None,
1175                    end_line: None,
1176                }),
1177                event_stream,
1178                cx,
1179            )
1180        });
1181
1182        let auth = event_rx.expect_authorization().await;
1183        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
1184        assert!(
1185            title.contains("points outside the project"),
1186            "title: {title}"
1187        );
1188
1189        auth.response
1190            .send(acp_thread::SelectedPermissionOutcome::new(
1191                acp::PermissionOptionId::new("allow"),
1192                acp::PermissionOptionKind::AllowOnce,
1193            ))
1194            .unwrap();
1195
1196        let result = task.await;
1197        assert!(result.is_ok(), "should succeed after approval: {result:?}");
1198    }
1199
1200    #[gpui::test]
1201    async fn test_read_file_symlink_escape_denied(cx: &mut TestAppContext) {
1202        init_test(cx);
1203
1204        let fs = FakeFs::new(cx.executor());
1205        fs.insert_tree(
1206            path!("/root"),
1207            json!({
1208                "project": {
1209                    "src": { "main.rs": "fn main() {}" }
1210                },
1211                "external": {
1212                    "secret.txt": "SECRET_KEY=abc123"
1213                }
1214            }),
1215        )
1216        .await;
1217
1218        fs.create_symlink(
1219            path!("/root/project/secret_link.txt").as_ref(),
1220            PathBuf::from("../external/secret.txt"),
1221        )
1222        .await
1223        .unwrap();
1224
1225        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1226        cx.executor().run_until_parked();
1227
1228        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1229        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1230
1231        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1232        let task = cx.update(|cx| {
1233            tool.clone().run(
1234                ToolInput::resolved(ReadFileToolInput {
1235                    path: "project/secret_link.txt".to_string(),
1236                    start_line: None,
1237                    end_line: None,
1238                }),
1239                event_stream,
1240                cx,
1241            )
1242        });
1243
1244        let auth = event_rx.expect_authorization().await;
1245        drop(auth);
1246
1247        let result = task.await;
1248        assert!(
1249            result.is_err(),
1250            "Tool should fail when authorization is denied"
1251        );
1252    }
1253
1254    #[gpui::test]
1255    async fn test_read_file_symlink_escape_private_path_no_authorization(cx: &mut TestAppContext) {
1256        init_test(cx);
1257
1258        let fs = FakeFs::new(cx.executor());
1259        fs.insert_tree(
1260            path!("/root"),
1261            json!({
1262                "project": {
1263                    "src": { "main.rs": "fn main() {}" }
1264                },
1265                "external": {
1266                    "secret.txt": "SECRET_KEY=abc123"
1267                }
1268            }),
1269        )
1270        .await;
1271
1272        fs.create_symlink(
1273            path!("/root/project/secret_link.txt").as_ref(),
1274            PathBuf::from("../external/secret.txt"),
1275        )
1276        .await
1277        .unwrap();
1278
1279        cx.update(|cx| {
1280            settings::SettingsStore::update_global(cx, |store, cx| {
1281                store.update_user_settings(cx, |settings| {
1282                    settings.project.worktree.private_files =
1283                        Some(vec!["**/secret_link.txt".to_string()].into());
1284                });
1285            });
1286        });
1287
1288        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1289        cx.executor().run_until_parked();
1290
1291        let action_log = cx.new(|_| ActionLog::new(project.clone()));
1292        let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true));
1293
1294        let (event_stream, mut event_rx) = ToolCallEventStream::test();
1295        let result = cx
1296            .update(|cx| {
1297                tool.clone().run(
1298                    ToolInput::resolved(ReadFileToolInput {
1299                        path: "project/secret_link.txt".to_string(),
1300                        start_line: None,
1301                        end_line: None,
1302                    }),
1303                    event_stream,
1304                    cx,
1305                )
1306            })
1307            .await;
1308
1309        assert!(
1310            result.is_err(),
1311            "Expected read_file to fail on private path"
1312        );
1313        let error = error_text(result.unwrap_err());
1314        assert!(
1315            error.contains("private_files"),
1316            "Expected private-files validation error, got: {error}"
1317        );
1318
1319        let event = event_rx.try_recv();
1320        assert!(
1321            !matches!(
1322                event,
1323                Ok(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(_)))
1324            ),
1325            "No authorization should be requested when validation fails before read",
1326        );
1327    }
1328}