Skip to main content

argentor_builtins/
file_write.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_WRITE_SIZE: usize = 10 * 1024 * 1024; // 10MB
9
10/// File writing skill. Writes content to files with path validation.
11pub struct FileWriteSkill {
12    descriptor: SkillDescriptor,
13}
14
15impl FileWriteSkill {
16    /// Create a new file write skill.
17    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![], // Configured at runtime
47                }],
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(()); // Empty path will be caught in execute()
74        }
75
76        let path = Path::new(path_str);
77
78        // For writes the file may not exist yet, so canonicalize the parent directory.
79        let canonical = if path.exists() {
80            match path.canonicalize() {
81                Ok(p) => p,
82                Err(_) => return Ok(()), // Let execute() handle the error
83            }
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                // Parent doesn't exist either — use the path as-is for the check.
92                // This may happen with create_dirs=true.
93                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        // Size check
121        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        // Must be absolute
135        if !path.is_absolute() {
136            return Ok(ToolResult::error(
137                &call.id,
138                format!("Path must be absolute: '{path_str}'"),
139            ));
140        }
141
142        // Block writing to sensitive locations
143        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        // Block writing executable files
173        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        // Create parent directories if requested
185        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        // Write or append
197        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        // Write initial content
271        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        // Append more
282        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}