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