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 path_buf = std::path::PathBuf::from(&file_path);
265 let buffer_content =
266 outline::get_buffer_content_or_outline(buffer.clone(), Some(&path_buf), cx)
267 .await?;
268
269 action_log.update(cx, |log, cx| {
270 log.buffer_read(buffer, cx);
271 })?;
272
273 if buffer_content.is_outline {
274 Ok(formatdoc! {"
275 This file was too big to read all at once.
276
277 {}
278
279 Using the line numbers in this outline, you can call this tool again
280 while specifying the start_line and end_line fields to see the
281 implementations of symbols in the outline.
282
283 Alternatively, you can fall back to the `grep` tool (if available)
284 to search the file for specific content.", buffer_content.text
285 }
286 .into())
287 } else {
288 Ok(buffer_content.text.into())
289 }
290 }
291 })
292 .into()
293 }
294}
295
296#[cfg(test)]
297mod test {
298 use super::*;
299 use gpui::{AppContext, TestAppContext, UpdateGlobal};
300 use language::{Language, LanguageConfig, LanguageMatcher};
301 use language_model::fake_provider::FakeLanguageModel;
302 use project::{FakeFs, Project};
303 use serde_json::json;
304 use settings::SettingsStore;
305 use util::path;
306
307 #[gpui::test]
308 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
309 init_test(cx);
310
311 let fs = FakeFs::new(cx.executor());
312 fs.insert_tree(path!("/root"), json!({})).await;
313 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
314 let action_log = cx.new(|_| ActionLog::new(project.clone()));
315 let model = Arc::new(FakeLanguageModel::default());
316 let result = cx
317 .update(|cx| {
318 let input = json!({
319 "path": "root/nonexistent_file.txt"
320 });
321 Arc::new(ReadFileTool)
322 .run(
323 input,
324 Arc::default(),
325 project.clone(),
326 action_log,
327 model,
328 None,
329 cx,
330 )
331 .output
332 })
333 .await;
334 assert_eq!(
335 result.unwrap_err().to_string(),
336 "root/nonexistent_file.txt not found"
337 );
338 }
339
340 #[gpui::test]
341 async fn test_read_small_file(cx: &mut TestAppContext) {
342 init_test(cx);
343
344 let fs = FakeFs::new(cx.executor());
345 fs.insert_tree(
346 path!("/root"),
347 json!({
348 "small_file.txt": "This is a small file content"
349 }),
350 )
351 .await;
352 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
353 let action_log = cx.new(|_| ActionLog::new(project.clone()));
354 let model = Arc::new(FakeLanguageModel::default());
355 let result = cx
356 .update(|cx| {
357 let input = json!({
358 "path": "root/small_file.txt"
359 });
360 Arc::new(ReadFileTool)
361 .run(
362 input,
363 Arc::default(),
364 project.clone(),
365 action_log,
366 model,
367 None,
368 cx,
369 )
370 .output
371 })
372 .await;
373 assert_eq!(
374 result.unwrap().content.as_str(),
375 Some("This is a small file content")
376 );
377 }
378
379 #[gpui::test]
380 async fn test_read_large_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 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
388 }),
389 )
390 .await;
391 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
392 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
393 language_registry.add(Arc::new(rust_lang()));
394 let action_log = cx.new(|_| ActionLog::new(project.clone()));
395 let model = Arc::new(FakeLanguageModel::default());
396
397 let result = cx
398 .update(|cx| {
399 let input = json!({
400 "path": "root/large_file.rs"
401 });
402 Arc::new(ReadFileTool)
403 .run(
404 input,
405 Arc::default(),
406 project.clone(),
407 action_log.clone(),
408 model.clone(),
409 None,
410 cx,
411 )
412 .output
413 })
414 .await;
415 let content = result.unwrap();
416 let content = content.as_str().unwrap();
417 assert_eq!(
418 content.lines().skip(4).take(6).collect::<Vec<_>>(),
419 vec![
420 "struct Test0 [L1-4]",
421 " a [L2]",
422 " b [L3]",
423 "struct Test1 [L5-8]",
424 " a [L6]",
425 " b [L7]",
426 ]
427 );
428
429 let result = cx
430 .update(|cx| {
431 let input = json!({
432 "path": "root/large_file.rs",
433 "offset": 1
434 });
435 Arc::new(ReadFileTool)
436 .run(
437 input,
438 Arc::default(),
439 project.clone(),
440 action_log,
441 model,
442 None,
443 cx,
444 )
445 .output
446 })
447 .await;
448 let content = result.unwrap();
449 let expected_content = (0..1000)
450 .flat_map(|i| {
451 vec![
452 format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
453 format!(" a [L{}]", i * 4 + 2),
454 format!(" b [L{}]", i * 4 + 3),
455 ]
456 })
457 .collect::<Vec<_>>();
458 pretty_assertions::assert_eq!(
459 content
460 .as_str()
461 .unwrap()
462 .lines()
463 .skip(4)
464 .take(expected_content.len())
465 .collect::<Vec<_>>(),
466 expected_content
467 );
468 }
469
470 #[gpui::test]
471 async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
472 init_test(cx);
473
474 let fs = FakeFs::new(cx.executor());
475 fs.insert_tree(
476 path!("/root"),
477 json!({
478 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
479 }),
480 )
481 .await;
482 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
483 let action_log = cx.new(|_| ActionLog::new(project.clone()));
484 let model = Arc::new(FakeLanguageModel::default());
485 let result = cx
486 .update(|cx| {
487 let input = json!({
488 "path": "root/multiline.txt",
489 "start_line": 2,
490 "end_line": 4
491 });
492 Arc::new(ReadFileTool)
493 .run(
494 input,
495 Arc::default(),
496 project.clone(),
497 action_log,
498 model,
499 None,
500 cx,
501 )
502 .output
503 })
504 .await;
505 assert_eq!(
506 result.unwrap().content.as_str(),
507 Some("Line 2\nLine 3\nLine 4")
508 );
509 }
510
511 #[gpui::test]
512 async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
513 init_test(cx);
514
515 let fs = FakeFs::new(cx.executor());
516 fs.insert_tree(
517 path!("/root"),
518 json!({
519 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
520 }),
521 )
522 .await;
523 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
524 let action_log = cx.new(|_| ActionLog::new(project.clone()));
525 let model = Arc::new(FakeLanguageModel::default());
526
527 // start_line of 0 should be treated as 1
528 let result = cx
529 .update(|cx| {
530 let input = json!({
531 "path": "root/multiline.txt",
532 "start_line": 0,
533 "end_line": 2
534 });
535 Arc::new(ReadFileTool)
536 .run(
537 input,
538 Arc::default(),
539 project.clone(),
540 action_log.clone(),
541 model.clone(),
542 None,
543 cx,
544 )
545 .output
546 })
547 .await;
548 assert_eq!(result.unwrap().content.as_str(), Some("Line 1\nLine 2"));
549
550 // end_line of 0 should result in at least 1 line
551 let result = cx
552 .update(|cx| {
553 let input = json!({
554 "path": "root/multiline.txt",
555 "start_line": 1,
556 "end_line": 0
557 });
558 Arc::new(ReadFileTool)
559 .run(
560 input,
561 Arc::default(),
562 project.clone(),
563 action_log.clone(),
564 model.clone(),
565 None,
566 cx,
567 )
568 .output
569 })
570 .await;
571 assert_eq!(result.unwrap().content.as_str(), Some("Line 1"));
572
573 // when start_line > end_line, should still return at least 1 line
574 let result = cx
575 .update(|cx| {
576 let input = json!({
577 "path": "root/multiline.txt",
578 "start_line": 3,
579 "end_line": 2
580 });
581 Arc::new(ReadFileTool)
582 .run(
583 input,
584 Arc::default(),
585 project.clone(),
586 action_log,
587 model,
588 None,
589 cx,
590 )
591 .output
592 })
593 .await;
594 assert_eq!(result.unwrap().content.as_str(), Some("Line 3"));
595 }
596
597 fn init_test(cx: &mut TestAppContext) {
598 cx.update(|cx| {
599 let settings_store = SettingsStore::test(cx);
600 cx.set_global(settings_store);
601 SettingsStore::load_registered_settings(cx);
602
603 language::init(cx);
604 Project::init_settings(cx);
605 });
606 }
607
608 fn rust_lang() -> Language {
609 Language::new(
610 LanguageConfig {
611 name: "Rust".into(),
612 matcher: LanguageMatcher {
613 path_suffixes: vec!["rs".to_string()],
614 ..Default::default()
615 },
616 ..Default::default()
617 },
618 Some(tree_sitter_rust::LANGUAGE.into()),
619 )
620 .with_outline_query(
621 r#"
622 (line_comment) @annotation
623
624 (struct_item
625 "struct" @context
626 name: (_) @name) @item
627 (enum_item
628 "enum" @context
629 name: (_) @name) @item
630 (enum_variant
631 name: (_) @name) @item
632 (field_declaration
633 name: (_) @name) @item
634 (impl_item
635 "impl" @context
636 trait: (_)? @name
637 "for"? @context
638 type: (_) @name
639 body: (_ "{" (_)* "}")) @item
640 (function_item
641 "fn" @context
642 name: (_) @name) @item
643 (mod_item
644 "mod" @context
645 name: (_) @name) @item
646 "#,
647 )
648 .unwrap()
649 }
650
651 #[gpui::test]
652 async fn test_read_file_security(cx: &mut TestAppContext) {
653 init_test(cx);
654
655 let fs = FakeFs::new(cx.executor());
656
657 fs.insert_tree(
658 path!("/"),
659 json!({
660 "project_root": {
661 "allowed_file.txt": "This file is in the project",
662 ".mysecrets": "SECRET_KEY=abc123",
663 ".secretdir": {
664 "config": "special configuration"
665 },
666 ".mymetadata": "custom metadata",
667 "subdir": {
668 "normal_file.txt": "Normal file content",
669 "special.privatekey": "private key content",
670 "data.mysensitive": "sensitive data"
671 }
672 },
673 "outside_project": {
674 "sensitive_file.txt": "This file is outside the project"
675 }
676 }),
677 )
678 .await;
679
680 cx.update(|cx| {
681 use gpui::UpdateGlobal;
682 use settings::SettingsStore;
683 SettingsStore::update_global(cx, |store, cx| {
684 store.update_user_settings(cx, |settings| {
685 settings.project.worktree.file_scan_exclusions = Some(vec![
686 "**/.secretdir".to_string(),
687 "**/.mymetadata".to_string(),
688 ]);
689 settings.project.worktree.private_files = Some(
690 vec![
691 "**/.mysecrets".to_string(),
692 "**/*.privatekey".to_string(),
693 "**/*.mysensitive".to_string(),
694 ]
695 .into(),
696 );
697 });
698 });
699 });
700
701 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
702 let action_log = cx.new(|_| ActionLog::new(project.clone()));
703 let model = Arc::new(FakeLanguageModel::default());
704
705 // Reading a file outside the project worktree should fail
706 let result = cx
707 .update(|cx| {
708 let input = json!({
709 "path": "/outside_project/sensitive_file.txt"
710 });
711 Arc::new(ReadFileTool)
712 .run(
713 input,
714 Arc::default(),
715 project.clone(),
716 action_log.clone(),
717 model.clone(),
718 None,
719 cx,
720 )
721 .output
722 })
723 .await;
724 assert!(
725 result.is_err(),
726 "read_file_tool should error when attempting to read an absolute path outside a worktree"
727 );
728
729 // Reading a file within the project should succeed
730 let result = cx
731 .update(|cx| {
732 let input = json!({
733 "path": "project_root/allowed_file.txt"
734 });
735 Arc::new(ReadFileTool)
736 .run(
737 input,
738 Arc::default(),
739 project.clone(),
740 action_log.clone(),
741 model.clone(),
742 None,
743 cx,
744 )
745 .output
746 })
747 .await;
748 assert!(
749 result.is_ok(),
750 "read_file_tool should be able to read files inside worktrees"
751 );
752
753 // Reading files that match file_scan_exclusions should fail
754 let result = cx
755 .update(|cx| {
756 let input = json!({
757 "path": "project_root/.secretdir/config"
758 });
759 Arc::new(ReadFileTool)
760 .run(
761 input,
762 Arc::default(),
763 project.clone(),
764 action_log.clone(),
765 model.clone(),
766 None,
767 cx,
768 )
769 .output
770 })
771 .await;
772 assert!(
773 result.is_err(),
774 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
775 );
776
777 let result = cx
778 .update(|cx| {
779 let input = json!({
780 "path": "project_root/.mymetadata"
781 });
782 Arc::new(ReadFileTool)
783 .run(
784 input,
785 Arc::default(),
786 project.clone(),
787 action_log.clone(),
788 model.clone(),
789 None,
790 cx,
791 )
792 .output
793 })
794 .await;
795 assert!(
796 result.is_err(),
797 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
798 );
799
800 // Reading private files should fail
801 let result = cx
802 .update(|cx| {
803 let input = json!({
804 "path": "project_root/.mysecrets"
805 });
806 Arc::new(ReadFileTool)
807 .run(
808 input,
809 Arc::default(),
810 project.clone(),
811 action_log.clone(),
812 model.clone(),
813 None,
814 cx,
815 )
816 .output
817 })
818 .await;
819 assert!(
820 result.is_err(),
821 "read_file_tool should error when attempting to read .mysecrets (private_files)"
822 );
823
824 let result = cx
825 .update(|cx| {
826 let input = json!({
827 "path": "project_root/subdir/special.privatekey"
828 });
829 Arc::new(ReadFileTool)
830 .run(
831 input,
832 Arc::default(),
833 project.clone(),
834 action_log.clone(),
835 model.clone(),
836 None,
837 cx,
838 )
839 .output
840 })
841 .await;
842 assert!(
843 result.is_err(),
844 "read_file_tool should error when attempting to read .privatekey files (private_files)"
845 );
846
847 let result = cx
848 .update(|cx| {
849 let input = json!({
850 "path": "project_root/subdir/data.mysensitive"
851 });
852 Arc::new(ReadFileTool)
853 .run(
854 input,
855 Arc::default(),
856 project.clone(),
857 action_log.clone(),
858 model.clone(),
859 None,
860 cx,
861 )
862 .output
863 })
864 .await;
865 assert!(
866 result.is_err(),
867 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
868 );
869
870 // Reading a normal file should still work, even with private_files configured
871 let result = cx
872 .update(|cx| {
873 let input = json!({
874 "path": "project_root/subdir/normal_file.txt"
875 });
876 Arc::new(ReadFileTool)
877 .run(
878 input,
879 Arc::default(),
880 project.clone(),
881 action_log.clone(),
882 model.clone(),
883 None,
884 cx,
885 )
886 .output
887 })
888 .await;
889 assert!(result.is_ok(), "Should be able to read normal files");
890 assert_eq!(
891 result.unwrap().content.as_str().unwrap(),
892 "Normal file content"
893 );
894
895 // Path traversal attempts with .. should fail
896 let result = cx
897 .update(|cx| {
898 let input = json!({
899 "path": "project_root/../outside_project/sensitive_file.txt"
900 });
901 Arc::new(ReadFileTool)
902 .run(
903 input,
904 Arc::default(),
905 project.clone(),
906 action_log.clone(),
907 model.clone(),
908 None,
909 cx,
910 )
911 .output
912 })
913 .await;
914 assert!(
915 result.is_err(),
916 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
917 );
918 }
919
920 #[gpui::test]
921 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
922 init_test(cx);
923
924 let fs = FakeFs::new(cx.executor());
925
926 // Create first worktree with its own private_files setting
927 fs.insert_tree(
928 path!("/worktree1"),
929 json!({
930 "src": {
931 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
932 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
933 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
934 },
935 "tests": {
936 "test.rs": "mod tests { fn test_it() {} }",
937 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
938 },
939 ".zed": {
940 "settings.json": r#"{
941 "file_scan_exclusions": ["**/fixture.*"],
942 "private_files": ["**/secret.rs", "**/config.toml"]
943 }"#
944 }
945 }),
946 )
947 .await;
948
949 // Create second worktree with different private_files setting
950 fs.insert_tree(
951 path!("/worktree2"),
952 json!({
953 "lib": {
954 "public.js": "export function greet() { return 'Hello from worktree2'; }",
955 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
956 "data.json": "{\"api_key\": \"json_secret_key\"}"
957 },
958 "docs": {
959 "README.md": "# Public Documentation",
960 "internal.md": "# Internal Secrets and Configuration"
961 },
962 ".zed": {
963 "settings.json": r#"{
964 "file_scan_exclusions": ["**/internal.*"],
965 "private_files": ["**/private.js", "**/data.json"]
966 }"#
967 }
968 }),
969 )
970 .await;
971
972 // Set global settings
973 cx.update(|cx| {
974 SettingsStore::update_global(cx, |store, cx| {
975 store.update_user_settings(cx, |settings| {
976 settings.project.worktree.file_scan_exclusions =
977 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
978 settings.project.worktree.private_files =
979 Some(vec!["**/.env".to_string()].into());
980 });
981 });
982 });
983
984 let project = Project::test(
985 fs.clone(),
986 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
987 cx,
988 )
989 .await;
990
991 let action_log = cx.new(|_| ActionLog::new(project.clone()));
992 let model = Arc::new(FakeLanguageModel::default());
993 let tool = Arc::new(ReadFileTool);
994
995 // Test reading allowed files in worktree1
996 let input = json!({
997 "path": "worktree1/src/main.rs"
998 });
999
1000 let result = cx
1001 .update(|cx| {
1002 tool.clone().run(
1003 input,
1004 Arc::default(),
1005 project.clone(),
1006 action_log.clone(),
1007 model.clone(),
1008 None,
1009 cx,
1010 )
1011 })
1012 .output
1013 .await
1014 .unwrap();
1015
1016 assert_eq!(
1017 result.content.as_str().unwrap(),
1018 "fn main() { println!(\"Hello from worktree1\"); }"
1019 );
1020
1021 // Test reading private file in worktree1 should fail
1022 let input = json!({
1023 "path": "worktree1/src/secret.rs"
1024 });
1025
1026 let result = cx
1027 .update(|cx| {
1028 tool.clone().run(
1029 input,
1030 Arc::default(),
1031 project.clone(),
1032 action_log.clone(),
1033 model.clone(),
1034 None,
1035 cx,
1036 )
1037 })
1038 .output
1039 .await;
1040
1041 assert!(result.is_err());
1042 assert!(
1043 result
1044 .unwrap_err()
1045 .to_string()
1046 .contains("worktree `private_files` setting"),
1047 "Error should mention worktree private_files setting"
1048 );
1049
1050 // Test reading excluded file in worktree1 should fail
1051 let input = json!({
1052 "path": "worktree1/tests/fixture.sql"
1053 });
1054
1055 let result = cx
1056 .update(|cx| {
1057 tool.clone().run(
1058 input,
1059 Arc::default(),
1060 project.clone(),
1061 action_log.clone(),
1062 model.clone(),
1063 None,
1064 cx,
1065 )
1066 })
1067 .output
1068 .await;
1069
1070 assert!(result.is_err());
1071 assert!(
1072 result
1073 .unwrap_err()
1074 .to_string()
1075 .contains("worktree `file_scan_exclusions` setting"),
1076 "Error should mention worktree file_scan_exclusions setting"
1077 );
1078
1079 // Test reading allowed files in worktree2
1080 let input = json!({
1081 "path": "worktree2/lib/public.js"
1082 });
1083
1084 let result = cx
1085 .update(|cx| {
1086 tool.clone().run(
1087 input,
1088 Arc::default(),
1089 project.clone(),
1090 action_log.clone(),
1091 model.clone(),
1092 None,
1093 cx,
1094 )
1095 })
1096 .output
1097 .await
1098 .unwrap();
1099
1100 assert_eq!(
1101 result.content.as_str().unwrap(),
1102 "export function greet() { return 'Hello from worktree2'; }"
1103 );
1104
1105 // Test reading private file in worktree2 should fail
1106 let input = json!({
1107 "path": "worktree2/lib/private.js"
1108 });
1109
1110 let result = cx
1111 .update(|cx| {
1112 tool.clone().run(
1113 input,
1114 Arc::default(),
1115 project.clone(),
1116 action_log.clone(),
1117 model.clone(),
1118 None,
1119 cx,
1120 )
1121 })
1122 .output
1123 .await;
1124
1125 assert!(result.is_err());
1126 assert!(
1127 result
1128 .unwrap_err()
1129 .to_string()
1130 .contains("worktree `private_files` setting"),
1131 "Error should mention worktree private_files setting"
1132 );
1133
1134 // Test reading excluded file in worktree2 should fail
1135 let input = json!({
1136 "path": "worktree2/docs/internal.md"
1137 });
1138
1139 let result = cx
1140 .update(|cx| {
1141 tool.clone().run(
1142 input,
1143 Arc::default(),
1144 project.clone(),
1145 action_log.clone(),
1146 model.clone(),
1147 None,
1148 cx,
1149 )
1150 })
1151 .output
1152 .await;
1153
1154 assert!(result.is_err());
1155 assert!(
1156 result
1157 .unwrap_err()
1158 .to_string()
1159 .contains("worktree `file_scan_exclusions` setting"),
1160 "Error should mention worktree file_scan_exclusions setting"
1161 );
1162
1163 // Test that files allowed in one worktree but not in another are handled correctly
1164 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1165 let input = json!({
1166 "path": "worktree1/src/config.toml"
1167 });
1168
1169 let result = cx
1170 .update(|cx| {
1171 tool.clone().run(
1172 input,
1173 Arc::default(),
1174 project.clone(),
1175 action_log.clone(),
1176 model.clone(),
1177 None,
1178 cx,
1179 )
1180 })
1181 .output
1182 .await;
1183
1184 assert!(result.is_err());
1185 assert!(
1186 result
1187 .unwrap_err()
1188 .to_string()
1189 .contains("worktree `private_files` setting"),
1190 "Config.toml should be blocked by worktree1's private_files setting"
1191 );
1192 }
1193}