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