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