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; pub struct FileReadSkill {
12 descriptor: SkillDescriptor,
13}
14
15impl FileReadSkill {
16 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![], }],
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(()); }
72
73 let path = Path::new(path_str);
74
75 let canonical = if path.exists() {
78 match path.canonicalize() {
79 Ok(p) => p,
80 Err(_) => return Ok(()), }
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(()), }
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 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 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 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 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 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 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}