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_WRITE_SIZE: usize = 10 * 1024 * 1024; pub struct FileWriteSkill {
12 descriptor: SkillDescriptor,
13}
14
15impl FileWriteSkill {
16 pub fn new() -> Self {
18 Self {
19 descriptor: SkillDescriptor {
20 name: "file_write".to_string(),
21 description: "Write content to a file. Path must be within allowed directories."
22 .to_string(),
23 parameters_schema: serde_json::json!({
24 "type": "object",
25 "properties": {
26 "path": {
27 "type": "string",
28 "description": "Absolute path to the file to write"
29 },
30 "content": {
31 "type": "string",
32 "description": "Content to write to the file"
33 },
34 "append": {
35 "type": "boolean",
36 "description": "Append to file instead of overwriting (default: false)"
37 },
38 "create_dirs": {
39 "type": "boolean",
40 "description": "Create parent directories if they don't exist (default: false)"
41 }
42 },
43 "required": ["path", "content"]
44 }),
45 required_capabilities: vec![Capability::FileWrite {
46 allowed_paths: vec![], }],
48 requires_approval: false,
49 },
50 }
51 }
52}
53
54impl Default for FileWriteSkill {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60#[async_trait]
61impl Skill for FileWriteSkill {
62 fn descriptor(&self) -> &SkillDescriptor {
63 &self.descriptor
64 }
65
66 fn validate_arguments(
67 &self,
68 call: &ToolCall,
69 permissions: &PermissionSet,
70 ) -> ArgentorResult<()> {
71 let path_str = call.arguments["path"].as_str().unwrap_or_default();
72 if path_str.is_empty() {
73 return Ok(()); }
75
76 let path = Path::new(path_str);
77
78 let canonical = if path.exists() {
80 match path.canonicalize() {
81 Ok(p) => p,
82 Err(_) => return Ok(()), }
84 } else if let Some(parent) = path.parent() {
85 if parent.exists() {
86 match parent.canonicalize() {
87 Ok(p) => p.join(path.file_name().unwrap_or_default()),
88 Err(_) => return Ok(()),
89 }
90 } else {
91 path.to_path_buf()
94 }
95 } else {
96 return Ok(());
97 };
98
99 if !permissions.check_file_write_path(&canonical) {
100 return Err(ArgentorError::Security(format!(
101 "file write not permitted for path '{}'",
102 canonical.display()
103 )));
104 }
105
106 Ok(())
107 }
108
109 async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
110 let path_str = call.arguments["path"].as_str().unwrap_or_default();
111
112 if path_str.is_empty() {
113 return Ok(ToolResult::error(&call.id, "Empty path"));
114 }
115
116 let content = call.arguments["content"].as_str().unwrap_or_default();
117 let append = call.arguments["append"].as_bool().unwrap_or(false);
118 let create_dirs = call.arguments["create_dirs"].as_bool().unwrap_or(false);
119
120 if content.len() > MAX_WRITE_SIZE {
122 return Ok(ToolResult::error(
123 &call.id,
124 format!(
125 "Content too large: {} bytes (max: {} bytes)",
126 content.len(),
127 MAX_WRITE_SIZE
128 ),
129 ));
130 }
131
132 let path = Path::new(path_str);
133
134 if !path.is_absolute() {
136 return Ok(ToolResult::error(
137 &call.id,
138 format!("Path must be absolute: '{path_str}'"),
139 ));
140 }
141
142 let blocked_patterns = [
144 "/etc/",
145 "/usr/",
146 "/bin/",
147 "/sbin/",
148 "/boot/",
149 "/proc/",
150 "/sys/",
151 ".ssh/",
152 ".env",
153 ".bashrc",
154 ".zshrc",
155 ".profile",
156 ".gitconfig",
157 "credentials",
158 "id_rsa",
159 "id_ed25519",
160 ];
161
162 let path_lower = path_str.to_lowercase();
163 for pattern in &blocked_patterns {
164 if path_lower.contains(pattern) {
165 return Ok(ToolResult::error(
166 &call.id,
167 format!("Access denied: '{path_str}' matches blocked pattern"),
168 ));
169 }
170 }
171
172 let blocked_extensions = [".sh", ".bash", ".exe", ".bat", ".cmd", ".ps1"];
174 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
175 let ext_lower = format!(".{}", ext.to_lowercase());
176 if blocked_extensions.contains(&ext_lower.as_str()) {
177 return Ok(ToolResult::error(
178 &call.id,
179 format!("Access denied: writing executable files ({ext_lower}) is not allowed"),
180 ));
181 }
182 }
183
184 if create_dirs {
186 if let Some(parent) = path.parent() {
187 if let Err(e) = tokio::fs::create_dir_all(parent).await {
188 return Ok(ToolResult::error(
189 &call.id,
190 format!("Failed to create directories for '{path_str}': {e}"),
191 ));
192 }
193 }
194 }
195
196 let result = if append {
198 use tokio::io::AsyncWriteExt;
199 let mut file = match tokio::fs::OpenOptions::new()
200 .create(true)
201 .append(true)
202 .open(path)
203 .await
204 {
205 Ok(f) => f,
206 Err(e) => {
207 return Ok(ToolResult::error(
208 &call.id,
209 format!("Failed to open '{path_str}' for append: {e}"),
210 ));
211 }
212 };
213 file.write_all(content.as_bytes()).await
214 } else {
215 tokio::fs::write(path, content).await
216 };
217
218 match result {
219 Ok(()) => {
220 info!(path = %path_str, size = content.len(), append = append, "File written");
221 let response = serde_json::json!({
222 "path": path_str,
223 "bytes_written": content.len(),
224 "append": append,
225 });
226 Ok(ToolResult::success(&call.id, response.to_string()))
227 }
228 Err(e) => Ok(ToolResult::error(
229 &call.id,
230 format!("Failed to write '{path_str}': {e}"),
231 )),
232 }
233 }
234}
235
236#[cfg(test)]
237#[allow(clippy::unwrap_used, clippy::expect_used)]
238mod tests {
239 use super::*;
240
241 #[tokio::test]
242 async fn test_file_write_and_read_back() {
243 let skill = FileWriteSkill::new();
244 let dir = tempfile::tempdir().unwrap();
245 let file_path = dir.path().join("test.txt");
246 let path_str = file_path.to_str().unwrap();
247
248 let call = ToolCall {
249 id: "test_1".to_string(),
250 name: "file_write".to_string(),
251 arguments: serde_json::json!({
252 "path": path_str,
253 "content": "Hello, Argentor!"
254 }),
255 };
256 let result = skill.execute(call).await.unwrap();
257 assert!(!result.is_error, "Result: {}", result.content);
258
259 let content = tokio::fs::read_to_string(&file_path).await.unwrap();
260 assert_eq!(content, "Hello, Argentor!");
261 }
262
263 #[tokio::test]
264 async fn test_file_write_append() {
265 let skill = FileWriteSkill::new();
266 let dir = tempfile::tempdir().unwrap();
267 let file_path = dir.path().join("append.txt");
268 let path_str = file_path.to_str().unwrap();
269
270 let call1 = ToolCall {
272 id: "test_2a".to_string(),
273 name: "file_write".to_string(),
274 arguments: serde_json::json!({
275 "path": path_str,
276 "content": "Line 1\n"
277 }),
278 };
279 skill.execute(call1).await.unwrap();
280
281 let call2 = ToolCall {
283 id: "test_2b".to_string(),
284 name: "file_write".to_string(),
285 arguments: serde_json::json!({
286 "path": path_str,
287 "content": "Line 2\n",
288 "append": true
289 }),
290 };
291 let result = skill.execute(call2).await.unwrap();
292 assert!(!result.is_error);
293
294 let content = tokio::fs::read_to_string(&file_path).await.unwrap();
295 assert_eq!(content, "Line 1\nLine 2\n");
296 }
297
298 #[tokio::test]
299 async fn test_file_write_create_dirs() {
300 let skill = FileWriteSkill::new();
301 let dir = tempfile::tempdir().unwrap();
302 let file_path = dir.path().join("a/b/c/deep.txt");
303 let path_str = file_path.to_str().unwrap();
304
305 let call = ToolCall {
306 id: "test_3".to_string(),
307 name: "file_write".to_string(),
308 arguments: serde_json::json!({
309 "path": path_str,
310 "content": "deep file",
311 "create_dirs": true
312 }),
313 };
314 let result = skill.execute(call).await.unwrap();
315 assert!(!result.is_error, "Result: {}", result.content);
316
317 let content = tokio::fs::read_to_string(&file_path).await.unwrap();
318 assert_eq!(content, "deep file");
319 }
320
321 #[tokio::test]
322 async fn test_file_write_blocked_path() {
323 let skill = FileWriteSkill::new();
324 let call = ToolCall {
325 id: "test_4".to_string(),
326 name: "file_write".to_string(),
327 arguments: serde_json::json!({
328 "path": "/etc/passwd",
329 "content": "malicious"
330 }),
331 };
332 let result = skill.execute(call).await.unwrap();
333 assert!(result.is_error);
334 assert!(result.content.contains("blocked"));
335 }
336
337 #[tokio::test]
338 async fn test_file_write_blocks_executables() {
339 let skill = FileWriteSkill::new();
340 let dir = tempfile::tempdir().unwrap();
341 let file_path = dir.path().join("evil.sh");
342 let path_str = file_path.to_str().unwrap();
343
344 let call = ToolCall {
345 id: "test_5".to_string(),
346 name: "file_write".to_string(),
347 arguments: serde_json::json!({
348 "path": path_str,
349 "content": "#!/bin/bash\nrm -rf /"
350 }),
351 };
352 let result = skill.execute(call).await.unwrap();
353 assert!(result.is_error);
354 assert!(result.content.contains("executable"));
355 }
356
357 #[tokio::test]
358 async fn test_file_write_empty_path() {
359 let skill = FileWriteSkill::new();
360 let call = ToolCall {
361 id: "test_6".to_string(),
362 name: "file_write".to_string(),
363 arguments: serde_json::json!({"path": "", "content": "x"}),
364 };
365 let result = skill.execute(call).await.unwrap();
366 assert!(result.is_error);
367 }
368
369 #[tokio::test]
370 async fn test_file_write_relative_path_rejected() {
371 let skill = FileWriteSkill::new();
372 let call = ToolCall {
373 id: "test_7".to_string(),
374 name: "file_write".to_string(),
375 arguments: serde_json::json!({
376 "path": "relative/path.txt",
377 "content": "x"
378 }),
379 };
380 let result = skill.execute(call).await.unwrap();
381 assert!(result.is_error);
382 assert!(result.content.contains("absolute"));
383 }
384
385 #[tokio::test]
386 async fn test_file_write_blocks_ssh_key() {
387 let skill = FileWriteSkill::new();
388 let call = ToolCall {
389 id: "test_8".to_string(),
390 name: "file_write".to_string(),
391 arguments: serde_json::json!({
392 "path": "/home/user/.ssh/id_rsa",
393 "content": "fake key"
394 }),
395 };
396 let result = skill.execute(call).await.unwrap();
397 assert!(result.is_error);
398 }
399
400 #[test]
401 fn test_validate_arguments_denies_disallowed_path() {
402 let skill = FileWriteSkill::new();
403 let mut perms = PermissionSet::new();
404 perms.grant(Capability::FileWrite {
405 allowed_paths: vec!["/allowed".to_string()],
406 });
407
408 let call = ToolCall {
409 id: "test_va_1".to_string(),
410 name: "file_write".to_string(),
411 arguments: serde_json::json!({"path": "/tmp/some_file.txt", "content": "x"}),
412 };
413 let result = skill.validate_arguments(&call, &perms);
414 assert!(result.is_err());
415 }
416
417 #[test]
418 fn test_validate_arguments_allows_permitted_path() {
419 let skill = FileWriteSkill::new();
420 let mut perms = PermissionSet::new();
421 perms.grant(Capability::FileWrite {
422 allowed_paths: vec!["/tmp".to_string()],
423 });
424
425 let call = ToolCall {
426 id: "test_va_2".to_string(),
427 name: "file_write".to_string(),
428 arguments: serde_json::json!({"path": "/tmp/some_file.txt", "content": "x"}),
429 };
430 let result = skill.validate_arguments(&call, &perms);
431 assert!(result.is_ok());
432 }
433}