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/// - This tool supports reading image files. Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF.
24/// Image files are returned as visual content that you can analyze directly.
25#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26pub struct ReadFileToolInput {
27 /// The relative path of the file to read.
28 ///
29 /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
30 ///
31 /// <example>
32 /// If the project has the following root directories:
33 ///
34 /// - /a/b/directory1
35 /// - /c/d/directory2
36 ///
37 /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
38 /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
39 /// </example>
40 pub path: String,
41 /// Optional line number to start reading on (1-based index)
42 #[serde(default)]
43 pub start_line: Option<u32>,
44 /// Optional line number to end reading on (1-based index, inclusive)
45 #[serde(default)]
46 pub end_line: Option<u32>,
47}
48
49pub struct ReadFileTool {
50 thread: WeakEntity<Thread>,
51 project: Entity<Project>,
52 action_log: Entity<ActionLog>,
53}
54
55impl ReadFileTool {
56 pub fn new(
57 thread: WeakEntity<Thread>,
58 project: Entity<Project>,
59 action_log: Entity<ActionLog>,
60 ) -> Self {
61 Self {
62 thread,
63 project,
64 action_log,
65 }
66 }
67}
68
69impl AgentTool for ReadFileTool {
70 type Input = ReadFileToolInput;
71 type Output = LanguageModelToolResultContent;
72
73 fn name() -> &'static str {
74 "read_file"
75 }
76
77 fn kind() -> acp::ToolKind {
78 acp::ToolKind::Read
79 }
80
81 fn initial_title(
82 &self,
83 input: Result<Self::Input, serde_json::Value>,
84 cx: &mut App,
85 ) -> SharedString {
86 if let Ok(input) = input
87 && let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx)
88 && let Some(path) = self
89 .project
90 .read(cx)
91 .short_full_path_for_project_path(&project_path, cx)
92 {
93 match (input.start_line, input.end_line) {
94 (Some(start), Some(end)) => {
95 format!("Read file `{path}` (lines {}-{})", start, end,)
96 }
97 (Some(start), None) => {
98 format!("Read file `{path}` (from line {})", start)
99 }
100 _ => format!("Read file `{path}`"),
101 }
102 .into()
103 } else {
104 "Read file".into()
105 }
106 }
107
108 fn run(
109 self: Arc<Self>,
110 input: Self::Input,
111 event_stream: ToolCallEventStream,
112 cx: &mut App,
113 ) -> Task<Result<LanguageModelToolResultContent>> {
114 let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
115 return Task::ready(Err(anyhow!("Path {} not found in project", &input.path)));
116 };
117 let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
118 return Task::ready(Err(anyhow!(
119 "Failed to convert {} to absolute path",
120 &input.path
121 )));
122 };
123
124 // Error out if this path is either excluded or private in global settings
125 let global_settings = WorktreeSettings::get_global(cx);
126 if global_settings.is_path_excluded(&project_path.path) {
127 return Task::ready(Err(anyhow!(
128 "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
129 &input.path
130 )));
131 }
132
133 if global_settings.is_path_private(&project_path.path) {
134 return Task::ready(Err(anyhow!(
135 "Cannot read file because its path matches the global `private_files` setting: {}",
136 &input.path
137 )));
138 }
139
140 // Error out if this path is either excluded or private in worktree settings
141 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
142 if worktree_settings.is_path_excluded(&project_path.path) {
143 return Task::ready(Err(anyhow!(
144 "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
145 &input.path
146 )));
147 }
148
149 if worktree_settings.is_path_private(&project_path.path) {
150 return Task::ready(Err(anyhow!(
151 "Cannot read file because its path matches the worktree `private_files` setting: {}",
152 &input.path
153 )));
154 }
155
156 let file_path = input.path.clone();
157
158 event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![
159 acp::ToolCallLocation::new(&abs_path)
160 .line(input.start_line.map(|line| line.saturating_sub(1))),
161 ]));
162
163 if image_store::is_image_file(&self.project, &project_path, cx) {
164 return cx.spawn(async move |cx| {
165 let image_entity: Entity<ImageItem> = cx
166 .update(|cx| {
167 self.project.update(cx, |project, cx| {
168 project.open_image(project_path.clone(), cx)
169 })
170 })?
171 .await?;
172
173 let image =
174 image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image))?;
175
176 let language_model_image = cx
177 .update(|cx| LanguageModelImage::from_image(image, cx))?
178 .await
179 .context("processing image")?;
180
181 event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
182 acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
183 acp::ImageContent::new(language_model_image.source.clone(), "image/png"),
184 ))),
185 ]));
186
187 Ok(language_model_image.into())
188 });
189 }
190
191 let project = self.project.clone();
192 let action_log = self.action_log.clone();
193
194 cx.spawn(async move |cx| {
195 let buffer = cx
196 .update(|cx| {
197 project.update(cx, |project, cx| {
198 project.open_buffer(project_path.clone(), cx)
199 })
200 })?
201 .await?;
202 if buffer.read_with(cx, |buffer, _| {
203 buffer
204 .file()
205 .as_ref()
206 .is_none_or(|file| !file.disk_state().exists())
207 })? {
208 anyhow::bail!("{file_path} not found");
209 }
210
211 // Record the file read time and mtime
212 if let Some(mtime) = buffer.read_with(cx, |buffer, _| {
213 buffer.file().and_then(|file| file.disk_state().mtime())
214 })? {
215 self.thread
216 .update(cx, |thread, _| {
217 thread.file_read_times.insert(abs_path.to_path_buf(), mtime);
218 })
219 .ok();
220 }
221
222 let mut anchor = None;
223
224 // Check if specific line ranges are provided
225 let result = if input.start_line.is_some() || input.end_line.is_some() {
226 let result = buffer.read_with(cx, |buffer, _cx| {
227 // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
228 let start = input.start_line.unwrap_or(1).max(1);
229 let start_row = start - 1;
230 if start_row <= buffer.max_point().row {
231 let column = buffer.line_indent_for_row(start_row).raw_len();
232 anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
233 }
234
235 let mut end_row = input.end_line.unwrap_or(u32::MAX);
236 if end_row <= start_row {
237 end_row = start_row + 1; // read at least one lines
238 }
239 let start = buffer.anchor_before(Point::new(start_row, 0));
240 let end = buffer.anchor_before(Point::new(end_row, 0));
241 buffer.text_for_range(start..end).collect::<String>()
242 })?;
243
244 action_log.update(cx, |log, cx| {
245 log.buffer_read(buffer.clone(), cx);
246 })?;
247
248 Ok(result.into())
249 } else {
250 // No line ranges specified, so check file size to see if it's too big.
251 let buffer_content = outline::get_buffer_content_or_outline(
252 buffer.clone(),
253 Some(&abs_path.to_string_lossy()),
254 cx,
255 )
256 .await?;
257
258 action_log.update(cx, |log, cx| {
259 log.buffer_read(buffer.clone(), cx);
260 })?;
261
262 if buffer_content.is_outline {
263 Ok(formatdoc! {"
264 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.
265
266 IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline.
267 Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters.
268
269 {}
270
271 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.
272 For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text
273 }
274 .into())
275 } else {
276 Ok(buffer_content.text.into())
277 }
278 };
279
280 project.update(cx, |project, cx| {
281 project.set_agent_location(
282 Some(AgentLocation {
283 buffer: buffer.downgrade(),
284 position: anchor.unwrap_or_else(|| {
285 text::Anchor::min_for_buffer(buffer.read(cx).remote_id())
286 }),
287 }),
288 cx,
289 );
290 if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
291 let markdown = MarkdownCodeBlock {
292 tag: &input.path,
293 text,
294 }
295 .to_string();
296 event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
297 acp::ToolCallContent::Content(acp::Content::new(markdown)),
298 ]));
299 }
300 })?;
301
302 result
303 })
304 }
305}
306
307#[cfg(test)]
308mod test {
309 use super::*;
310 use crate::{ContextServerRegistry, Templates, Thread};
311 use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
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(language::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 #[gpui::test]
606 async fn test_read_file_security(cx: &mut TestAppContext) {
607 init_test(cx);
608
609 let fs = FakeFs::new(cx.executor());
610
611 fs.insert_tree(
612 path!("/"),
613 json!({
614 "project_root": {
615 "allowed_file.txt": "This file is in the project",
616 ".mysecrets": "SECRET_KEY=abc123",
617 ".secretdir": {
618 "config": "special configuration"
619 },
620 ".mymetadata": "custom metadata",
621 "subdir": {
622 "normal_file.txt": "Normal file content",
623 "special.privatekey": "private key content",
624 "data.mysensitive": "sensitive data"
625 }
626 },
627 "outside_project": {
628 "sensitive_file.txt": "This file is outside the project"
629 }
630 }),
631 )
632 .await;
633
634 cx.update(|cx| {
635 use gpui::UpdateGlobal;
636 use settings::SettingsStore;
637 SettingsStore::update_global(cx, |store, cx| {
638 store.update_user_settings(cx, |settings| {
639 settings.project.worktree.file_scan_exclusions = Some(vec![
640 "**/.secretdir".to_string(),
641 "**/.mymetadata".to_string(),
642 ]);
643 settings.project.worktree.private_files = Some(
644 vec![
645 "**/.mysecrets".to_string(),
646 "**/*.privatekey".to_string(),
647 "**/*.mysensitive".to_string(),
648 ]
649 .into(),
650 );
651 });
652 });
653 });
654
655 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
656 let action_log = cx.new(|_| ActionLog::new(project.clone()));
657 let context_server_registry =
658 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
659 let model = Arc::new(FakeLanguageModel::default());
660 let thread = cx.new(|cx| {
661 Thread::new(
662 project.clone(),
663 cx.new(|_cx| ProjectContext::default()),
664 context_server_registry,
665 Templates::new(),
666 Some(model),
667 cx,
668 )
669 });
670 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
671
672 // Reading a file outside the project worktree should fail
673 let result = cx
674 .update(|cx| {
675 let input = ReadFileToolInput {
676 path: "/outside_project/sensitive_file.txt".to_string(),
677 start_line: None,
678 end_line: None,
679 };
680 tool.clone().run(input, ToolCallEventStream::test().0, cx)
681 })
682 .await;
683 assert!(
684 result.is_err(),
685 "read_file_tool should error when attempting to read an absolute path outside a worktree"
686 );
687
688 // Reading a file within the project should succeed
689 let result = cx
690 .update(|cx| {
691 let input = ReadFileToolInput {
692 path: "project_root/allowed_file.txt".to_string(),
693 start_line: None,
694 end_line: None,
695 };
696 tool.clone().run(input, ToolCallEventStream::test().0, cx)
697 })
698 .await;
699 assert!(
700 result.is_ok(),
701 "read_file_tool should be able to read files inside worktrees"
702 );
703
704 // Reading files that match file_scan_exclusions should fail
705 let result = cx
706 .update(|cx| {
707 let input = ReadFileToolInput {
708 path: "project_root/.secretdir/config".to_string(),
709 start_line: None,
710 end_line: None,
711 };
712 tool.clone().run(input, ToolCallEventStream::test().0, cx)
713 })
714 .await;
715 assert!(
716 result.is_err(),
717 "read_file_tool should error when attempting to read files in .secretdir (file_scan_exclusions)"
718 );
719
720 let result = cx
721 .update(|cx| {
722 let input = ReadFileToolInput {
723 path: "project_root/.mymetadata".to_string(),
724 start_line: None,
725 end_line: None,
726 };
727 tool.clone().run(input, ToolCallEventStream::test().0, cx)
728 })
729 .await;
730 assert!(
731 result.is_err(),
732 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
733 );
734
735 // Reading private files should fail
736 let result = cx
737 .update(|cx| {
738 let input = ReadFileToolInput {
739 path: "project_root/.mysecrets".to_string(),
740 start_line: None,
741 end_line: None,
742 };
743 tool.clone().run(input, ToolCallEventStream::test().0, cx)
744 })
745 .await;
746 assert!(
747 result.is_err(),
748 "read_file_tool should error when attempting to read .mysecrets (private_files)"
749 );
750
751 let result = cx
752 .update(|cx| {
753 let input = ReadFileToolInput {
754 path: "project_root/subdir/special.privatekey".to_string(),
755 start_line: None,
756 end_line: None,
757 };
758 tool.clone().run(input, ToolCallEventStream::test().0, cx)
759 })
760 .await;
761 assert!(
762 result.is_err(),
763 "read_file_tool should error when attempting to read .privatekey files (private_files)"
764 );
765
766 let result = cx
767 .update(|cx| {
768 let input = ReadFileToolInput {
769 path: "project_root/subdir/data.mysensitive".to_string(),
770 start_line: None,
771 end_line: None,
772 };
773 tool.clone().run(input, ToolCallEventStream::test().0, cx)
774 })
775 .await;
776 assert!(
777 result.is_err(),
778 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
779 );
780
781 // Reading a normal file should still work, even with private_files configured
782 let result = cx
783 .update(|cx| {
784 let input = ReadFileToolInput {
785 path: "project_root/subdir/normal_file.txt".to_string(),
786 start_line: None,
787 end_line: None,
788 };
789 tool.clone().run(input, ToolCallEventStream::test().0, cx)
790 })
791 .await;
792 assert!(result.is_ok(), "Should be able to read normal files");
793 assert_eq!(result.unwrap(), "Normal file content".into());
794
795 // Path traversal attempts with .. should fail
796 let result = cx
797 .update(|cx| {
798 let input = ReadFileToolInput {
799 path: "project_root/../outside_project/sensitive_file.txt".to_string(),
800 start_line: None,
801 end_line: None,
802 };
803 tool.run(input, ToolCallEventStream::test().0, cx)
804 })
805 .await;
806 assert!(
807 result.is_err(),
808 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
809 );
810 }
811
812 #[gpui::test]
813 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
814 init_test(cx);
815
816 let fs = FakeFs::new(cx.executor());
817
818 // Create first worktree with its own private_files setting
819 fs.insert_tree(
820 path!("/worktree1"),
821 json!({
822 "src": {
823 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
824 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
825 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
826 },
827 "tests": {
828 "test.rs": "mod tests { fn test_it() {} }",
829 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
830 },
831 ".zed": {
832 "settings.json": r#"{
833 "file_scan_exclusions": ["**/fixture.*"],
834 "private_files": ["**/secret.rs", "**/config.toml"]
835 }"#
836 }
837 }),
838 )
839 .await;
840
841 // Create second worktree with different private_files setting
842 fs.insert_tree(
843 path!("/worktree2"),
844 json!({
845 "lib": {
846 "public.js": "export function greet() { return 'Hello from worktree2'; }",
847 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
848 "data.json": "{\"api_key\": \"json_secret_key\"}"
849 },
850 "docs": {
851 "README.md": "# Public Documentation",
852 "internal.md": "# Internal Secrets and Configuration"
853 },
854 ".zed": {
855 "settings.json": r#"{
856 "file_scan_exclusions": ["**/internal.*"],
857 "private_files": ["**/private.js", "**/data.json"]
858 }"#
859 }
860 }),
861 )
862 .await;
863
864 // Set global settings
865 cx.update(|cx| {
866 SettingsStore::update_global(cx, |store, cx| {
867 store.update_user_settings(cx, |settings| {
868 settings.project.worktree.file_scan_exclusions =
869 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
870 settings.project.worktree.private_files =
871 Some(vec!["**/.env".to_string()].into());
872 });
873 });
874 });
875
876 let project = Project::test(
877 fs.clone(),
878 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
879 cx,
880 )
881 .await;
882
883 let action_log = cx.new(|_| ActionLog::new(project.clone()));
884 let context_server_registry =
885 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
886 let model = Arc::new(FakeLanguageModel::default());
887 let thread = cx.new(|cx| {
888 Thread::new(
889 project.clone(),
890 cx.new(|_cx| ProjectContext::default()),
891 context_server_registry,
892 Templates::new(),
893 Some(model),
894 cx,
895 )
896 });
897 let tool = Arc::new(ReadFileTool::new(
898 thread.downgrade(),
899 project.clone(),
900 action_log.clone(),
901 ));
902
903 // Test reading allowed files in worktree1
904 let result = cx
905 .update(|cx| {
906 let input = ReadFileToolInput {
907 path: "worktree1/src/main.rs".to_string(),
908 start_line: None,
909 end_line: None,
910 };
911 tool.clone().run(input, ToolCallEventStream::test().0, cx)
912 })
913 .await
914 .unwrap();
915
916 assert_eq!(
917 result,
918 "fn main() { println!(\"Hello from worktree1\"); }".into()
919 );
920
921 // Test reading private file in worktree1 should fail
922 let result = cx
923 .update(|cx| {
924 let input = ReadFileToolInput {
925 path: "worktree1/src/secret.rs".to_string(),
926 start_line: None,
927 end_line: None,
928 };
929 tool.clone().run(input, ToolCallEventStream::test().0, cx)
930 })
931 .await;
932
933 assert!(result.is_err());
934 assert!(
935 result
936 .unwrap_err()
937 .to_string()
938 .contains("worktree `private_files` setting"),
939 "Error should mention worktree private_files setting"
940 );
941
942 // Test reading excluded file in worktree1 should fail
943 let result = cx
944 .update(|cx| {
945 let input = ReadFileToolInput {
946 path: "worktree1/tests/fixture.sql".to_string(),
947 start_line: None,
948 end_line: None,
949 };
950 tool.clone().run(input, ToolCallEventStream::test().0, cx)
951 })
952 .await;
953
954 assert!(result.is_err());
955 assert!(
956 result
957 .unwrap_err()
958 .to_string()
959 .contains("worktree `file_scan_exclusions` setting"),
960 "Error should mention worktree file_scan_exclusions setting"
961 );
962
963 // Test reading allowed files in worktree2
964 let result = cx
965 .update(|cx| {
966 let input = ReadFileToolInput {
967 path: "worktree2/lib/public.js".to_string(),
968 start_line: None,
969 end_line: None,
970 };
971 tool.clone().run(input, ToolCallEventStream::test().0, cx)
972 })
973 .await
974 .unwrap();
975
976 assert_eq!(
977 result,
978 "export function greet() { return 'Hello from worktree2'; }".into()
979 );
980
981 // Test reading private file in worktree2 should fail
982 let result = cx
983 .update(|cx| {
984 let input = ReadFileToolInput {
985 path: "worktree2/lib/private.js".to_string(),
986 start_line: None,
987 end_line: None,
988 };
989 tool.clone().run(input, ToolCallEventStream::test().0, cx)
990 })
991 .await;
992
993 assert!(result.is_err());
994 assert!(
995 result
996 .unwrap_err()
997 .to_string()
998 .contains("worktree `private_files` setting"),
999 "Error should mention worktree private_files setting"
1000 );
1001
1002 // Test reading excluded file in worktree2 should fail
1003 let result = cx
1004 .update(|cx| {
1005 let input = ReadFileToolInput {
1006 path: "worktree2/docs/internal.md".to_string(),
1007 start_line: None,
1008 end_line: None,
1009 };
1010 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1011 })
1012 .await;
1013
1014 assert!(result.is_err());
1015 assert!(
1016 result
1017 .unwrap_err()
1018 .to_string()
1019 .contains("worktree `file_scan_exclusions` setting"),
1020 "Error should mention worktree file_scan_exclusions setting"
1021 );
1022
1023 // Test that files allowed in one worktree but not in another are handled correctly
1024 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1025 let result = cx
1026 .update(|cx| {
1027 let input = ReadFileToolInput {
1028 path: "worktree1/src/config.toml".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 "Config.toml should be blocked by worktree1's private_files setting"
1043 );
1044 }
1045}