Skip to main content

argentor_builtins/
file_read.rs

1use argentor_core::{ArgentorError, ArgentorResult, ToolCall, ToolResult};
2use argentor_security::{Capability, PermissionSet};
3use argentor_skills::skill::{Skill, SkillDescriptor};
4use async_trait::async_trait;
5use std::path::Path;
6use tracing::info;
7
8const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
9
10/// File reading skill. Reads file contents with path validation.
11pub struct FileReadSkill {
12    descriptor: SkillDescriptor,
13}
14
15impl FileReadSkill {
16    /// Create a new file read skill.
17    pub fn new() -> Self {
18        Self {
19            descriptor: SkillDescriptor {
20                name: "file_read".to_string(),
21                description:
22                    "Read the contents of a file. Path must be within allowed directories."
23                        .to_string(),
24                parameters_schema: serde_json::json!({
25                    "type": "object",
26                    "properties": {
27                        "path": {
28                            "type": "string",
29                            "description": "Absolute path to the file to read"
30                        },
31                        "offset": {
32                            "type": "integer",
33                            "description": "Byte offset to start reading from (default: 0)"
34                        },
35                        "limit": {
36                            "type": "integer",
37                            "description": "Maximum bytes to read (default: entire file, max: 10MB)"
38                        }
39                    },
40                    "required": ["path"]
41                }),
42                required_capabilities: vec![Capability::FileRead {
43                    allowed_paths: vec![], // Configured at runtime
44                }],
45                requires_approval: false,
46            },
47        }
48    }
49}
50
51impl Default for FileReadSkill {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57#[async_trait]
58impl Skill for FileReadSkill {
59    fn descriptor(&self) -> &SkillDescriptor {
60        &self.descriptor
61    }
62
63    fn validate_arguments(
64        &self,
65        call: &ToolCall,
66        permissions: &PermissionSet,
67    ) -> ArgentorResult<()> {
68        let path_str = call.arguments["path"].as_str().unwrap_or_default();
69        if path_str.is_empty() {
70            return Ok(()); // Empty path will be caught in execute()
71        }
72
73        let path = Path::new(path_str);
74
75        // Canonicalize the path to resolve symlinks and ".." components.
76        // If the file doesn't exist yet, canonicalize the parent directory.
77        let canonical = if path.exists() {
78            match path.canonicalize() {
79                Ok(p) => p,
80                Err(_) => return Ok(()), // Let execute() handle the error
81            }
82        } else if let Some(parent) = path.parent() {
83            match parent.canonicalize() {
84                Ok(p) => p.join(path.file_name().unwrap_or_default()),
85                Err(_) => return Ok(()), // Let execute() handle the error
86            }
87        } else {
88            return Ok(());
89        };
90
91        if !permissions.check_file_read_path(&canonical) {
92            return Err(ArgentorError::Security(format!(
93                "file read not permitted for path '{}'",
94                canonical.display()
95            )));
96        }
97
98        Ok(())
99    }
100
101    async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
102        let path_str = call.arguments["path"].as_str().unwrap_or_default();
103
104        if path_str.is_empty() {
105            return Ok(ToolResult::error(&call.id, "Empty path"));
106        }
107
108        let path = Path::new(path_str);
109
110        // Resolve symlinks and canonicalize to prevent path traversal
111        let canonical = match tokio::fs::canonicalize(path).await {
112            Ok(p) => p,
113            Err(e) => {
114                return Ok(ToolResult::error(
115                    &call.id,
116                    format!("Cannot resolve path '{path_str}': {e}"),
117                ));
118            }
119        };
120
121        // Block reading sensitive files
122        let blocked_patterns = [
123            "/etc/shadow",
124            "/etc/passwd",
125            ".ssh/",
126            ".env",
127            "credentials",
128            "secret",
129            ".aws/",
130        ];
131        let canonical_str = canonical.to_string_lossy();
132        for pattern in &blocked_patterns {
133            if canonical_str.contains(pattern) {
134                return Ok(ToolResult::error(
135                    &call.id,
136                    format!("Access denied: '{path_str}' matches blocked pattern"),
137                ));
138            }
139        }
140
141        // Check file size
142        let metadata = match tokio::fs::metadata(&canonical).await {
143            Ok(m) => m,
144            Err(e) => {
145                return Ok(ToolResult::error(
146                    &call.id,
147                    format!("Cannot read metadata for '{path_str}': {e}"),
148                ));
149            }
150        };
151
152        if !metadata.is_file() {
153            return Ok(ToolResult::error(
154                &call.id,
155                format!("'{path_str}' is not a file"),
156            ));
157        }
158
159        if metadata.len() > MAX_FILE_SIZE {
160            return Ok(ToolResult::error(
161                &call.id,
162                format!(
163                    "File too large: {} bytes (max: {} bytes)",
164                    metadata.len(),
165                    MAX_FILE_SIZE
166                ),
167            ));
168        }
169
170        info!(path = %canonical.display(), size = metadata.len(), "Reading file");
171
172        let content = match tokio::fs::read_to_string(&canonical).await {
173            Ok(c) => c,
174            Err(e) => {
175                // Try reading as binary if not valid UTF-8
176                match tokio::fs::read(&canonical).await {
177                    Ok(bytes) => {
178                        return Ok(ToolResult::success(
179                            &call.id,
180                            serde_json::json!({
181                                "path": canonical_str,
182                                "size": bytes.len(),
183                                "encoding": "binary",
184                                "note": format!("File is not valid UTF-8: {}", e),
185                            })
186                            .to_string(),
187                        ));
188                    }
189                    Err(e2) => {
190                        return Ok(ToolResult::error(
191                            &call.id,
192                            format!("Failed to read '{path_str}': {e2}"),
193                        ));
194                    }
195                }
196            }
197        };
198
199        let offset = call.arguments["offset"].as_u64().unwrap_or(0) as usize;
200        let limit = call.arguments["limit"]
201            .as_u64()
202            .map(|l| l as usize)
203            .unwrap_or(content.len());
204
205        let slice = if offset < content.len() {
206            &content[offset..content.len().min(offset + limit)]
207        } else {
208            ""
209        };
210
211        let response = serde_json::json!({
212            "path": canonical_str,
213            "size": metadata.len(),
214            "content": slice,
215        });
216
217        Ok(ToolResult::success(&call.id, response.to_string()))
218    }
219}
220
221#[cfg(test)]
222#[allow(clippy::unwrap_used, clippy::expect_used)]
223mod tests {
224    use super::*;
225
226    #[tokio::test]
227    async fn test_file_read_self() {
228        let skill = FileReadSkill::new();
229        // Read our own source file using absolute path
230        let path = format!("{}/src/file_read.rs", env!("CARGO_MANIFEST_DIR"));
231        let call = ToolCall {
232            id: "test_1".to_string(),
233            name: "file_read".to_string(),
234            arguments: serde_json::json!({"path": path}),
235        };
236        let result = skill.execute(call).await.unwrap();
237        assert!(!result.is_error, "Result: {}", result.content);
238        assert!(result.content.contains("FileReadSkill"));
239    }
240
241    #[tokio::test]
242    async fn test_file_read_blocked_path() {
243        let skill = FileReadSkill::new();
244        let call = ToolCall {
245            id: "test_2".to_string(),
246            name: "file_read".to_string(),
247            arguments: serde_json::json!({"path": "/etc/shadow"}),
248        };
249        let result = skill.execute(call).await.unwrap();
250        assert!(result.is_error);
251    }
252
253    #[tokio::test]
254    async fn test_file_read_nonexistent() {
255        let skill = FileReadSkill::new();
256        let call = ToolCall {
257            id: "test_3".to_string(),
258            name: "file_read".to_string(),
259            arguments: serde_json::json!({"path": "/tmp/argentor_nonexistent_file_12345"}),
260        };
261        let result = skill.execute(call).await.unwrap();
262        assert!(result.is_error);
263    }
264
265    #[tokio::test]
266    async fn test_file_read_empty_path() {
267        let skill = FileReadSkill::new();
268        let call = ToolCall {
269            id: "test_4".to_string(),
270            name: "file_read".to_string(),
271            arguments: serde_json::json!({"path": ""}),
272        };
273        let result = skill.execute(call).await.unwrap();
274        assert!(result.is_error);
275    }
276
277    #[test]
278    fn test_validate_arguments_denies_disallowed_path() {
279        let skill = FileReadSkill::new();
280        let mut perms = PermissionSet::new();
281        perms.grant(Capability::FileRead {
282            allowed_paths: vec!["/allowed".to_string()],
283        });
284
285        // Use a path that exists so canonicalize works — /tmp always exists
286        let call = ToolCall {
287            id: "test_va_1".to_string(),
288            name: "file_read".to_string(),
289            arguments: serde_json::json!({"path": "/tmp/some_file.txt"}),
290        };
291        let result = skill.validate_arguments(&call, &perms);
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn test_validate_arguments_allows_permitted_path() {
297        let skill = FileReadSkill::new();
298        let mut perms = PermissionSet::new();
299        perms.grant(Capability::FileRead {
300            allowed_paths: vec!["/tmp".to_string()],
301        });
302
303        let call = ToolCall {
304            id: "test_va_2".to_string(),
305            name: "file_read".to_string(),
306            arguments: serde_json::json!({"path": "/tmp/some_file.txt"}),
307        };
308        let result = skill.validate_arguments(&call, &perms);
309        assert!(result.is_ok());
310    }
311}