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