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