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