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