Skip to main content

anda_engine/extension/fs/
read.rs

1//! File read tool for configured workspaces.
2//!
3//! Text files are decoded with platform-aware fallbacks, while binary or
4//! unsupported files are returned as base64. Large inline output is truncated
5//! with paging metadata.
6
7use 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/// Arguments for filesystem read operations.
26#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
27pub struct ReadFileArgs {
28    /// Path to the file. Relative paths resolve from the configured workspaces in priority order; absolute paths must be inside one configured workspace.
29    pub path: String,
30    /// Zero-based line offset for decoded text output (default: 0)
31    #[serde(default)]
32    pub offset: usize,
33    /// Maximum number of decoded text lines to return (default: 0, all remaining lines). Responses are capped at 256KiB and marked with `truncated: true` when cut; use offset and limit to page through large files.
34    #[serde(default)]
35    pub limit: usize,
36}
37
38/// Normalized result returned by a filesystem read operation.
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40pub struct ReadFileOutput {
41    /// File content as decoded text or base64-encoded bytes for unsupported/binary files.
42    pub content: String,
43    /// The encoding of the file content.
44    pub encoding: String,
45    /// The size of the file in bytes.
46    pub size: u64,
47    /// The MIME type of the file content.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub mime_type: Option<String>,
50    /// The number of lines in the file content, if the content is decoded text.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub total_lines: Option<usize>,
53    /// True when `content` was truncated to the inline output limit. Page through
54    /// large text files with `offset` and `limit`.
55    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
56    pub truncated: bool,
57}
58
59/// Typed hook for read-file tool calls.
60pub type ReadFileHook = DynToolHook<ReadFileArgs, ReadFileOutput>;
61
62/// Tool implementation for reading files inside configured workspaces.
63#[derive(Clone)]
64pub struct ReadFileTool {
65    workspaces: Vec<PathBuf>,
66    description: String,
67}
68
69impl ReadFileTool {
70    /// Tool name used for registration and function definition.
71    pub const NAME: &'static str = "read_file";
72
73    /// Create a new `ReadFileTool` with the default workspace directory.
74    /// A call may narrow the workspace by including `workspace` or `workspaces` in the tool
75    /// call's context meta extra. Request metadata is caller-controlled, so a requested
76    /// directory is honored only when it resolves inside a configured workspace.
77    pub fn new(workspace: PathBuf) -> Self {
78        Self::with_workspaces([workspace])
79    }
80
81    /// Create a new `ReadFileTool` with the default workspace directories.
82    /// A requested workspace that resolves inside one of these takes precedence at call
83    /// time; one that does not is ignored, so these bound everything the tool can reach.
84    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    /// Overrides the function description exposed to the model.
100    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                    // Cap binary previews as well; keep the length a multiple of 3 so the
183                    // base64 prefix decodes cleanly.
184                    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        // Paging through the same file stays untruncated.
447        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        // The truncated base64 prefix still decodes to the head of the file.
487        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        // A workspace-local symlink whose target escapes the workspace must not expose host files.
564        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        // A symlinked directory that points outside the workspace must not expose its contents.
596        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}