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