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
16use crate::{AgentTool, Thread, ToolCallEventStream, outline};
17
18/// Reads the content of the given file in the project.
19///
20/// - Never attempt to read a path that hasn't been previously mentioned.
21/// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content.
22/// This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line.
23/// Do NOT retry reading the same file without line numbers if you receive an outline.
24/// - This tool supports reading image files. Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF.
25/// Image files are returned as visual content that you can analyze directly.
26#[derive(Debug, Serialize, Deserialize, JsonSchema)]
27pub struct ReadFileToolInput {
28 /// The relative path of the file to read.
29 ///
30 /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
31 ///
32 /// <example>
33 /// If the project has the following root directories:
34 ///
35 /// - /a/b/directory1
36 /// - /c/d/directory2
37 ///
38 /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
39 /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
40 /// </example>
41 pub path: String,
42 /// Optional line number to start reading on (1-based index)
43 #[serde(default)]
44 pub start_line: Option<u32>,
45 /// Optional line number to end reading on (1-based index, inclusive)
46 #[serde(default)]
47 pub end_line: Option<u32>,
48}
49
50pub struct ReadFileTool {
51 thread: WeakEntity<Thread>,
52 project: Entity<Project>,
53 action_log: Entity<ActionLog>,
54}
55
56impl ReadFileTool {
57 pub fn new(
58 thread: WeakEntity<Thread>,
59 project: Entity<Project>,
60 action_log: Entity<ActionLog>,
61 ) -> Self {
62 Self {
63 thread,
64 project,
65 action_log,
66 }
67 }
68
69 pub fn with_thread(&self, new_thread: WeakEntity<Thread>) -> Self {
70 Self {
71 thread: new_thread,
72 project: self.project.clone(),
73 action_log: self.action_log.clone(),
74 }
75 }
76}
77
78impl AgentTool for ReadFileTool {
79 type Input = ReadFileToolInput;
80 type Output = LanguageModelToolResultContent;
81
82 fn name() -> &'static str {
83 "read_file"
84 }
85
86 fn kind() -> acp::ToolKind {
87 acp::ToolKind::Read
88 }
89
90 fn initial_title(
91 &self,
92 input: Result<Self::Input, serde_json::Value>,
93 cx: &mut App,
94 ) -> SharedString {
95 if let Ok(input) = input
96 && let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx)
97 && let Some(path) = self
98 .project
99 .read(cx)
100 .short_full_path_for_project_path(&project_path, cx)
101 {
102 match (input.start_line, input.end_line) {
103 (Some(start), Some(end)) => {
104 format!("Read file `{path}` (lines {}-{})", start, end,)
105 }
106 (Some(start), None) => {
107 format!("Read file `{path}` (from line {})", start)
108 }
109 _ => format!("Read file `{path}`"),
110 }
111 .into()
112 } else {
113 "Read file".into()
114 }
115 }
116
117 fn run(
118 self: Arc<Self>,
119 input: Self::Input,
120 event_stream: ToolCallEventStream,
121 cx: &mut App,
122 ) -> Task<Result<LanguageModelToolResultContent>> {
123 let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
124 return Task::ready(Err(anyhow!("Path {} not found in project", &input.path)));
125 };
126 let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
127 return Task::ready(Err(anyhow!(
128 "Failed to convert {} to absolute path",
129 &input.path
130 )));
131 };
132
133 // Error out if this path is either excluded or private in global settings
134 let global_settings = WorktreeSettings::get_global(cx);
135 if global_settings.is_path_excluded(&project_path.path) {
136 return Task::ready(Err(anyhow!(
137 "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
138 &input.path
139 )));
140 }
141
142 if global_settings.is_path_private(&project_path.path) {
143 return Task::ready(Err(anyhow!(
144 "Cannot read file because its path matches the global `private_files` setting: {}",
145 &input.path
146 )));
147 }
148
149 // Error out if this path is either excluded or private in worktree settings
150 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
151 if worktree_settings.is_path_excluded(&project_path.path) {
152 return Task::ready(Err(anyhow!(
153 "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
154 &input.path
155 )));
156 }
157
158 if worktree_settings.is_path_private(&project_path.path) {
159 return Task::ready(Err(anyhow!(
160 "Cannot read file because its path matches the worktree `private_files` setting: {}",
161 &input.path
162 )));
163 }
164
165 let file_path = input.path.clone();
166
167 event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![
168 acp::ToolCallLocation::new(&abs_path)
169 .line(input.start_line.map(|line| line.saturating_sub(1))),
170 ]));
171
172 if image_store::is_image_file(&self.project, &project_path, cx) {
173 return cx.spawn(async move |cx| {
174 let image_entity: Entity<ImageItem> = cx
175 .update(|cx| {
176 self.project.update(cx, |project, cx| {
177 project.open_image(project_path.clone(), cx)
178 })
179 })
180 .await?;
181
182 let image =
183 image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image));
184
185 let language_model_image = cx
186 .update(|cx| LanguageModelImage::from_image(image, cx))
187 .await
188 .context("processing image")?;
189
190 event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
191 acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
192 acp::ImageContent::new(language_model_image.source.clone(), "image/png"),
193 ))),
194 ]));
195
196 Ok(language_model_image.into())
197 });
198 }
199
200 let project = self.project.clone();
201 let action_log = self.action_log.clone();
202
203 cx.spawn(async move |cx| {
204 let open_buffer_task = cx.update(|cx| {
205 project.update(cx, |project, cx| {
206 project.open_buffer(project_path.clone(), cx)
207 })
208 });
209
210 let buffer = futures::select! {
211 result = open_buffer_task.fuse() => result?,
212 _ = event_stream.cancelled_by_user().fuse() => {
213 anyhow::bail!("File read cancelled by user");
214 }
215 };
216 if buffer.read_with(cx, |buffer, _| {
217 buffer
218 .file()
219 .as_ref()
220 .is_none_or(|file| !file.disk_state().exists())
221 }) {
222 anyhow::bail!("{file_path} not found");
223 }
224
225 // Record the file read time and mtime
226 if let Some(mtime) = buffer.read_with(cx, |buffer, _| {
227 buffer.file().and_then(|file| file.disk_state().mtime())
228 }) {
229 self.thread
230 .update(cx, |thread, _| {
231 thread.file_read_times.insert(abs_path.to_path_buf(), mtime);
232 })
233 .ok();
234 }
235
236 let mut anchor = None;
237
238 // Check if specific line ranges are provided
239 let result = if input.start_line.is_some() || input.end_line.is_some() {
240 let result = buffer.read_with(cx, |buffer, _cx| {
241 // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
242 let start = input.start_line.unwrap_or(1).max(1);
243 let start_row = start - 1;
244 if start_row <= buffer.max_point().row {
245 let column = buffer.line_indent_for_row(start_row).raw_len();
246 anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
247 }
248
249 let mut end_row = input.end_line.unwrap_or(u32::MAX);
250 if end_row <= start_row {
251 end_row = start_row + 1; // read at least one lines
252 }
253 let start = buffer.anchor_before(Point::new(start_row, 0));
254 let end = buffer.anchor_before(Point::new(end_row, 0));
255 buffer.text_for_range(start..end).collect::<String>()
256 });
257
258 action_log.update(cx, |log, cx| {
259 log.buffer_read(buffer.clone(), cx);
260 });
261
262 Ok(result.into())
263 } else {
264 // No line ranges specified, so check file size to see if it's too big.
265 let buffer_content = outline::get_buffer_content_or_outline(
266 buffer.clone(),
267 Some(&abs_path.to_string_lossy()),
268 cx,
269 )
270 .await?;
271
272 action_log.update(cx, |log, cx| {
273 log.buffer_read(buffer.clone(), cx);
274 });
275
276 if buffer_content.is_outline {
277 Ok(formatdoc! {"
278 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.
279
280 IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline.
281 Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters.
282
283 {}
284
285 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.
286 For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text
287 }
288 .into())
289 } else {
290 Ok(buffer_content.text.into())
291 }
292 };
293
294 project.update(cx, |project, cx| {
295 project.set_agent_location(
296 Some(AgentLocation {
297 buffer: buffer.downgrade(),
298 position: anchor.unwrap_or_else(|| {
299 text::Anchor::min_for_buffer(buffer.read(cx).remote_id())
300 }),
301 }),
302 cx,
303 );
304 if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
305 let markdown = MarkdownCodeBlock {
306 tag: &input.path,
307 text,
308 }
309 .to_string();
310 event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
311 acp::ToolCallContent::Content(acp::Content::new(markdown)),
312 ]));
313 }
314 });
315
316 result
317 })
318 }
319
320 fn rebind_thread(
321 &self,
322 new_thread: WeakEntity<Thread>,
323 ) -> Option<std::sync::Arc<dyn crate::AnyAgentTool>> {
324 Some(self.with_thread(new_thread).erase())
325 }
326}
327
328#[cfg(test)]
329mod test {
330 use super::*;
331 use crate::{ContextServerRegistry, Templates, Thread};
332 use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
333 use language_model::fake_provider::FakeLanguageModel;
334 use project::{FakeFs, Project};
335 use prompt_store::ProjectContext;
336 use serde_json::json;
337 use settings::SettingsStore;
338 use std::sync::Arc;
339 use util::path;
340
341 #[gpui::test]
342 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
343 init_test(cx);
344
345 let fs = FakeFs::new(cx.executor());
346 fs.insert_tree(path!("/root"), json!({})).await;
347 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
348 let action_log = cx.new(|_| ActionLog::new(project.clone()));
349 let context_server_registry =
350 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
351 let model = Arc::new(FakeLanguageModel::default());
352 let thread = cx.new(|cx| {
353 Thread::new(
354 project.clone(),
355 cx.new(|_cx| ProjectContext::default()),
356 context_server_registry,
357 Templates::new(),
358 Some(model),
359 cx,
360 )
361 });
362 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
363 let (event_stream, _) = ToolCallEventStream::test();
364
365 let result = cx
366 .update(|cx| {
367 let input = ReadFileToolInput {
368 path: "root/nonexistent_file.txt".to_string(),
369 start_line: None,
370 end_line: None,
371 };
372 tool.run(input, event_stream, cx)
373 })
374 .await;
375 assert_eq!(
376 result.unwrap_err().to_string(),
377 "root/nonexistent_file.txt not found"
378 );
379 }
380
381 #[gpui::test]
382 async fn test_read_small_file(cx: &mut TestAppContext) {
383 init_test(cx);
384
385 let fs = FakeFs::new(cx.executor());
386 fs.insert_tree(
387 path!("/root"),
388 json!({
389 "small_file.txt": "This is a small file content"
390 }),
391 )
392 .await;
393 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
394 let action_log = cx.new(|_| ActionLog::new(project.clone()));
395 let context_server_registry =
396 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
397 let model = Arc::new(FakeLanguageModel::default());
398 let thread = cx.new(|cx| {
399 Thread::new(
400 project.clone(),
401 cx.new(|_cx| ProjectContext::default()),
402 context_server_registry,
403 Templates::new(),
404 Some(model),
405 cx,
406 )
407 });
408 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
409 let result = cx
410 .update(|cx| {
411 let input = ReadFileToolInput {
412 path: "root/small_file.txt".into(),
413 start_line: None,
414 end_line: None,
415 };
416 tool.run(input, ToolCallEventStream::test().0, cx)
417 })
418 .await;
419 assert_eq!(result.unwrap(), "This is a small file content".into());
420 }
421
422 #[gpui::test]
423 async fn test_read_large_file(cx: &mut TestAppContext) {
424 init_test(cx);
425
426 let fs = FakeFs::new(cx.executor());
427 fs.insert_tree(
428 path!("/root"),
429 json!({
430 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
431 }),
432 )
433 .await;
434 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
435 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
436 language_registry.add(language::rust_lang());
437 let action_log = cx.new(|_| ActionLog::new(project.clone()));
438 let context_server_registry =
439 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
440 let model = Arc::new(FakeLanguageModel::default());
441 let thread = cx.new(|cx| {
442 Thread::new(
443 project.clone(),
444 cx.new(|_cx| ProjectContext::default()),
445 context_server_registry,
446 Templates::new(),
447 Some(model),
448 cx,
449 )
450 });
451 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
452 let result = cx
453 .update(|cx| {
454 let input = ReadFileToolInput {
455 path: "root/large_file.rs".into(),
456 start_line: None,
457 end_line: None,
458 };
459 tool.clone().run(input, ToolCallEventStream::test().0, cx)
460 })
461 .await
462 .unwrap();
463 let content = result.to_str().unwrap();
464
465 assert_eq!(
466 content.lines().skip(7).take(6).collect::<Vec<_>>(),
467 vec![
468 "struct Test0 [L1-4]",
469 " a [L2]",
470 " b [L3]",
471 "struct Test1 [L5-8]",
472 " a [L6]",
473 " b [L7]",
474 ]
475 );
476
477 let result = cx
478 .update(|cx| {
479 let input = ReadFileToolInput {
480 path: "root/large_file.rs".into(),
481 start_line: None,
482 end_line: None,
483 };
484 tool.run(input, ToolCallEventStream::test().0, cx)
485 })
486 .await
487 .unwrap();
488 let content = result.to_str().unwrap();
489 let expected_content = (0..1000)
490 .flat_map(|i| {
491 vec![
492 format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
493 format!(" a [L{}]", i * 4 + 2),
494 format!(" b [L{}]", i * 4 + 3),
495 ]
496 })
497 .collect::<Vec<_>>();
498 pretty_assertions::assert_eq!(
499 content
500 .lines()
501 .skip(7)
502 .take(expected_content.len())
503 .collect::<Vec<_>>(),
504 expected_content
505 );
506 }
507
508 #[gpui::test]
509 async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
510 init_test(cx);
511
512 let fs = FakeFs::new(cx.executor());
513 fs.insert_tree(
514 path!("/root"),
515 json!({
516 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
517 }),
518 )
519 .await;
520 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
521
522 let action_log = cx.new(|_| ActionLog::new(project.clone()));
523 let context_server_registry =
524 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
525 let model = Arc::new(FakeLanguageModel::default());
526 let thread = cx.new(|cx| {
527 Thread::new(
528 project.clone(),
529 cx.new(|_cx| ProjectContext::default()),
530 context_server_registry,
531 Templates::new(),
532 Some(model),
533 cx,
534 )
535 });
536 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
537 let result = cx
538 .update(|cx| {
539 let input = ReadFileToolInput {
540 path: "root/multiline.txt".to_string(),
541 start_line: Some(2),
542 end_line: Some(4),
543 };
544 tool.run(input, ToolCallEventStream::test().0, cx)
545 })
546 .await;
547 assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
548 }
549
550 #[gpui::test]
551 async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
552 init_test(cx);
553
554 let fs = FakeFs::new(cx.executor());
555 fs.insert_tree(
556 path!("/root"),
557 json!({
558 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
559 }),
560 )
561 .await;
562 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
563 let action_log = cx.new(|_| ActionLog::new(project.clone()));
564 let context_server_registry =
565 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
566 let model = Arc::new(FakeLanguageModel::default());
567 let thread = cx.new(|cx| {
568 Thread::new(
569 project.clone(),
570 cx.new(|_cx| ProjectContext::default()),
571 context_server_registry,
572 Templates::new(),
573 Some(model),
574 cx,
575 )
576 });
577 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
578
579 // start_line of 0 should be treated as 1
580 let result = cx
581 .update(|cx| {
582 let input = ReadFileToolInput {
583 path: "root/multiline.txt".to_string(),
584 start_line: Some(0),
585 end_line: Some(2),
586 };
587 tool.clone().run(input, ToolCallEventStream::test().0, cx)
588 })
589 .await;
590 assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
591
592 // end_line of 0 should result in at least 1 line
593 let result = cx
594 .update(|cx| {
595 let input = ReadFileToolInput {
596 path: "root/multiline.txt".to_string(),
597 start_line: Some(1),
598 end_line: Some(0),
599 };
600 tool.clone().run(input, ToolCallEventStream::test().0, cx)
601 })
602 .await;
603 assert_eq!(result.unwrap(), "Line 1\n".into());
604
605 // when start_line > end_line, should still return at least 1 line
606 let result = cx
607 .update(|cx| {
608 let input = ReadFileToolInput {
609 path: "root/multiline.txt".to_string(),
610 start_line: Some(3),
611 end_line: Some(2),
612 };
613 tool.clone().run(input, ToolCallEventStream::test().0, cx)
614 })
615 .await;
616 assert_eq!(result.unwrap(), "Line 3\n".into());
617 }
618
619 fn init_test(cx: &mut TestAppContext) {
620 cx.update(|cx| {
621 let settings_store = SettingsStore::test(cx);
622 cx.set_global(settings_store);
623 });
624 }
625
626 #[gpui::test]
627 async fn test_read_file_security(cx: &mut TestAppContext) {
628 init_test(cx);
629
630 let fs = FakeFs::new(cx.executor());
631
632 fs.insert_tree(
633 path!("/"),
634 json!({
635 "project_root": {
636 "allowed_file.txt": "This file is in the project",
637 ".mysecrets": "SECRET_KEY=abc123",
638 ".secretdir": {
639 "config": "special configuration"
640 },
641 ".mymetadata": "custom metadata",
642 "subdir": {
643 "normal_file.txt": "Normal file content",
644 "special.privatekey": "private key content",
645 "data.mysensitive": "sensitive data"
646 }
647 },
648 "outside_project": {
649 "sensitive_file.txt": "This file is outside the project"
650 }
651 }),
652 )
653 .await;
654
655 cx.update(|cx| {
656 use gpui::UpdateGlobal;
657 use settings::SettingsStore;
658 SettingsStore::update_global(cx, |store, cx| {
659 store.update_user_settings(cx, |settings| {
660 settings.project.worktree.file_scan_exclusions = Some(vec![
661 "**/.secretdir".to_string(),
662 "**/.mymetadata".to_string(),
663 ]);
664 settings.project.worktree.private_files = Some(
665 vec![
666 "**/.mysecrets".to_string(),
667 "**/*.privatekey".to_string(),
668 "**/*.mysensitive".to_string(),
669 ]
670 .into(),
671 );
672 });
673 });
674 });
675
676 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
677 let action_log = cx.new(|_| ActionLog::new(project.clone()));
678 let context_server_registry =
679 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
680 let model = Arc::new(FakeLanguageModel::default());
681 let thread = cx.new(|cx| {
682 Thread::new(
683 project.clone(),
684 cx.new(|_cx| ProjectContext::default()),
685 context_server_registry,
686 Templates::new(),
687 Some(model),
688 cx,
689 )
690 });
691 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
692
693 // Reading a file outside the project worktree should fail
694 let result = cx
695 .update(|cx| {
696 let input = ReadFileToolInput {
697 path: "/outside_project/sensitive_file.txt".to_string(),
698 start_line: None,
699 end_line: None,
700 };
701 tool.clone().run(input, ToolCallEventStream::test().0, cx)
702 })
703 .await;
704 assert!(
705 result.is_err(),
706 "read_file_tool should error when attempting to read an absolute path outside a worktree"
707 );
708
709 // Reading a file within the project should succeed
710 let result = cx
711 .update(|cx| {
712 let input = ReadFileToolInput {
713 path: "project_root/allowed_file.txt".to_string(),
714 start_line: None,
715 end_line: None,
716 };
717 tool.clone().run(input, ToolCallEventStream::test().0, cx)
718 })
719 .await;
720 assert!(
721 result.is_ok(),
722 "read_file_tool should be able to read files inside worktrees"
723 );
724
725 // Reading files that match file_scan_exclusions should fail
726 let result = cx
727 .update(|cx| {
728 let input = ReadFileToolInput {
729 path: "project_root/.secretdir/config".to_string(),
730 start_line: None,
731 end_line: None,
732 };
733 tool.clone().run(input, ToolCallEventStream::test().0, cx)
734 })
735 .await;
736 assert!(
737 result.is_err(),
738 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
739 );
740
741 let result = cx
742 .update(|cx| {
743 let input = ReadFileToolInput {
744 path: "project_root/.mymetadata".to_string(),
745 start_line: None,
746 end_line: None,
747 };
748 tool.clone().run(input, ToolCallEventStream::test().0, cx)
749 })
750 .await;
751 assert!(
752 result.is_err(),
753 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
754 );
755
756 // Reading private files should fail
757 let result = cx
758 .update(|cx| {
759 let input = ReadFileToolInput {
760 path: "project_root/.mysecrets".to_string(),
761 start_line: None,
762 end_line: None,
763 };
764 tool.clone().run(input, ToolCallEventStream::test().0, cx)
765 })
766 .await;
767 assert!(
768 result.is_err(),
769 "read_file_tool should error when attempting to read .mysecrets (private_files)"
770 );
771
772 let result = cx
773 .update(|cx| {
774 let input = ReadFileToolInput {
775 path: "project_root/subdir/special.privatekey".to_string(),
776 start_line: None,
777 end_line: None,
778 };
779 tool.clone().run(input, ToolCallEventStream::test().0, cx)
780 })
781 .await;
782 assert!(
783 result.is_err(),
784 "read_file_tool should error when attempting to read .privatekey files (private_files)"
785 );
786
787 let result = cx
788 .update(|cx| {
789 let input = ReadFileToolInput {
790 path: "project_root/subdir/data.mysensitive".to_string(),
791 start_line: None,
792 end_line: None,
793 };
794 tool.clone().run(input, ToolCallEventStream::test().0, cx)
795 })
796 .await;
797 assert!(
798 result.is_err(),
799 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
800 );
801
802 // Reading a normal file should still work, even with private_files configured
803 let result = cx
804 .update(|cx| {
805 let input = ReadFileToolInput {
806 path: "project_root/subdir/normal_file.txt".to_string(),
807 start_line: None,
808 end_line: None,
809 };
810 tool.clone().run(input, ToolCallEventStream::test().0, cx)
811 })
812 .await;
813 assert!(result.is_ok(), "Should be able to read normal files");
814 assert_eq!(result.unwrap(), "Normal file content".into());
815
816 // Path traversal attempts with .. should fail
817 let result = cx
818 .update(|cx| {
819 let input = ReadFileToolInput {
820 path: "project_root/../outside_project/sensitive_file.txt".to_string(),
821 start_line: None,
822 end_line: None,
823 };
824 tool.run(input, ToolCallEventStream::test().0, cx)
825 })
826 .await;
827 assert!(
828 result.is_err(),
829 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
830 );
831 }
832
833 #[gpui::test]
834 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
835 init_test(cx);
836
837 let fs = FakeFs::new(cx.executor());
838
839 // Create first worktree with its own private_files setting
840 fs.insert_tree(
841 path!("/worktree1"),
842 json!({
843 "src": {
844 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
845 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
846 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
847 },
848 "tests": {
849 "test.rs": "mod tests { fn test_it() {} }",
850 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
851 },
852 ".zed": {
853 "settings.json": r#"{
854 "file_scan_exclusions": ["**/fixture.*"],
855 "private_files": ["**/secret.rs", "**/config.toml"]
856 }"#
857 }
858 }),
859 )
860 .await;
861
862 // Create second worktree with different private_files setting
863 fs.insert_tree(
864 path!("/worktree2"),
865 json!({
866 "lib": {
867 "public.js": "export function greet() { return 'Hello from worktree2'; }",
868 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
869 "data.json": "{\"api_key\": \"json_secret_key\"}"
870 },
871 "docs": {
872 "README.md": "# Public Documentation",
873 "internal.md": "# Internal Secrets and Configuration"
874 },
875 ".zed": {
876 "settings.json": r#"{
877 "file_scan_exclusions": ["**/internal.*"],
878 "private_files": ["**/private.js", "**/data.json"]
879 }"#
880 }
881 }),
882 )
883 .await;
884
885 // Set global settings
886 cx.update(|cx| {
887 SettingsStore::update_global(cx, |store, cx| {
888 store.update_user_settings(cx, |settings| {
889 settings.project.worktree.file_scan_exclusions =
890 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
891 settings.project.worktree.private_files =
892 Some(vec!["**/.env".to_string()].into());
893 });
894 });
895 });
896
897 let project = Project::test(
898 fs.clone(),
899 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
900 cx,
901 )
902 .await;
903
904 let action_log = cx.new(|_| ActionLog::new(project.clone()));
905 let context_server_registry =
906 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
907 let model = Arc::new(FakeLanguageModel::default());
908 let thread = cx.new(|cx| {
909 Thread::new(
910 project.clone(),
911 cx.new(|_cx| ProjectContext::default()),
912 context_server_registry,
913 Templates::new(),
914 Some(model),
915 cx,
916 )
917 });
918 let tool = Arc::new(ReadFileTool::new(
919 thread.downgrade(),
920 project.clone(),
921 action_log.clone(),
922 ));
923
924 // Test reading allowed files in worktree1
925 let result = cx
926 .update(|cx| {
927 let input = ReadFileToolInput {
928 path: "worktree1/src/main.rs".to_string(),
929 start_line: None,
930 end_line: None,
931 };
932 tool.clone().run(input, ToolCallEventStream::test().0, cx)
933 })
934 .await
935 .unwrap();
936
937 assert_eq!(
938 result,
939 "fn main() { println!(\"Hello from worktree1\"); }".into()
940 );
941
942 // Test reading private file in worktree1 should fail
943 let result = cx
944 .update(|cx| {
945 let input = ReadFileToolInput {
946 path: "worktree1/src/secret.rs".to_string(),
947 start_line: None,
948 end_line: None,
949 };
950 tool.clone().run(input, ToolCallEventStream::test().0, cx)
951 })
952 .await;
953
954 assert!(result.is_err());
955 assert!(
956 result
957 .unwrap_err()
958 .to_string()
959 .contains("worktree `private_files` setting"),
960 "Error should mention worktree private_files setting"
961 );
962
963 // Test reading excluded file in worktree1 should fail
964 let result = cx
965 .update(|cx| {
966 let input = ReadFileToolInput {
967 path: "worktree1/tests/fixture.sql".to_string(),
968 start_line: None,
969 end_line: None,
970 };
971 tool.clone().run(input, ToolCallEventStream::test().0, cx)
972 })
973 .await;
974
975 assert!(result.is_err());
976 assert!(
977 result
978 .unwrap_err()
979 .to_string()
980 .contains("worktree `file_scan_exclusions` setting"),
981 "Error should mention worktree file_scan_exclusions setting"
982 );
983
984 // Test reading allowed files in worktree2
985 let result = cx
986 .update(|cx| {
987 let input = ReadFileToolInput {
988 path: "worktree2/lib/public.js".to_string(),
989 start_line: None,
990 end_line: None,
991 };
992 tool.clone().run(input, ToolCallEventStream::test().0, cx)
993 })
994 .await
995 .unwrap();
996
997 assert_eq!(
998 result,
999 "export function greet() { return 'Hello from worktree2'; }".into()
1000 );
1001
1002 // Test reading private file in worktree2 should fail
1003 let result = cx
1004 .update(|cx| {
1005 let input = ReadFileToolInput {
1006 path: "worktree2/lib/private.js".to_string(),
1007 start_line: None,
1008 end_line: None,
1009 };
1010 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1011 })
1012 .await;
1013
1014 assert!(result.is_err());
1015 assert!(
1016 result
1017 .unwrap_err()
1018 .to_string()
1019 .contains("worktree `private_files` setting"),
1020 "Error should mention worktree private_files setting"
1021 );
1022
1023 // Test reading excluded file in worktree2 should fail
1024 let result = cx
1025 .update(|cx| {
1026 let input = ReadFileToolInput {
1027 path: "worktree2/docs/internal.md".to_string(),
1028 start_line: None,
1029 end_line: None,
1030 };
1031 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1032 })
1033 .await;
1034
1035 assert!(result.is_err());
1036 assert!(
1037 result
1038 .unwrap_err()
1039 .to_string()
1040 .contains("worktree `file_scan_exclusions` setting"),
1041 "Error should mention worktree file_scan_exclusions setting"
1042 );
1043
1044 // Test that files allowed in one worktree but not in another are handled correctly
1045 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1046 let result = cx
1047 .update(|cx| {
1048 let input = ReadFileToolInput {
1049 path: "worktree1/src/config.toml".to_string(),
1050 start_line: None,
1051 end_line: None,
1052 };
1053 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1054 })
1055 .await;
1056
1057 assert!(result.is_err());
1058 assert!(
1059 result
1060 .unwrap_err()
1061 .to_string()
1062 .contains("worktree `private_files` setting"),
1063 "Config.toml should be blocked by worktree1's private_files setting"
1064 );
1065 }
1066}