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