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