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