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