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