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