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
16fn tool_content_err(e: impl std::fmt::Display) -> LanguageModelToolResultContent {
17 LanguageModelToolResultContent::from(e.to_string())
18}
19
20use super::tool_permissions::{
21 ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots,
22 resolve_project_path,
23};
24use crate::{AgentTool, Thread, ToolCallEventStream, outline};
25
26/// Reads the content of the given file in the project.
27///
28/// - Never attempt to read a path that hasn't been previously mentioned.
29/// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content.
30/// This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line.
31/// Do NOT retry reading the same file without line numbers if you receive an outline.
32/// - This tool supports reading image files. Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF.
33/// Image files are returned as visual content that you can analyze directly.
34#[derive(Debug, Serialize, Deserialize, JsonSchema)]
35pub struct ReadFileToolInput {
36 /// The relative path of the file to read.
37 ///
38 /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
39 ///
40 /// <example>
41 /// If the project has the following root directories:
42 ///
43 /// - /a/b/directory1
44 /// - /c/d/directory2
45 ///
46 /// If you want to access `file.txt` in `directory1`, you should use the path `directory1/file.txt`.
47 /// If you want to access `file.txt` in `directory2`, you should use the path `directory2/file.txt`.
48 /// </example>
49 pub path: String,
50 /// Optional line number to start reading on (1-based index)
51 #[serde(default)]
52 pub start_line: Option<u32>,
53 /// Optional line number to end reading on (1-based index, inclusive)
54 #[serde(default)]
55 pub end_line: Option<u32>,
56}
57
58pub struct ReadFileTool {
59 thread: WeakEntity<Thread>,
60 project: Entity<Project>,
61 action_log: Entity<ActionLog>,
62}
63
64impl ReadFileTool {
65 pub fn new(
66 thread: WeakEntity<Thread>,
67 project: Entity<Project>,
68 action_log: Entity<ActionLog>,
69 ) -> Self {
70 Self {
71 thread,
72 project,
73 action_log,
74 }
75 }
76}
77
78impl AgentTool for ReadFileTool {
79 type Input = ReadFileToolInput;
80 type Output = LanguageModelToolResultContent;
81
82 const NAME: &'static str = "read_file";
83
84 fn kind() -> acp::ToolKind {
85 acp::ToolKind::Read
86 }
87
88 fn initial_title(
89 &self,
90 input: Result<Self::Input, serde_json::Value>,
91 cx: &mut App,
92 ) -> SharedString {
93 if let Ok(input) = input
94 && let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx)
95 && let Some(path) = self
96 .project
97 .read(cx)
98 .short_full_path_for_project_path(&project_path, cx)
99 {
100 match (input.start_line, input.end_line) {
101 (Some(start), Some(end)) => {
102 format!("Read file `{path}` (lines {}-{})", start, end,)
103 }
104 (Some(start), None) => {
105 format!("Read file `{path}` (from line {})", start)
106 }
107 _ => format!("Read file `{path}`"),
108 }
109 .into()
110 } else {
111 "Read file".into()
112 }
113 }
114
115 fn run(
116 self: Arc<Self>,
117 input: Self::Input,
118 event_stream: ToolCallEventStream,
119 cx: &mut App,
120 ) -> Task<Result<LanguageModelToolResultContent, LanguageModelToolResultContent>> {
121 let project = self.project.clone();
122 let thread = self.thread.clone();
123 let action_log = self.action_log.clone();
124 cx.spawn(async move |cx| {
125 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
126 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
127
128 let (project_path, symlink_canonical_target) =
129 project.read_with(cx, |project, cx| {
130 let resolved =
131 resolve_project_path(project, &input.path, &canonical_roots, cx)?;
132 anyhow::Ok(match resolved {
133 ResolvedProjectPath::Safe(path) => (path, None),
134 ResolvedProjectPath::SymlinkEscape {
135 project_path,
136 canonical_target,
137 } => (project_path, Some(canonical_target)),
138 })
139 }).map_err(tool_content_err)?;
140
141 let abs_path = project
142 .read_with(cx, |project, cx| {
143 project.absolute_path(&project_path, cx)
144 })
145 .ok_or_else(|| {
146 anyhow!("Failed to convert {} to absolute path", &input.path)
147 }).map_err(tool_content_err)?;
148
149 // Check settings exclusions synchronously
150 project.read_with(cx, |_project, cx| {
151 let global_settings = WorktreeSettings::get_global(cx);
152 if global_settings.is_path_excluded(&project_path.path) {
153 anyhow::bail!(
154 "Cannot read file because its path matches the global `file_scan_exclusions` setting: {}",
155 &input.path
156 );
157 }
158
159 if global_settings.is_path_private(&project_path.path) {
160 anyhow::bail!(
161 "Cannot read file because its path matches the global `private_files` setting: {}",
162 &input.path
163 );
164 }
165
166 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
167 if worktree_settings.is_path_excluded(&project_path.path) {
168 anyhow::bail!(
169 "Cannot read file because its path matches the worktree `file_scan_exclusions` setting: {}",
170 &input.path
171 );
172 }
173
174 if worktree_settings.is_path_private(&project_path.path) {
175 anyhow::bail!(
176 "Cannot read file because its path matches the worktree `private_files` setting: {}",
177 &input.path
178 );
179 }
180
181 anyhow::Ok(())
182 }).map_err(tool_content_err)?;
183
184 if let Some(canonical_target) = &symlink_canonical_target {
185 let authorize = cx.update(|cx| {
186 authorize_symlink_access(
187 Self::NAME,
188 &input.path,
189 canonical_target,
190 &event_stream,
191 cx,
192 )
193 });
194 authorize.await.map_err(tool_content_err)?;
195 }
196
197 let file_path = input.path.clone();
198
199 cx.update(|_cx| {
200 event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![
201 acp::ToolCallLocation::new(&abs_path)
202 .line(input.start_line.map(|line| line.saturating_sub(1))),
203 ]));
204 });
205
206 let is_image = project.read_with(cx, |_project, cx| {
207 image_store::is_image_file(&project, &project_path, cx)
208 });
209
210 if is_image {
211
212 let image_entity: Entity<ImageItem> = cx
213 .update(|cx| {
214 self.project.update(cx, |project, cx| {
215 project.open_image(project_path.clone(), cx)
216 })
217 })
218 .await.map_err(tool_content_err)?;
219
220 let image =
221 image_entity.read_with(cx, |image_item, _| Arc::clone(&image_item.image));
222
223 let language_model_image = cx
224 .update(|cx| LanguageModelImage::from_image(image, cx))
225 .await
226 .context("processing image")
227 .map_err(tool_content_err)?;
228
229 event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
230 acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
231 acp::ImageContent::new(language_model_image.source.clone(), "image/png"),
232 ))),
233 ]));
234
235 return Ok(language_model_image.into());
236 }
237
238 let open_buffer_task = project.update(cx, |project, cx| {
239 project.open_buffer(project_path.clone(), cx)
240 });
241
242 let buffer = futures::select! {
243 result = open_buffer_task.fuse() => result.map_err(tool_content_err)?,
244 _ = event_stream.cancelled_by_user().fuse() => {
245 return Err(tool_content_err("File read cancelled by user"));
246 }
247 };
248 if buffer.read_with(cx, |buffer, _| {
249 buffer
250 .file()
251 .as_ref()
252 .is_none_or(|file| !file.disk_state().exists())
253 }) {
254 return Err(tool_content_err(format!("{file_path} not found")));
255 }
256
257 // Record the file read time and mtime
258 if let Some(mtime) = buffer.read_with(cx, |buffer, _| {
259 buffer.file().and_then(|file| file.disk_state().mtime())
260 }) {
261 thread
262 .update(cx, |thread, _| {
263 thread.file_read_times.insert(abs_path.to_path_buf(), mtime);
264 })
265 .ok();
266 }
267
268 let mut anchor = None;
269
270 // Check if specific line ranges are provided
271 let result = if input.start_line.is_some() || input.end_line.is_some() {
272 let result = buffer.read_with(cx, |buffer, _cx| {
273 // .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
274 let start = input.start_line.unwrap_or(1).max(1);
275 let start_row = start - 1;
276 if start_row <= buffer.max_point().row {
277 let column = buffer.line_indent_for_row(start_row).raw_len();
278 anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
279 }
280
281 let mut end_row = input.end_line.unwrap_or(u32::MAX);
282 if end_row <= start_row {
283 end_row = start_row + 1; // read at least one lines
284 }
285 let start = buffer.anchor_before(Point::new(start_row, 0));
286 let end = buffer.anchor_before(Point::new(end_row, 0));
287 buffer.text_for_range(start..end).collect::<String>()
288 });
289
290 action_log.update(cx, |log, cx| {
291 log.buffer_read(buffer.clone(), cx);
292 });
293
294 Ok(result.into())
295 } else {
296 // No line ranges specified, so check file size to see if it's too big.
297 let buffer_content = outline::get_buffer_content_or_outline(
298 buffer.clone(),
299 Some(&abs_path.to_string_lossy()),
300 cx,
301 )
302 .await.map_err(tool_content_err)?;
303
304 action_log.update(cx, |log, cx| {
305 log.buffer_read(buffer.clone(), cx);
306 });
307
308 if buffer_content.is_outline {
309 Ok(formatdoc! {"
310 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.
311
312 IMPORTANT: Do NOT retry this call without line numbers - you will get the same outline.
313 Instead, use the line numbers below to read specific sections by calling this tool again with start_line and end_line parameters.
314
315 {}
316
317 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.
318 For example, to read a function shown as [L100-150], use start_line: 100 and end_line: 150.", buffer_content.text
319 }
320 .into())
321 } else {
322 Ok(buffer_content.text.into())
323 }
324 };
325
326 project.update(cx, |project, cx| {
327 project.set_agent_location(
328 Some(AgentLocation {
329 buffer: buffer.downgrade(),
330 position: anchor.unwrap_or_else(|| {
331 text::Anchor::min_for_buffer(buffer.read(cx).remote_id())
332 }),
333 }),
334 cx,
335 );
336 if let Ok(LanguageModelToolResultContent::Text(text)) = &result {
337 let text: &str = text;
338 let markdown = MarkdownCodeBlock {
339 tag: &input.path,
340 text,
341 }
342 .to_string();
343 event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
344 acp::ToolCallContent::Content(acp::Content::new(markdown)),
345 ]));
346 }
347 });
348
349 result
350 })
351 }
352}
353
354#[cfg(test)]
355mod test {
356 use super::*;
357 use crate::{ContextServerRegistry, Templates, Thread};
358 use agent_client_protocol as acp;
359 use fs::Fs as _;
360 use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
361 use language_model::fake_provider::FakeLanguageModel;
362 use project::{FakeFs, Project};
363 use prompt_store::ProjectContext;
364 use serde_json::json;
365 use settings::SettingsStore;
366 use std::path::PathBuf;
367 use std::sync::Arc;
368 use util::path;
369
370 #[gpui::test]
371 async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
372 init_test(cx);
373
374 let fs = FakeFs::new(cx.executor());
375 fs.insert_tree(path!("/root"), json!({})).await;
376 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
377 let action_log = cx.new(|_| ActionLog::new(project.clone()));
378 let context_server_registry =
379 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
380 let model = Arc::new(FakeLanguageModel::default());
381 let thread = cx.new(|cx| {
382 Thread::new(
383 project.clone(),
384 cx.new(|_cx| ProjectContext::default()),
385 context_server_registry,
386 Templates::new(),
387 Some(model),
388 cx,
389 )
390 });
391 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
392 let (event_stream, _) = ToolCallEventStream::test();
393
394 let result = cx
395 .update(|cx| {
396 let input = ReadFileToolInput {
397 path: "root/nonexistent_file.txt".to_string(),
398 start_line: None,
399 end_line: None,
400 };
401 tool.run(input, event_stream, cx)
402 })
403 .await;
404 assert_eq!(
405 error_text(result.unwrap_err()),
406 "root/nonexistent_file.txt not found"
407 );
408 }
409
410 #[gpui::test]
411 async fn test_read_small_file(cx: &mut TestAppContext) {
412 init_test(cx);
413
414 let fs = FakeFs::new(cx.executor());
415 fs.insert_tree(
416 path!("/root"),
417 json!({
418 "small_file.txt": "This is a small file content"
419 }),
420 )
421 .await;
422 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
423 let action_log = cx.new(|_| ActionLog::new(project.clone()));
424 let context_server_registry =
425 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
426 let model = Arc::new(FakeLanguageModel::default());
427 let thread = cx.new(|cx| {
428 Thread::new(
429 project.clone(),
430 cx.new(|_cx| ProjectContext::default()),
431 context_server_registry,
432 Templates::new(),
433 Some(model),
434 cx,
435 )
436 });
437 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
438 let result = cx
439 .update(|cx| {
440 let input = ReadFileToolInput {
441 path: "root/small_file.txt".into(),
442 start_line: None,
443 end_line: None,
444 };
445 tool.run(input, ToolCallEventStream::test().0, cx)
446 })
447 .await;
448 assert_eq!(result.unwrap(), "This is a small file content".into());
449 }
450
451 #[gpui::test]
452 async fn test_read_large_file(cx: &mut TestAppContext) {
453 init_test(cx);
454
455 let fs = FakeFs::new(cx.executor());
456 fs.insert_tree(
457 path!("/root"),
458 json!({
459 "large_file.rs": (0..1000).map(|i| format!("struct Test{} {{\n a: u32,\n b: usize,\n}}", i)).collect::<Vec<_>>().join("\n")
460 }),
461 )
462 .await;
463 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
464 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
465 language_registry.add(language::rust_lang());
466 let action_log = cx.new(|_| ActionLog::new(project.clone()));
467 let context_server_registry =
468 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
469 let model = Arc::new(FakeLanguageModel::default());
470 let thread = cx.new(|cx| {
471 Thread::new(
472 project.clone(),
473 cx.new(|_cx| ProjectContext::default()),
474 context_server_registry,
475 Templates::new(),
476 Some(model),
477 cx,
478 )
479 });
480 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
481 let result = cx
482 .update(|cx| {
483 let input = ReadFileToolInput {
484 path: "root/large_file.rs".into(),
485 start_line: None,
486 end_line: None,
487 };
488 tool.clone().run(input, ToolCallEventStream::test().0, cx)
489 })
490 .await
491 .unwrap();
492 let content = result.to_str().unwrap();
493
494 assert_eq!(
495 content.lines().skip(7).take(6).collect::<Vec<_>>(),
496 vec![
497 "struct Test0 [L1-4]",
498 " a [L2]",
499 " b [L3]",
500 "struct Test1 [L5-8]",
501 " a [L6]",
502 " b [L7]",
503 ]
504 );
505
506 let result = cx
507 .update(|cx| {
508 let input = ReadFileToolInput {
509 path: "root/large_file.rs".into(),
510 start_line: None,
511 end_line: None,
512 };
513 tool.run(input, ToolCallEventStream::test().0, cx)
514 })
515 .await
516 .unwrap();
517 let content = result.to_str().unwrap();
518 let expected_content = (0..1000)
519 .flat_map(|i| {
520 vec![
521 format!("struct Test{} [L{}-{}]", i, i * 4 + 1, i * 4 + 4),
522 format!(" a [L{}]", i * 4 + 2),
523 format!(" b [L{}]", i * 4 + 3),
524 ]
525 })
526 .collect::<Vec<_>>();
527 pretty_assertions::assert_eq!(
528 content
529 .lines()
530 .skip(7)
531 .take(expected_content.len())
532 .collect::<Vec<_>>(),
533 expected_content
534 );
535 }
536
537 #[gpui::test]
538 async fn test_read_file_with_line_range(cx: &mut TestAppContext) {
539 init_test(cx);
540
541 let fs = FakeFs::new(cx.executor());
542 fs.insert_tree(
543 path!("/root"),
544 json!({
545 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
546 }),
547 )
548 .await;
549 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
550
551 let action_log = cx.new(|_| ActionLog::new(project.clone()));
552 let context_server_registry =
553 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
554 let model = Arc::new(FakeLanguageModel::default());
555 let thread = cx.new(|cx| {
556 Thread::new(
557 project.clone(),
558 cx.new(|_cx| ProjectContext::default()),
559 context_server_registry,
560 Templates::new(),
561 Some(model),
562 cx,
563 )
564 });
565 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
566 let result = cx
567 .update(|cx| {
568 let input = ReadFileToolInput {
569 path: "root/multiline.txt".to_string(),
570 start_line: Some(2),
571 end_line: Some(4),
572 };
573 tool.run(input, ToolCallEventStream::test().0, cx)
574 })
575 .await;
576 assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
577 }
578
579 #[gpui::test]
580 async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) {
581 init_test(cx);
582
583 let fs = FakeFs::new(cx.executor());
584 fs.insert_tree(
585 path!("/root"),
586 json!({
587 "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
588 }),
589 )
590 .await;
591 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
592 let action_log = cx.new(|_| ActionLog::new(project.clone()));
593 let context_server_registry =
594 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
595 let model = Arc::new(FakeLanguageModel::default());
596 let thread = cx.new(|cx| {
597 Thread::new(
598 project.clone(),
599 cx.new(|_cx| ProjectContext::default()),
600 context_server_registry,
601 Templates::new(),
602 Some(model),
603 cx,
604 )
605 });
606 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
607
608 // start_line of 0 should be treated as 1
609 let result = cx
610 .update(|cx| {
611 let input = ReadFileToolInput {
612 path: "root/multiline.txt".to_string(),
613 start_line: Some(0),
614 end_line: Some(2),
615 };
616 tool.clone().run(input, ToolCallEventStream::test().0, cx)
617 })
618 .await;
619 assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
620
621 // end_line of 0 should result in at least 1 line
622 let result = cx
623 .update(|cx| {
624 let input = ReadFileToolInput {
625 path: "root/multiline.txt".to_string(),
626 start_line: Some(1),
627 end_line: Some(0),
628 };
629 tool.clone().run(input, ToolCallEventStream::test().0, cx)
630 })
631 .await;
632 assert_eq!(result.unwrap(), "Line 1\n".into());
633
634 // when start_line > end_line, should still return at least 1 line
635 let result = cx
636 .update(|cx| {
637 let input = ReadFileToolInput {
638 path: "root/multiline.txt".to_string(),
639 start_line: Some(3),
640 end_line: Some(2),
641 };
642 tool.clone().run(input, ToolCallEventStream::test().0, cx)
643 })
644 .await;
645 assert_eq!(result.unwrap(), "Line 3\n".into());
646 }
647
648 fn error_text(content: LanguageModelToolResultContent) -> String {
649 match content {
650 LanguageModelToolResultContent::Text(text) => text.to_string(),
651 other => panic!("Expected text error, got: {other:?}"),
652 }
653 }
654
655 fn init_test(cx: &mut TestAppContext) {
656 cx.update(|cx| {
657 let settings_store = SettingsStore::test(cx);
658 cx.set_global(settings_store);
659 });
660 }
661
662 fn single_pixel_png() -> Vec<u8> {
663 vec![
664 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
665 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
666 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
667 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
668 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
669 ]
670 }
671
672 #[gpui::test]
673 async fn test_read_file_security(cx: &mut TestAppContext) {
674 init_test(cx);
675
676 let fs = FakeFs::new(cx.executor());
677
678 fs.insert_tree(
679 path!("/"),
680 json!({
681 "project_root": {
682 "allowed_file.txt": "This file is in the project",
683 ".mysecrets": "SECRET_KEY=abc123",
684 ".secretdir": {
685 "config": "special configuration"
686 },
687 ".mymetadata": "custom metadata",
688 "subdir": {
689 "normal_file.txt": "Normal file content",
690 "special.privatekey": "private key content",
691 "data.mysensitive": "sensitive data"
692 }
693 },
694 "outside_project": {
695 "sensitive_file.txt": "This file is outside the project"
696 }
697 }),
698 )
699 .await;
700
701 cx.update(|cx| {
702 use gpui::UpdateGlobal;
703 use settings::SettingsStore;
704 SettingsStore::update_global(cx, |store, cx| {
705 store.update_user_settings(cx, |settings| {
706 settings.project.worktree.file_scan_exclusions = Some(vec![
707 "**/.secretdir".to_string(),
708 "**/.mymetadata".to_string(),
709 ]);
710 settings.project.worktree.private_files = Some(
711 vec![
712 "**/.mysecrets".to_string(),
713 "**/*.privatekey".to_string(),
714 "**/*.mysensitive".to_string(),
715 ]
716 .into(),
717 );
718 });
719 });
720 });
721
722 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
723 let action_log = cx.new(|_| ActionLog::new(project.clone()));
724 let context_server_registry =
725 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
726 let model = Arc::new(FakeLanguageModel::default());
727 let thread = cx.new(|cx| {
728 Thread::new(
729 project.clone(),
730 cx.new(|_cx| ProjectContext::default()),
731 context_server_registry,
732 Templates::new(),
733 Some(model),
734 cx,
735 )
736 });
737 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
738
739 // Reading a file outside the project worktree should fail
740 let result = cx
741 .update(|cx| {
742 let input = ReadFileToolInput {
743 path: "/outside_project/sensitive_file.txt".to_string(),
744 start_line: None,
745 end_line: None,
746 };
747 tool.clone().run(input, ToolCallEventStream::test().0, cx)
748 })
749 .await;
750 assert!(
751 result.is_err(),
752 "read_file_tool should error when attempting to read an absolute path outside a worktree"
753 );
754
755 // Reading a file within the project should succeed
756 let result = cx
757 .update(|cx| {
758 let input = ReadFileToolInput {
759 path: "project_root/allowed_file.txt".to_string(),
760 start_line: None,
761 end_line: None,
762 };
763 tool.clone().run(input, ToolCallEventStream::test().0, cx)
764 })
765 .await;
766 assert!(
767 result.is_ok(),
768 "read_file_tool should be able to read files inside worktrees"
769 );
770
771 // Reading files that match file_scan_exclusions should fail
772 let result = cx
773 .update(|cx| {
774 let input = ReadFileToolInput {
775 path: "project_root/.secretdir/config".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 files in .secretdir (file_scan_exclusions)"
785 );
786
787 let result = cx
788 .update(|cx| {
789 let input = ReadFileToolInput {
790 path: "project_root/.mymetadata".to_string(),
791 start_line: None,
792 end_line: None,
793 };
794 tool.clone().run(input, ToolCallEventStream::test().0, cx)
795 })
796 .await;
797 assert!(
798 result.is_err(),
799 "read_file_tool should error when attempting to read .mymetadata files (file_scan_exclusions)"
800 );
801
802 // Reading private files should fail
803 let result = cx
804 .update(|cx| {
805 let input = ReadFileToolInput {
806 path: "project_root/.mysecrets".to_string(),
807 start_line: None,
808 end_line: None,
809 };
810 tool.clone().run(input, ToolCallEventStream::test().0, cx)
811 })
812 .await;
813 assert!(
814 result.is_err(),
815 "read_file_tool should error when attempting to read .mysecrets (private_files)"
816 );
817
818 let result = cx
819 .update(|cx| {
820 let input = ReadFileToolInput {
821 path: "project_root/subdir/special.privatekey".to_string(),
822 start_line: None,
823 end_line: None,
824 };
825 tool.clone().run(input, ToolCallEventStream::test().0, cx)
826 })
827 .await;
828 assert!(
829 result.is_err(),
830 "read_file_tool should error when attempting to read .privatekey files (private_files)"
831 );
832
833 let result = cx
834 .update(|cx| {
835 let input = ReadFileToolInput {
836 path: "project_root/subdir/data.mysensitive".to_string(),
837 start_line: None,
838 end_line: None,
839 };
840 tool.clone().run(input, ToolCallEventStream::test().0, cx)
841 })
842 .await;
843 assert!(
844 result.is_err(),
845 "read_file_tool should error when attempting to read .mysensitive files (private_files)"
846 );
847
848 // Reading a normal file should still work, even with private_files configured
849 let result = cx
850 .update(|cx| {
851 let input = ReadFileToolInput {
852 path: "project_root/subdir/normal_file.txt".to_string(),
853 start_line: None,
854 end_line: None,
855 };
856 tool.clone().run(input, ToolCallEventStream::test().0, cx)
857 })
858 .await;
859 assert!(result.is_ok(), "Should be able to read normal files");
860 assert_eq!(result.unwrap(), "Normal file content".into());
861
862 // Path traversal attempts with .. should fail
863 let result = cx
864 .update(|cx| {
865 let input = ReadFileToolInput {
866 path: "project_root/../outside_project/sensitive_file.txt".to_string(),
867 start_line: None,
868 end_line: None,
869 };
870 tool.run(input, ToolCallEventStream::test().0, cx)
871 })
872 .await;
873 assert!(
874 result.is_err(),
875 "read_file_tool should error when attempting to read a relative path that resolves to outside a worktree"
876 );
877 }
878
879 #[gpui::test]
880 async fn test_read_image_symlink_requires_authorization(cx: &mut TestAppContext) {
881 init_test(cx);
882
883 let fs = FakeFs::new(cx.executor());
884 fs.insert_tree(path!("/root"), json!({})).await;
885 fs.insert_tree(path!("/outside"), json!({})).await;
886 fs.insert_file(path!("/outside/secret.png"), single_pixel_png())
887 .await;
888 fs.insert_symlink(
889 path!("/root/secret.png"),
890 PathBuf::from("/outside/secret.png"),
891 )
892 .await;
893
894 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
895 let action_log = cx.new(|_| ActionLog::new(project.clone()));
896 let context_server_registry =
897 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
898 let model = Arc::new(FakeLanguageModel::default());
899 let thread = cx.new(|cx| {
900 Thread::new(
901 project.clone(),
902 cx.new(|_cx| ProjectContext::default()),
903 context_server_registry,
904 Templates::new(),
905 Some(model),
906 cx,
907 )
908 });
909 let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log));
910
911 let (event_stream, mut event_rx) = ToolCallEventStream::test();
912 let read_task = cx.update(|cx| {
913 tool.run(
914 ReadFileToolInput {
915 path: "root/secret.png".to_string(),
916 start_line: None,
917 end_line: None,
918 },
919 event_stream,
920 cx,
921 )
922 });
923
924 let authorization = event_rx.expect_authorization().await;
925 assert!(
926 authorization
927 .tool_call
928 .fields
929 .title
930 .as_deref()
931 .is_some_and(|title| title.contains("points outside the project")),
932 "Expected symlink escape authorization before reading the image"
933 );
934 authorization
935 .response
936 .send(acp::PermissionOptionId::new("allow"))
937 .unwrap();
938
939 let result = read_task.await;
940 assert!(result.is_ok());
941 }
942
943 #[gpui::test]
944 async fn test_read_file_with_multiple_worktree_settings(cx: &mut TestAppContext) {
945 init_test(cx);
946
947 let fs = FakeFs::new(cx.executor());
948
949 // Create first worktree with its own private_files setting
950 fs.insert_tree(
951 path!("/worktree1"),
952 json!({
953 "src": {
954 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
955 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
956 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
957 },
958 "tests": {
959 "test.rs": "mod tests { fn test_it() {} }",
960 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
961 },
962 ".zed": {
963 "settings.json": r#"{
964 "file_scan_exclusions": ["**/fixture.*"],
965 "private_files": ["**/secret.rs", "**/config.toml"]
966 }"#
967 }
968 }),
969 )
970 .await;
971
972 // Create second worktree with different private_files setting
973 fs.insert_tree(
974 path!("/worktree2"),
975 json!({
976 "lib": {
977 "public.js": "export function greet() { return 'Hello from worktree2'; }",
978 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
979 "data.json": "{\"api_key\": \"json_secret_key\"}"
980 },
981 "docs": {
982 "README.md": "# Public Documentation",
983 "internal.md": "# Internal Secrets and Configuration"
984 },
985 ".zed": {
986 "settings.json": r#"{
987 "file_scan_exclusions": ["**/internal.*"],
988 "private_files": ["**/private.js", "**/data.json"]
989 }"#
990 }
991 }),
992 )
993 .await;
994
995 // Set global settings
996 cx.update(|cx| {
997 SettingsStore::update_global(cx, |store, cx| {
998 store.update_user_settings(cx, |settings| {
999 settings.project.worktree.file_scan_exclusions =
1000 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1001 settings.project.worktree.private_files =
1002 Some(vec!["**/.env".to_string()].into());
1003 });
1004 });
1005 });
1006
1007 let project = Project::test(
1008 fs.clone(),
1009 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1010 cx,
1011 )
1012 .await;
1013
1014 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1015 let context_server_registry =
1016 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1017 let model = Arc::new(FakeLanguageModel::default());
1018 let thread = cx.new(|cx| {
1019 Thread::new(
1020 project.clone(),
1021 cx.new(|_cx| ProjectContext::default()),
1022 context_server_registry,
1023 Templates::new(),
1024 Some(model),
1025 cx,
1026 )
1027 });
1028 let tool = Arc::new(ReadFileTool::new(
1029 thread.downgrade(),
1030 project.clone(),
1031 action_log.clone(),
1032 ));
1033
1034 // Test reading allowed files in worktree1
1035 let result = cx
1036 .update(|cx| {
1037 let input = ReadFileToolInput {
1038 path: "worktree1/src/main.rs".to_string(),
1039 start_line: None,
1040 end_line: None,
1041 };
1042 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1043 })
1044 .await
1045 .unwrap();
1046
1047 assert_eq!(
1048 result,
1049 "fn main() { println!(\"Hello from worktree1\"); }".into()
1050 );
1051
1052 // Test reading private file in worktree1 should fail
1053 let result = cx
1054 .update(|cx| {
1055 let input = ReadFileToolInput {
1056 path: "worktree1/src/secret.rs".to_string(),
1057 start_line: None,
1058 end_line: None,
1059 };
1060 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1061 })
1062 .await;
1063
1064 assert!(result.is_err());
1065 assert!(
1066 error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1067 "Error should mention worktree private_files setting"
1068 );
1069
1070 // Test reading excluded file in worktree1 should fail
1071 let result = cx
1072 .update(|cx| {
1073 let input = ReadFileToolInput {
1074 path: "worktree1/tests/fixture.sql".to_string(),
1075 start_line: None,
1076 end_line: None,
1077 };
1078 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1079 })
1080 .await;
1081
1082 assert!(result.is_err());
1083 assert!(
1084 error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1085 "Error should mention worktree file_scan_exclusions setting"
1086 );
1087
1088 // Test reading allowed files in worktree2
1089 let result = cx
1090 .update(|cx| {
1091 let input = ReadFileToolInput {
1092 path: "worktree2/lib/public.js".to_string(),
1093 start_line: None,
1094 end_line: None,
1095 };
1096 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1097 })
1098 .await
1099 .unwrap();
1100
1101 assert_eq!(
1102 result,
1103 "export function greet() { return 'Hello from worktree2'; }".into()
1104 );
1105
1106 // Test reading private file in worktree2 should fail
1107 let result = cx
1108 .update(|cx| {
1109 let input = ReadFileToolInput {
1110 path: "worktree2/lib/private.js".to_string(),
1111 start_line: None,
1112 end_line: None,
1113 };
1114 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1115 })
1116 .await;
1117
1118 assert!(result.is_err());
1119 assert!(
1120 error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1121 "Error should mention worktree private_files setting"
1122 );
1123
1124 // Test reading excluded file in worktree2 should fail
1125 let result = cx
1126 .update(|cx| {
1127 let input = ReadFileToolInput {
1128 path: "worktree2/docs/internal.md".to_string(),
1129 start_line: None,
1130 end_line: None,
1131 };
1132 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1133 })
1134 .await;
1135
1136 assert!(result.is_err());
1137 assert!(
1138 error_text(result.unwrap_err()).contains("worktree `file_scan_exclusions` setting"),
1139 "Error should mention worktree file_scan_exclusions setting"
1140 );
1141
1142 // Test that files allowed in one worktree but not in another are handled correctly
1143 // (e.g., config.toml is private in worktree1 but doesn't exist in worktree2)
1144 let result = cx
1145 .update(|cx| {
1146 let input = ReadFileToolInput {
1147 path: "worktree1/src/config.toml".to_string(),
1148 start_line: None,
1149 end_line: None,
1150 };
1151 tool.clone().run(input, ToolCallEventStream::test().0, cx)
1152 })
1153 .await;
1154
1155 assert!(result.is_err());
1156 assert!(
1157 error_text(result.unwrap_err()).contains("worktree `private_files` setting"),
1158 "Config.toml should be blocked by worktree1's private_files setting"
1159 );
1160 }
1161
1162 #[gpui::test]
1163 async fn test_read_file_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
1164 init_test(cx);
1165
1166 let fs = FakeFs::new(cx.executor());
1167 fs.insert_tree(
1168 path!("/root"),
1169 json!({
1170 "project": {
1171 "src": { "main.rs": "fn main() {}" }
1172 },
1173 "external": {
1174 "secret.txt": "SECRET_KEY=abc123"
1175 }
1176 }),
1177 )
1178 .await;
1179
1180 fs.create_symlink(
1181 path!("/root/project/secret_link.txt").as_ref(),
1182 PathBuf::from("../external/secret.txt"),
1183 )
1184 .await
1185 .unwrap();
1186
1187 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1188 cx.executor().run_until_parked();
1189
1190 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1191 let context_server_registry =
1192 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1193 let model = Arc::new(FakeLanguageModel::default());
1194 let thread = cx.new(|cx| {
1195 Thread::new(
1196 project.clone(),
1197 cx.new(|_cx| ProjectContext::default()),
1198 context_server_registry,
1199 Templates::new(),
1200 Some(model),
1201 cx,
1202 )
1203 });
1204 let tool = Arc::new(ReadFileTool::new(
1205 thread.downgrade(),
1206 project.clone(),
1207 action_log,
1208 ));
1209
1210 let (event_stream, mut event_rx) = ToolCallEventStream::test();
1211 let task = cx.update(|cx| {
1212 tool.clone().run(
1213 ReadFileToolInput {
1214 path: "project/secret_link.txt".to_string(),
1215 start_line: None,
1216 end_line: None,
1217 },
1218 event_stream,
1219 cx,
1220 )
1221 });
1222
1223 let auth = event_rx.expect_authorization().await;
1224 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
1225 assert!(
1226 title.contains("points outside the project"),
1227 "title: {title}"
1228 );
1229
1230 auth.response
1231 .send(acp::PermissionOptionId::new("allow"))
1232 .unwrap();
1233
1234 let result = task.await;
1235 assert!(result.is_ok(), "should succeed after approval: {result:?}");
1236 }
1237
1238 #[gpui::test]
1239 async fn test_read_file_symlink_escape_denied(cx: &mut TestAppContext) {
1240 init_test(cx);
1241
1242 let fs = FakeFs::new(cx.executor());
1243 fs.insert_tree(
1244 path!("/root"),
1245 json!({
1246 "project": {
1247 "src": { "main.rs": "fn main() {}" }
1248 },
1249 "external": {
1250 "secret.txt": "SECRET_KEY=abc123"
1251 }
1252 }),
1253 )
1254 .await;
1255
1256 fs.create_symlink(
1257 path!("/root/project/secret_link.txt").as_ref(),
1258 PathBuf::from("../external/secret.txt"),
1259 )
1260 .await
1261 .unwrap();
1262
1263 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1264 cx.executor().run_until_parked();
1265
1266 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1267 let context_server_registry =
1268 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1269 let model = Arc::new(FakeLanguageModel::default());
1270 let thread = cx.new(|cx| {
1271 Thread::new(
1272 project.clone(),
1273 cx.new(|_cx| ProjectContext::default()),
1274 context_server_registry,
1275 Templates::new(),
1276 Some(model),
1277 cx,
1278 )
1279 });
1280 let tool = Arc::new(ReadFileTool::new(
1281 thread.downgrade(),
1282 project.clone(),
1283 action_log,
1284 ));
1285
1286 let (event_stream, mut event_rx) = ToolCallEventStream::test();
1287 let task = cx.update(|cx| {
1288 tool.clone().run(
1289 ReadFileToolInput {
1290 path: "project/secret_link.txt".to_string(),
1291 start_line: None,
1292 end_line: None,
1293 },
1294 event_stream,
1295 cx,
1296 )
1297 });
1298
1299 let auth = event_rx.expect_authorization().await;
1300 drop(auth);
1301
1302 let result = task.await;
1303 assert!(
1304 result.is_err(),
1305 "Tool should fail when authorization is denied"
1306 );
1307 }
1308
1309 #[gpui::test]
1310 async fn test_read_file_symlink_escape_private_path_no_authorization(cx: &mut TestAppContext) {
1311 init_test(cx);
1312
1313 let fs = FakeFs::new(cx.executor());
1314 fs.insert_tree(
1315 path!("/root"),
1316 json!({
1317 "project": {
1318 "src": { "main.rs": "fn main() {}" }
1319 },
1320 "external": {
1321 "secret.txt": "SECRET_KEY=abc123"
1322 }
1323 }),
1324 )
1325 .await;
1326
1327 fs.create_symlink(
1328 path!("/root/project/secret_link.txt").as_ref(),
1329 PathBuf::from("../external/secret.txt"),
1330 )
1331 .await
1332 .unwrap();
1333
1334 cx.update(|cx| {
1335 settings::SettingsStore::update_global(cx, |store, cx| {
1336 store.update_user_settings(cx, |settings| {
1337 settings.project.worktree.private_files =
1338 Some(vec!["**/secret_link.txt".to_string()].into());
1339 });
1340 });
1341 });
1342
1343 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
1344 cx.executor().run_until_parked();
1345
1346 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1347 let context_server_registry =
1348 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1349 let model = Arc::new(FakeLanguageModel::default());
1350 let thread = cx.new(|cx| {
1351 Thread::new(
1352 project.clone(),
1353 cx.new(|_cx| ProjectContext::default()),
1354 context_server_registry,
1355 Templates::new(),
1356 Some(model),
1357 cx,
1358 )
1359 });
1360 let tool = Arc::new(ReadFileTool::new(
1361 thread.downgrade(),
1362 project.clone(),
1363 action_log,
1364 ));
1365
1366 let (event_stream, mut event_rx) = ToolCallEventStream::test();
1367 let result = cx
1368 .update(|cx| {
1369 tool.clone().run(
1370 ReadFileToolInput {
1371 path: "project/secret_link.txt".to_string(),
1372 start_line: None,
1373 end_line: None,
1374 },
1375 event_stream,
1376 cx,
1377 )
1378 })
1379 .await;
1380
1381 assert!(
1382 result.is_err(),
1383 "Expected read_file to fail on private path"
1384 );
1385 let error = error_text(result.unwrap_err());
1386 assert!(
1387 error.contains("private_files"),
1388 "Expected private-files validation error, got: {error}"
1389 );
1390
1391 let event = event_rx.try_next();
1392 assert!(
1393 !matches!(
1394 event,
1395 Ok(Some(Ok(crate::thread::ThreadEvent::ToolCallAuthorization(
1396 _
1397 ))))
1398 ),
1399 "No authorization should be requested when validation fails before read",
1400 );
1401 }
1402}