1use anda_core::{
8 BoxError, FunctionDefinition, Resource, StateFeatures, Tool, ToolGroupInfo, ToolOutput,
9};
10use ic_auth_types::ByteBufB64;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15use super::{
16 BASE64_ENCODING, MAX_INLINE_CONTENT_BYTES, UTF8_ENCODING, WorkspaceScope, decode_file_text,
17 format_workspaces, normalize_workspaces, truncate_inline_text,
18};
19use crate::{
20 context::BaseCtx,
21 extension::{hooked_call, tool_definition},
22 hook::DynToolHook,
23};
24
25#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
27pub struct ReadFileArgs {
28 pub path: String,
30 #[serde(default)]
32 pub offset: usize,
33 #[serde(default)]
35 pub limit: usize,
36}
37
38#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct ReadFileOutput {
41 pub content: String,
43 pub encoding: String,
45 pub size: u64,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub mime_type: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub total_lines: Option<usize>,
53 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
56 pub truncated: bool,
57}
58
59pub type ReadFileHook = DynToolHook<ReadFileArgs, ReadFileOutput>;
61
62#[derive(Clone)]
64pub struct ReadFileTool {
65 workspaces: Vec<PathBuf>,
66 description: String,
67}
68
69impl ReadFileTool {
70 pub const NAME: &'static str = "read_file";
72
73 pub fn new(workspace: PathBuf) -> Self {
78 Self::with_workspaces([workspace])
79 }
80
81 pub fn with_workspaces<I>(workspaces: I) -> Self
85 where
86 I: IntoIterator<Item = PathBuf>,
87 {
88 let workspaces = normalize_workspaces(workspaces);
89 let description = format!(
90 "Read files from the filesystem in the workspace directories ({})",
91 format_workspaces(&workspaces)
92 );
93 Self {
94 workspaces,
95 description,
96 }
97 }
98
99 pub fn with_description(mut self, description: String) -> Self {
101 self.description = description;
102 self
103 }
104}
105
106impl Tool<BaseCtx> for ReadFileTool {
107 type Args = ReadFileArgs;
108 type Output = ReadFileOutput;
109
110 fn name(&self) -> String {
111 Self::NAME.to_string()
112 }
113
114 fn description(&self) -> String {
115 self.description.clone()
116 }
117
118 fn group(&self) -> Option<ToolGroupInfo> {
119 Some(super::fs_tool_group_info())
120 }
121
122 fn definition(&self) -> FunctionDefinition {
123 tool_definition::<Self::Args>(self.name(), self.description())
124 }
125
126 async fn call(
127 &self,
128 ctx: BaseCtx,
129 args: Self::Args,
130 _resources: Vec<Resource>,
131 ) -> Result<ToolOutput<Self::Output>, BoxError> {
132 let ctx = &ctx;
133 hooked_call(ctx, args, |args| async move {
134 let scope = WorkspaceScope::for_call(ctx.meta(), &self.workspaces).await;
135 let target = scope.open_read(&args.path).await?;
136 let workspace_display = target.workspace.display().to_string();
137 let meta = target.metadata;
138 let resolved_path = target.path;
139
140 let data = tokio::fs::read(&resolved_path).await.map_err(|err| {
141 format!(
142 "Failed to read file (workspace: {}, requested_path: {}, resolved_path: {}): {err}",
143 workspace_display,
144 args.path,
145 resolved_path.display()
146 )
147 })?;
148 let mut output = ReadFileOutput {
149 content: String::new(),
150 encoding: UTF8_ENCODING.to_string(),
151 size: meta.len(),
152 ..Default::default()
153 };
154 if let Some(kind) = infer2::get(&data) {
155 output.mime_type = Some(kind.mime_type().to_string());
156 }
157 match decode_file_text(data) {
158 Ok(decoded) => {
159 output.encoding = decoded.encoding;
160 let text = decoded.text;
161 output.total_lines = Some(text.lines().count());
162 if args.offset == 0 && args.limit == 0 {
163 output.content = text;
164 } else if args.limit == 0 {
165 output.content = text
166 .lines()
167 .skip(args.offset)
168 .collect::<Vec<_>>()
169 .join("\n");
170 } else {
171 output.content = text
172 .lines()
173 .skip(args.offset)
174 .take(args.limit)
175 .collect::<Vec<_>>()
176 .join("\n");
177 }
178 output.truncated =
179 truncate_inline_text(&mut output.content, MAX_INLINE_CONTENT_BYTES);
180 }
181 Err(mut bytes) => {
182 let max_raw_bytes = MAX_INLINE_CONTENT_BYTES / 4 * 3;
185 if bytes.len() > max_raw_bytes {
186 bytes.truncate(max_raw_bytes);
187 output.truncated = true;
188 }
189 output.content = ByteBufB64(bytes).to_base64();
190 output.encoding = BASE64_ENCODING.to_string();
191 }
192 }
193
194 Ok(ToolOutput::new(output))
195 })
196 .await
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::{engine::EngineBuilder, hook::ToolHook};
204 use serde_json::json;
205 use std::{
206 path::{Path, PathBuf},
207 sync::Arc,
208 };
209
210 struct TestTempDir(PathBuf);
211
212 impl TestTempDir {
213 async fn new() -> Self {
214 let path = std::env::temp_dir()
215 .join(format!("anda-fs-read-test-{:016x}", rand::random::<u64>()));
216 tokio::fs::create_dir_all(&path).await.unwrap();
217 Self(path)
218 }
219
220 fn path(&self) -> &Path {
221 &self.0
222 }
223 }
224
225 impl Drop for TestTempDir {
226 fn drop(&mut self) {
227 let _ = std::fs::remove_dir_all(&self.0);
228 }
229 }
230
231 fn mock_ctx() -> BaseCtx {
232 EngineBuilder::new().mock_ctx().base
233 }
234
235 fn mock_ctx_with_workspace(workspace: &Path) -> BaseCtx {
236 let mut ctx = mock_ctx();
237 ctx.meta.extra.insert(
238 "workspace".to_string(),
239 json!(workspace.to_string_lossy().to_string()),
240 );
241 ctx
242 }
243
244 fn read_tool(workspace: &Path) -> ReadFileTool {
245 ReadFileTool::new(workspace.to_path_buf())
246 }
247
248 struct RewritingReadHook;
249
250 #[async_trait::async_trait]
251 impl ToolHook<ReadFileArgs, ReadFileOutput> for RewritingReadHook {
252 async fn before_tool_call(
253 &self,
254 _ctx: &BaseCtx,
255 mut args: ReadFileArgs,
256 ) -> Result<ReadFileArgs, BoxError> {
257 args.path = "hook.txt".to_string();
258 args.offset = 1;
259 args.limit = 1;
260 Ok(args)
261 }
262
263 async fn after_tool_call(
264 &self,
265 _ctx: &BaseCtx,
266 mut output: ToolOutput<ReadFileOutput>,
267 ) -> Result<ToolOutput<ReadFileOutput>, BoxError> {
268 output.output.content.push_str("\nhooked");
269 Ok(output)
270 }
271 }
272
273 #[tokio::test]
274 async fn metadata_hooks_and_mime_detection_are_covered() {
275 let temp_dir = TestTempDir::new().await;
276 let workspace = temp_dir.path().join("workspace");
277 tokio::fs::create_dir_all(&workspace).await.unwrap();
278 tokio::fs::write(workspace.join("hook.txt"), "zero\none\ntwo\n")
279 .await
280 .unwrap();
281 tokio::fs::write(
282 workspace.join("tiny.png"),
283 [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
284 )
285 .await
286 .unwrap();
287
288 let tool = read_tool(&workspace).with_description("custom read".to_string());
289 assert_eq!(tool.name(), ReadFileTool::NAME);
290 assert_eq!(tool.description(), "custom read");
291 let definition = tool.definition();
292 assert_eq!(definition.name, ReadFileTool::NAME);
293 assert_eq!(definition.strict, Some(true));
294 assert_eq!(
295 definition.parameters["required"],
296 json!(["path", "offset", "limit"])
297 );
298
299 let image = tool
300 .call(
301 mock_ctx(),
302 ReadFileArgs {
303 path: "tiny.png".to_string(),
304 offset: 0,
305 limit: 0,
306 },
307 Vec::new(),
308 )
309 .await
310 .unwrap();
311 assert_eq!(image.output.encoding, BASE64_ENCODING);
312 assert_eq!(image.output.mime_type.as_deref(), Some("image/png"));
313
314 let ctx = mock_ctx();
315 ctx.set_state(ReadFileHook::new(Arc::new(RewritingReadHook)));
316 let hooked = tool
317 .call(
318 ctx,
319 ReadFileArgs {
320 path: "ignored.txt".to_string(),
321 offset: 0,
322 limit: 0,
323 },
324 Vec::new(),
325 )
326 .await
327 .unwrap();
328 assert_eq!(hooked.output.content, "one\nhooked");
329 assert_eq!(hooked.output.total_lines, Some(3));
330 }
331
332 #[tokio::test]
333 async fn reads_from_default_workspace_when_meta_workspace_has_no_match() {
334 let temp_dir = TestTempDir::new().await;
335 let runtime_workspace = temp_dir.path().join("runtime");
336 let home_workspace = temp_dir.path().join("home");
337 tokio::fs::create_dir_all(&runtime_workspace).await.unwrap();
338 tokio::fs::create_dir_all(&home_workspace).await.unwrap();
339 tokio::fs::write(home_workspace.join("notes.txt"), "from home")
340 .await
341 .unwrap();
342
343 let result = read_tool(&home_workspace)
344 .call(
345 mock_ctx_with_workspace(&runtime_workspace),
346 ReadFileArgs {
347 path: "notes.txt".to_string(),
348 offset: 0,
349 limit: 0,
350 },
351 Vec::new(),
352 )
353 .await
354 .unwrap();
355
356 assert_eq!(result.output.content, "from home");
357 assert_eq!(result.output.encoding, "utf8");
358 }
359
360 #[tokio::test]
361 async fn applies_offset_when_limit_is_zero() {
362 let temp_dir = TestTempDir::new().await;
363 let workspace = temp_dir.path().join("workspace");
364 tokio::fs::create_dir_all(&workspace).await.unwrap();
365 tokio::fs::write(workspace.join("notes.txt"), "zero\none\ntwo\nthree\n")
366 .await
367 .unwrap();
368
369 let result = read_tool(&workspace)
370 .call(
371 mock_ctx(),
372 ReadFileArgs {
373 path: "notes.txt".to_string(),
374 offset: 1,
375 limit: 0,
376 },
377 Vec::new(),
378 )
379 .await
380 .unwrap();
381
382 assert_eq!(result.output.content, "one\ntwo\nthree");
383 assert_eq!(result.output.encoding, "utf8");
384 }
385
386 #[tokio::test]
387 async fn reads_requested_text_window() {
388 let temp_dir = TestTempDir::new().await;
389 let workspace = temp_dir.path().join("workspace");
390 tokio::fs::create_dir_all(&workspace).await.unwrap();
391 tokio::fs::write(workspace.join("notes.txt"), "zero\none\ntwo\nthree\n")
392 .await
393 .unwrap();
394
395 let result = read_tool(&workspace)
396 .call(
397 mock_ctx(),
398 ReadFileArgs {
399 path: "notes.txt".to_string(),
400 offset: 1,
401 limit: 2,
402 },
403 Vec::new(),
404 )
405 .await
406 .unwrap();
407
408 assert_eq!(result.output.content, "one\ntwo");
409 assert_eq!(result.output.size, 19);
410 }
411
412 #[tokio::test]
413 async fn truncates_oversized_text_and_binary_content() {
414 use crate::extension::fs::MAX_INLINE_CONTENT_BYTES;
415 use std::str::FromStr;
416
417 let temp_dir = TestTempDir::new().await;
418 let workspace = temp_dir.path().join("workspace");
419 tokio::fs::create_dir_all(&workspace).await.unwrap();
420
421 let line = "0123456789abcdef\n";
422 let total_lines = MAX_INLINE_CONTENT_BYTES / line.len() + 1024;
423 let text = line.repeat(total_lines);
424 tokio::fs::write(workspace.join("big.txt"), &text)
425 .await
426 .unwrap();
427
428 let tool = read_tool(&workspace);
429 let result = tool
430 .call(
431 mock_ctx(),
432 ReadFileArgs {
433 path: "big.txt".to_string(),
434 offset: 0,
435 limit: 0,
436 },
437 Vec::new(),
438 )
439 .await
440 .unwrap();
441 assert!(result.output.truncated);
442 assert!(result.output.content.len() <= MAX_INLINE_CONTENT_BYTES);
443 assert!(result.output.content.ends_with('\n'));
444 assert_eq!(result.output.total_lines, Some(total_lines));
445
446 let window = tool
448 .call(
449 mock_ctx(),
450 ReadFileArgs {
451 path: "big.txt".to_string(),
452 offset: total_lines - 2,
453 limit: 2,
454 },
455 Vec::new(),
456 )
457 .await
458 .unwrap();
459 assert!(!window.output.truncated);
460 assert_eq!(
461 window.output.content,
462 format!("{}\n{}", line.trim_end(), line.trim_end())
463 );
464
465 let mut binary = vec![0u8; MAX_INLINE_CONTENT_BYTES];
466 binary[0] = 0xff;
467 binary[1] = 0xfe;
468 tokio::fs::write(workspace.join("big.bin"), &binary)
469 .await
470 .unwrap();
471 let result = tool
472 .call(
473 mock_ctx(),
474 ReadFileArgs {
475 path: "big.bin".to_string(),
476 offset: 0,
477 limit: 0,
478 },
479 Vec::new(),
480 )
481 .await
482 .unwrap();
483 assert!(result.output.truncated);
484 assert_eq!(result.output.encoding, BASE64_ENCODING);
485 assert!(result.output.content.len() <= MAX_INLINE_CONTENT_BYTES);
486 let decoded = ByteBufB64::from_str(&result.output.content).unwrap();
488 assert_eq!(decoded.0.len(), MAX_INLINE_CONTENT_BYTES / 4 * 3);
489 assert_eq!(&decoded.0[..2], &[0xff, 0xfe]);
490 }
491
492 #[tokio::test]
493 async fn returns_base64_for_non_utf8_content() {
494 let temp_dir = TestTempDir::new().await;
495 let workspace = temp_dir.path().join("workspace");
496 let binary = vec![0xff, 0x00, 0x81, 0x7f];
497 tokio::fs::create_dir_all(&workspace).await.unwrap();
498 tokio::fs::write(workspace.join("payload.bin"), &binary)
499 .await
500 .unwrap();
501
502 let result = read_tool(&workspace)
503 .call(
504 mock_ctx(),
505 ReadFileArgs {
506 path: "payload.bin".to_string(),
507 offset: 0,
508 limit: 0,
509 },
510 Vec::new(),
511 )
512 .await
513 .unwrap();
514
515 assert_eq!(result.output.content, ByteBufB64(binary).to_base64());
516 assert_eq!(result.output.encoding, "base64");
517 assert_eq!(result.output.size, 4);
518 }
519
520 #[cfg(unix)]
521 #[tokio::test]
522 async fn reads_files_from_a_symlinked_workspace_root() {
523 use std::os::unix::fs::symlink;
524
525 let temp_dir = TestTempDir::new().await;
526 let workspace = temp_dir.path().join("workspace");
527 let workspace_link = temp_dir.path().join("workspace-link");
528 tokio::fs::create_dir_all(&workspace).await.unwrap();
529 tokio::fs::write(workspace.join("notes.txt"), "hello\nworld\n")
530 .await
531 .unwrap();
532 symlink(&workspace, &workspace_link).unwrap();
533
534 let result = read_tool(&workspace_link)
535 .call(
536 mock_ctx(),
537 ReadFileArgs {
538 path: "notes.txt".to_string(),
539 offset: 0,
540 limit: 0,
541 },
542 Vec::new(),
543 )
544 .await
545 .unwrap();
546
547 assert_eq!(result.output.content, "hello\nworld\n");
548 assert_eq!(result.output.encoding, "utf8");
549 }
550
551 #[cfg(unix)]
552 #[tokio::test]
553 async fn rejects_reading_through_symbolic_link_leaving_workspace() {
554 use std::os::unix::fs::symlink;
555
556 let temp_dir = TestTempDir::new().await;
557 let workspace = temp_dir.path().join("workspace");
558 let external = temp_dir.path().join("secret.txt");
559 tokio::fs::create_dir_all(&workspace).await.unwrap();
560 tokio::fs::write(&external, "secret").await.unwrap();
561 symlink(&external, workspace.join("secret-link.txt")).unwrap();
562
563 let err = read_tool(&workspace)
565 .call(
566 mock_ctx(),
567 ReadFileArgs {
568 path: "secret-link.txt".to_string(),
569 offset: 0,
570 limit: 0,
571 },
572 Vec::new(),
573 )
574 .await
575 .unwrap_err();
576
577 assert!(err.to_string().contains("outside the workspace"));
578 }
579
580 #[cfg(unix)]
581 #[tokio::test]
582 async fn rejects_reading_through_symbolic_linked_directory_leaving_workspace() {
583 use std::os::unix::fs::symlink;
584
585 let temp_dir = TestTempDir::new().await;
586 let workspace = temp_dir.path().join("workspace");
587 let external = temp_dir.path().join("external");
588 tokio::fs::create_dir_all(&workspace).await.unwrap();
589 tokio::fs::create_dir_all(&external).await.unwrap();
590 tokio::fs::write(external.join("secret.txt"), "secret")
591 .await
592 .unwrap();
593 symlink(&external, workspace.join("linked-dir")).unwrap();
594
595 let err = read_tool(&workspace)
597 .call(
598 mock_ctx(),
599 ReadFileArgs {
600 path: "linked-dir/secret.txt".to_string(),
601 offset: 0,
602 limit: 0,
603 },
604 Vec::new(),
605 )
606 .await
607 .unwrap_err();
608
609 assert!(err.to_string().contains("outside the workspace"));
610 }
611
612 #[tokio::test]
613 async fn rejects_absolute_path_outside_workspace() {
614 let temp_dir = TestTempDir::new().await;
615 let workspace = temp_dir.path().join("workspace");
616 let external = temp_dir.path().join("secret.txt");
617 tokio::fs::create_dir_all(&workspace).await.unwrap();
618 tokio::fs::write(&external, "secret").await.unwrap();
619
620 let err = read_tool(&workspace)
621 .call(
622 mock_ctx(),
623 ReadFileArgs {
624 path: external.to_string_lossy().into_owned(),
625 offset: 0,
626 limit: 0,
627 },
628 Vec::new(),
629 )
630 .await
631 .unwrap_err();
632
633 assert!(
634 err.to_string()
635 .contains("Access to paths outside the workspace is not allowed")
636 );
637 }
638
639 #[tokio::test]
640 async fn rejects_parent_dir_escape_outside_workspace() {
641 let temp_dir = TestTempDir::new().await;
642 let workspace = temp_dir.path().join("workspace");
643 let external = temp_dir.path().join("secret.txt");
644 tokio::fs::create_dir_all(&workspace).await.unwrap();
645 tokio::fs::write(&external, "secret").await.unwrap();
646
647 let err = read_tool(&workspace)
648 .call(
649 mock_ctx(),
650 ReadFileArgs {
651 path: "../secret.txt".to_string(),
652 offset: 0,
653 limit: 0,
654 },
655 Vec::new(),
656 )
657 .await
658 .unwrap_err();
659
660 assert!(
661 err.to_string()
662 .contains("Access to paths outside the workspace is not allowed")
663 );
664 }
665}