Skip to main content

zeroclaw_tools/
file_write.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use zeroclaw_api::tool::{Tool, ToolResult};
5use zeroclaw_config::policy::SecurityPolicy;
6
7/// Write file contents with path sandboxing
8pub struct FileWriteTool {
9    security: Arc<SecurityPolicy>,
10}
11
12impl FileWriteTool {
13    pub fn new(security: Arc<SecurityPolicy>) -> Self {
14        Self { security }
15    }
16}
17
18#[async_trait]
19impl Tool for FileWriteTool {
20    fn name(&self) -> &str {
21        "file_write"
22    }
23
24    fn description(&self) -> &str {
25        "Write contents to a file in the workspace"
26    }
27
28    fn parameters_schema(&self) -> serde_json::Value {
29        json!({
30            "type": "object",
31            "properties": {
32                "path": {
33                    "type": "string",
34                    "description": "Path to the file. Relative paths resolve from workspace; outside paths require policy allowlist."
35                },
36                "content": {
37                    "type": "string",
38                    "description": "Content to write to the file"
39                }
40            },
41            "required": ["path", "content"]
42        })
43    }
44
45    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
46        let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
47            ::zeroclaw_log::record!(
48                WARN,
49                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
50                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
51                    .with_attrs(::serde_json::json!({"param": "path"})),
52                "file_write: missing path parameter"
53            );
54            anyhow::Error::msg("Missing 'path' parameter")
55        })?;
56
57        let content = args
58            .get("content")
59            .and_then(|v| v.as_str())
60            .ok_or_else(|| {
61                ::zeroclaw_log::record!(
62                    WARN,
63                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
64                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
65                        .with_attrs(::serde_json::json!({"param": "content"})),
66                    "file_write: missing content parameter"
67                );
68                anyhow::Error::msg("Missing 'content' parameter")
69            })?;
70
71        if !self.security.can_act() {
72            return Ok(ToolResult {
73                success: false,
74                output: String::new(),
75                error: Some("Action blocked: autonomy is read-only".into()),
76            });
77        }
78
79        // Rate limiting and path-allowlist checks are applied by the
80        // RateLimitedTool + PathGuardedTool wrappers at registration time
81        // (see zeroclaw-runtime::tools::mod).
82
83        let full_path = self.security.resolve_tool_path(path);
84
85        let Some(parent) = full_path.parent() else {
86            return Ok(ToolResult {
87                success: false,
88                output: String::new(),
89                error: Some("Invalid path: missing parent directory".into()),
90            });
91        };
92
93        // Ensure parent directory exists before canonicalising.
94        tokio::fs::create_dir_all(parent).await?;
95
96        // Canonicalise parent AFTER creation to detect symlink escapes.
97        let resolved_parent = match tokio::fs::canonicalize(parent).await {
98            Ok(p) => p,
99            Err(e) => {
100                return Ok(ToolResult {
101                    success: false,
102                    output: String::new(),
103                    error: Some(format!("Failed to resolve file path: {e}")),
104                });
105            }
106        };
107
108        if !self.security.is_resolved_path_allowed(&resolved_parent) {
109            return Ok(ToolResult {
110                success: false,
111                output: String::new(),
112                error: Some(
113                    self.security
114                        .resolved_path_violation_message(&resolved_parent),
115                ),
116            });
117        }
118
119        let Some(file_name) = full_path.file_name() else {
120            return Ok(ToolResult {
121                success: false,
122                output: String::new(),
123                error: Some("Invalid path: missing file name".into()),
124            });
125        };
126
127        let resolved_target = resolved_parent.join(file_name);
128
129        if self.security.is_runtime_config_path(&resolved_target) {
130            return Ok(ToolResult {
131                success: false,
132                output: String::new(),
133                error: Some(
134                    self.security
135                        .runtime_config_violation_message(&resolved_target),
136                ),
137            });
138        }
139
140        // If the target already exists and is a symlink, refuse to follow it
141        if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await
142            && meta.file_type().is_symlink()
143        {
144            return Ok(ToolResult {
145                success: false,
146                output: String::new(),
147                error: Some(format!(
148                    "Refusing to write through symlink: {}",
149                    resolved_target.display()
150                )),
151            });
152        }
153
154        match tokio::fs::write(&resolved_target, content).await {
155            Ok(()) => Ok(ToolResult {
156                success: true,
157                output: format!("Written {} bytes to {path}", content.len()),
158                error: None,
159            }),
160            Err(e) => Ok(ToolResult {
161                success: false,
162                output: String::new(),
163                error: Some(format!("Failed to write file: {e}")),
164            }),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::wrappers::{PathGuardedTool, RateLimitedTool};
173    use zeroclaw_config::autonomy::AutonomyLevel;
174    use zeroclaw_config::policy::SecurityPolicy;
175
176    fn test_tool(workspace: std::path::PathBuf) -> FileWriteTool {
177        let security = Arc::new(SecurityPolicy {
178            autonomy: AutonomyLevel::Supervised,
179            workspace_dir: workspace,
180            ..SecurityPolicy::default()
181        });
182        FileWriteTool::new(security)
183    }
184
185    /// Wraps `FileWriteTool` with the production `PathGuardedTool` + `RateLimitedTool`
186    /// stack, mirroring the registration in `zeroclaw-runtime::tools::mod`. Use this
187    /// in tests that exercise path-allowlist or rate-limit behavior.
188    fn wrapped_tool(workspace: std::path::PathBuf) -> Box<dyn Tool> {
189        let security = Arc::new(SecurityPolicy {
190            autonomy: AutonomyLevel::Supervised,
191            workspace_dir: workspace,
192            ..SecurityPolicy::default()
193        });
194        Box::new(RateLimitedTool::new(
195            PathGuardedTool::new(FileWriteTool::new(security.clone()), security.clone()),
196            security,
197        ))
198    }
199
200    fn test_tool_with(
201        workspace: std::path::PathBuf,
202        autonomy: AutonomyLevel,
203        max_actions_per_hour: u32,
204    ) -> FileWriteTool {
205        let security = Arc::new(SecurityPolicy {
206            autonomy,
207            workspace_dir: workspace,
208            max_actions_per_hour,
209            ..SecurityPolicy::default()
210        });
211        FileWriteTool::new(security)
212    }
213
214    #[test]
215    fn file_write_name() {
216        let tool = test_tool(std::env::temp_dir());
217        assert_eq!(tool.name(), "file_write");
218    }
219
220    #[test]
221    fn file_write_schema_has_path_and_content() {
222        let tool = test_tool(std::env::temp_dir());
223        let schema = tool.parameters_schema();
224        assert!(schema["properties"]["path"].is_object());
225        assert!(schema["properties"]["content"].is_object());
226        let required = schema["required"].as_array().unwrap();
227        assert!(required.contains(&json!("path")));
228        assert!(required.contains(&json!("content")));
229    }
230
231    #[tokio::test]
232    async fn file_write_creates_file() {
233        let dir = std::env::temp_dir().join("zeroclaw_test_file_write");
234        let _ = tokio::fs::remove_dir_all(&dir).await;
235        tokio::fs::create_dir_all(&dir).await.unwrap();
236
237        let tool = test_tool(dir.clone());
238        let result = tool
239            .execute(json!({"path": "out.txt", "content": "written!"}))
240            .await
241            .unwrap();
242        assert!(result.success);
243        assert!(result.output.contains("8 bytes"));
244
245        let content = tokio::fs::read_to_string(dir.join("out.txt"))
246            .await
247            .unwrap();
248        assert_eq!(content, "written!");
249
250        let _ = tokio::fs::remove_dir_all(&dir).await;
251    }
252
253    #[tokio::test]
254    async fn file_write_creates_parent_dirs() {
255        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_nested");
256        let _ = tokio::fs::remove_dir_all(&dir).await;
257        tokio::fs::create_dir_all(&dir).await.unwrap();
258
259        let tool = test_tool(dir.clone());
260        let result = tool
261            .execute(json!({"path": "a/b/c/deep.txt", "content": "deep"}))
262            .await
263            .unwrap();
264        assert!(result.success);
265
266        let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt"))
267            .await
268            .unwrap();
269        assert_eq!(content, "deep");
270
271        let _ = tokio::fs::remove_dir_all(&dir).await;
272    }
273
274    #[tokio::test]
275    async fn file_write_normalizes_workspace_prefixed_relative_path() {
276        let root = std::env::temp_dir().join("zeroclaw_test_file_write_workspace_prefixed");
277        let workspace = root.join("workspace");
278        let _ = tokio::fs::remove_dir_all(&root).await;
279        tokio::fs::create_dir_all(&workspace).await.unwrap();
280
281        let tool = test_tool(workspace.clone());
282        let workspace_prefixed = workspace
283            .strip_prefix(std::path::Path::new("/"))
284            .unwrap()
285            .join("nested/out.txt");
286        let result = tool
287            .execute(json!({
288                "path": workspace_prefixed.to_string_lossy(),
289                "content": "written!"
290            }))
291            .await
292            .unwrap();
293        assert!(result.success);
294
295        let content = tokio::fs::read_to_string(workspace.join("nested/out.txt"))
296            .await
297            .unwrap();
298        assert_eq!(content, "written!");
299        assert!(!workspace.join(workspace_prefixed).exists());
300
301        let _ = tokio::fs::remove_dir_all(&root).await;
302    }
303
304    #[tokio::test]
305    async fn file_write_overwrites_existing() {
306        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite");
307        let _ = tokio::fs::remove_dir_all(&dir).await;
308        tokio::fs::create_dir_all(&dir).await.unwrap();
309        tokio::fs::write(dir.join("exist.txt"), "old")
310            .await
311            .unwrap();
312
313        let tool = test_tool(dir.clone());
314        let result = tool
315            .execute(json!({"path": "exist.txt", "content": "new"}))
316            .await
317            .unwrap();
318        assert!(result.success);
319
320        let content = tokio::fs::read_to_string(dir.join("exist.txt"))
321            .await
322            .unwrap();
323        assert_eq!(content, "new");
324
325        let _ = tokio::fs::remove_dir_all(&dir).await;
326    }
327
328    #[tokio::test]
329    async fn file_write_blocks_path_traversal() {
330        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_traversal");
331        let _ = tokio::fs::remove_dir_all(&dir).await;
332        tokio::fs::create_dir_all(&dir).await.unwrap();
333
334        let tool = wrapped_tool(dir.clone());
335        let result = tool
336            .execute(json!({"path": "../../etc/evil", "content": "bad"}))
337            .await
338            .unwrap();
339        assert!(!result.success);
340        assert!(
341            result.error.as_ref().unwrap().contains("Path blocked"),
342            "expected 'Path blocked' error, got: {:?}",
343            result.error
344        );
345
346        let _ = tokio::fs::remove_dir_all(&dir).await;
347    }
348
349    #[tokio::test]
350    async fn file_write_blocks_absolute_path() {
351        let tool = wrapped_tool(std::env::temp_dir());
352        let result = tool
353            .execute(json!({"path": "/etc/evil", "content": "bad"}))
354            .await
355            .unwrap();
356        assert!(!result.success);
357        assert!(
358            result.error.as_ref().unwrap().contains("Path blocked"),
359            "expected 'Path blocked' error, got: {:?}",
360            result.error
361        );
362    }
363
364    #[tokio::test]
365    async fn file_write_missing_path_param() {
366        let tool = test_tool(std::env::temp_dir());
367        let result = tool.execute(json!({"content": "data"})).await;
368        assert!(result.is_err());
369    }
370
371    #[tokio::test]
372    async fn file_write_missing_content_param() {
373        let tool = test_tool(std::env::temp_dir());
374        let result = tool.execute(json!({"path": "file.txt"})).await;
375        assert!(result.is_err());
376    }
377
378    #[tokio::test]
379    async fn file_write_empty_content() {
380        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_empty");
381        let _ = tokio::fs::remove_dir_all(&dir).await;
382        tokio::fs::create_dir_all(&dir).await.unwrap();
383
384        let tool = test_tool(dir.clone());
385        let result = tool
386            .execute(json!({"path": "empty.txt", "content": ""}))
387            .await
388            .unwrap();
389        assert!(result.success);
390        assert!(result.output.contains("0 bytes"));
391
392        let _ = tokio::fs::remove_dir_all(&dir).await;
393    }
394
395    #[cfg(unix)]
396    #[tokio::test]
397    async fn file_write_blocks_symlink_escape() {
398        use std::os::unix::fs::symlink;
399
400        let root = std::env::temp_dir().join("zeroclaw_test_file_write_symlink_escape");
401        let workspace = root.join("workspace");
402        let outside = root.join("outside");
403
404        let _ = tokio::fs::remove_dir_all(&root).await;
405        tokio::fs::create_dir_all(&workspace).await.unwrap();
406        tokio::fs::create_dir_all(&outside).await.unwrap();
407
408        symlink(&outside, workspace.join("escape_dir")).unwrap();
409
410        let tool = test_tool(workspace.clone());
411        let result = tool
412            .execute(json!({"path": "escape_dir/hijack.txt", "content": "bad"}))
413            .await
414            .unwrap();
415
416        assert!(!result.success);
417        assert!(
418            result
419                .error
420                .as_deref()
421                .unwrap_or("")
422                .contains("escapes workspace")
423        );
424        assert!(!outside.join("hijack.txt").exists());
425
426        let _ = tokio::fs::remove_dir_all(&root).await;
427    }
428
429    #[tokio::test]
430    async fn file_write_blocks_readonly_mode() {
431        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_readonly");
432        let _ = tokio::fs::remove_dir_all(&dir).await;
433        tokio::fs::create_dir_all(&dir).await.unwrap();
434
435        let tool = test_tool_with(dir.clone(), AutonomyLevel::ReadOnly, 20);
436        let result = tool
437            .execute(json!({"path": "out.txt", "content": "should-block"}))
438            .await
439            .unwrap();
440
441        assert!(!result.success);
442        assert!(result.error.as_deref().unwrap_or("").contains("read-only"));
443        assert!(!dir.join("out.txt").exists());
444
445        let _ = tokio::fs::remove_dir_all(&dir).await;
446    }
447
448    #[cfg(unix)]
449    #[tokio::test]
450    async fn file_write_blocks_symlink_target_file() {
451        use std::os::unix::fs::symlink;
452
453        let root = std::env::temp_dir().join("zeroclaw_test_file_write_symlink_target");
454        let workspace = root.join("workspace");
455        let outside = root.join("outside");
456
457        let _ = tokio::fs::remove_dir_all(&root).await;
458        tokio::fs::create_dir_all(&workspace).await.unwrap();
459        tokio::fs::create_dir_all(&outside).await.unwrap();
460
461        tokio::fs::write(outside.join("target.txt"), "original")
462            .await
463            .unwrap();
464        symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
465
466        let tool = test_tool(workspace.clone());
467        let result = tool
468            .execute(json!({"path": "linked.txt", "content": "overwritten"}))
469            .await
470            .unwrap();
471
472        assert!(!result.success, "writing through symlink must be blocked");
473        assert!(
474            result.error.as_deref().unwrap_or("").contains("symlink"),
475            "error should mention symlink"
476        );
477
478        let content = tokio::fs::read_to_string(outside.join("target.txt"))
479            .await
480            .unwrap();
481        assert_eq!(content, "original", "original file must not be modified");
482
483        let _ = tokio::fs::remove_dir_all(&root).await;
484    }
485
486    #[tokio::test]
487    async fn file_write_absolute_path_in_workspace() {
488        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_abs_path");
489        let _ = tokio::fs::remove_dir_all(&dir).await;
490        tokio::fs::create_dir_all(&dir).await.unwrap();
491
492        // Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
493        let dir = tokio::fs::canonicalize(&dir).await.unwrap();
494
495        let tool = test_tool(dir.clone());
496
497        let abs_path = dir.join("abs_test.txt");
498        let result = tool
499            .execute(
500                json!({"path": abs_path.to_string_lossy().to_string(), "content": "absolute!"}),
501            )
502            .await
503            .unwrap();
504
505        assert!(
506            result.success,
507            "writing via absolute workspace path should succeed, error: {:?}",
508            result.error
509        );
510
511        let content = tokio::fs::read_to_string(dir.join("abs_test.txt"))
512            .await
513            .unwrap();
514        assert_eq!(content, "absolute!");
515
516        let _ = tokio::fs::remove_dir_all(&dir).await;
517    }
518
519    #[tokio::test]
520    async fn file_write_blocks_null_byte_in_path() {
521        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_null");
522        let _ = tokio::fs::remove_dir_all(&dir).await;
523        tokio::fs::create_dir_all(&dir).await.unwrap();
524
525        let tool = test_tool(dir.clone());
526        let result = tool
527            .execute(json!({"path": "file\u{0000}.txt", "content": "bad"}))
528            .await
529            .unwrap();
530        assert!(!result.success, "paths with null bytes must be blocked");
531
532        let _ = tokio::fs::remove_dir_all(&dir).await;
533    }
534
535    #[tokio::test]
536    async fn file_write_blocks_path_outside_workspace() {
537        let root = std::env::temp_dir().join("zeroclaw_test_file_write_outside_workspace");
538        let workspace = root.join("workspace");
539        let outside_file = root.join("outside.txt");
540        let _ = tokio::fs::remove_dir_all(&root).await;
541        tokio::fs::create_dir_all(&workspace).await.unwrap();
542
543        let tool = test_tool(workspace.clone());
544        let result = tool
545            .execute(json!({
546                "path": outside_file.to_string_lossy(),
547                "content": "should-block"
548            }))
549            .await
550            .unwrap();
551
552        assert!(!result.success);
553        assert!(!outside_file.exists());
554
555        let _ = tokio::fs::remove_dir_all(&root).await;
556    }
557}