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