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