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