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