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