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