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    /// Whether writes to the workspace will persist on the host filesystem.
11    /// `false` when the runtime uses an ephemeral sandbox (e.g. Docker without
12    /// a workspace volume mount), in which case writes succeed inside the
13    /// container but are invisible on the host.
14    persistent_writes: bool,
15}
16
17impl FileWriteTool {
18    pub fn new(security: Arc<SecurityPolicy>) -> Self {
19        Self {
20            security,
21            persistent_writes: true,
22        }
23    }
24
25    /// Construct with an explicit persistence flag derived from the active
26    /// runtime adapter's `has_filesystem_access()`.
27    pub fn new_with_persistence(security: Arc<SecurityPolicy>, persistent_writes: bool) -> Self {
28        Self {
29            security,
30            persistent_writes,
31        }
32    }
33}
34
35#[async_trait]
36impl Tool for FileWriteTool {
37    fn name(&self) -> &str {
38        "file_write"
39    }
40
41    fn description(&self) -> &str {
42        "Write contents to a file in the workspace. Text by default; set encoding=\"base64\" to write binary files (e.g. .xlsx/.docx) by decoding base64 content into raw bytes."
43    }
44
45    fn parameters_schema(&self) -> serde_json::Value {
46        json!({
47            "type": "object",
48            "properties": {
49                "path": {
50                    "type": "string",
51                    "description": "Path to the file. Relative paths resolve from workspace; outside paths require policy allowlist."
52                },
53                "content": {
54                    "type": "string",
55                    "description": "Content to write. UTF-8 text when encoding is 'utf8'; base64-encoded bytes when encoding is 'base64'."
56                },
57                "encoding": {
58                    "type": "string",
59                    "enum": ["utf8", "base64"],
60                    "description": "How to interpret 'content' before writing (default: 'utf8'). Use 'base64' for binary files."
61                }
62            },
63            "required": ["path", "content"]
64        })
65    }
66
67    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
68        let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
69            ::zeroclaw_log::record!(
70                WARN,
71                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
72                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
73                    .with_attrs(::serde_json::json!({"param": "path"})),
74                "file_write: missing path parameter"
75            );
76            anyhow::Error::msg("Missing 'path' parameter")
77        })?;
78
79        let content = args
80            .get("content")
81            .and_then(|v| v.as_str())
82            .ok_or_else(|| {
83                ::zeroclaw_log::record!(
84                    WARN,
85                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
86                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
87                        .with_attrs(::serde_json::json!({"param": "content"})),
88                    "file_write: missing content parameter"
89                );
90                anyhow::Error::msg("Missing 'content' parameter")
91            })?;
92
93        let encoding = args
94            .get("encoding")
95            .and_then(|v| v.as_str())
96            .unwrap_or("utf8");
97
98        if !self.security.can_act() {
99            return Ok(ToolResult {
100                success: false,
101                output: String::new(),
102                error: Some("Action blocked: autonomy is read-only".into()),
103            });
104        }
105
106        if !self.persistent_writes {
107            return Ok(ToolResult {
108                success: false,
109                output: String::new(),
110                error: Some(
111                    "file_write is unavailable: the active runtime uses an ephemeral workspace \
112                     (tmpfs / no host volume mount). Files written here would not persist on the \
113                     host after the session ends. To fix this, set \
114                     `runtime.docker.mount_workspace = true` in your config and ensure the \
115                     workspace directory is bind-mounted into the container."
116                        .into(),
117                ),
118            });
119        }
120
121        // Validate the encoding and decode base64 BEFORE any write-side
122        // filesystem mutation (e.g. parent directory creation), so invalid
123        // input fails without touching the workspace. Path-sandbox checks
124        // below still run on the resolved target before the write.
125        let bytes = match encoding {
126            "utf8" => content.as_bytes().to_vec(),
127            "base64" => {
128                use base64::Engine;
129                match base64::engine::general_purpose::STANDARD.decode(content) {
130                    Ok(decoded) => decoded,
131                    Err(e) => {
132                        return Ok(ToolResult {
133                            success: false,
134                            output: String::new(),
135                            error: Some(format!("Invalid base64 content: {e}")),
136                        });
137                    }
138                }
139            }
140            other => {
141                return Ok(ToolResult {
142                    success: false,
143                    output: String::new(),
144                    error: Some(format!(
145                        "Unsupported encoding '{other}' (expected 'utf8' or 'base64')"
146                    )),
147                });
148            }
149        };
150
151        // Rate limiting and path-allowlist checks are applied by the
152        // RateLimitedTool + PathGuardedTool wrappers at registration time
153        // (see zeroclaw-runtime::tools::mod).
154
155        let full_path = self.security.resolve_tool_path(path);
156
157        let Some(parent) = full_path.parent() else {
158            return Ok(ToolResult {
159                success: false,
160                output: String::new(),
161                error: Some("Invalid path: missing parent directory".into()),
162            });
163        };
164
165        // Ensure parent directory exists before canonicalising.
166        tokio::fs::create_dir_all(parent).await?;
167
168        // Canonicalise parent AFTER creation to detect symlink escapes.
169        let resolved_parent = match tokio::fs::canonicalize(parent).await {
170            Ok(p) => p,
171            Err(e) => {
172                return Ok(ToolResult {
173                    success: false,
174                    output: String::new(),
175                    error: Some(format!("Failed to resolve file path: {e}")),
176                });
177            }
178        };
179
180        if !self.security.is_resolved_path_allowed(&resolved_parent) {
181            return Ok(ToolResult {
182                success: false,
183                output: String::new(),
184                error: Some(
185                    self.security
186                        .resolved_path_violation_message(&resolved_parent),
187                ),
188            });
189        }
190
191        let Some(file_name) = full_path.file_name() else {
192            return Ok(ToolResult {
193                success: false,
194                output: String::new(),
195                error: Some("Invalid path: missing file name".into()),
196            });
197        };
198
199        let resolved_target = resolved_parent.join(file_name);
200
201        if self.security.is_runtime_config_path(&resolved_target) {
202            return Ok(ToolResult {
203                success: false,
204                output: String::new(),
205                error: Some(
206                    self.security
207                        .runtime_config_violation_message(&resolved_target),
208                ),
209            });
210        }
211
212        // If the target already exists and is a symlink, refuse to follow it
213        if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await
214            && meta.file_type().is_symlink()
215        {
216            return Ok(ToolResult {
217                success: false,
218                output: String::new(),
219                error: Some(format!(
220                    "Refusing to write through symlink: {}",
221                    resolved_target.display()
222                )),
223            });
224        }
225
226        match tokio::fs::write(&resolved_target, &bytes).await {
227            Ok(()) => Ok(ToolResult {
228                success: true,
229                output: format!("Written {} bytes to {path}", bytes.len()),
230                error: None,
231            }),
232            Err(e) => Ok(ToolResult {
233                success: false,
234                output: String::new(),
235                error: Some(format!("Failed to write file: {e}")),
236            }),
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::wrappers::{PathGuardedTool, RateLimitedTool};
245    use zeroclaw_config::autonomy::AutonomyLevel;
246    use zeroclaw_config::policy::SecurityPolicy;
247
248    fn test_tool(workspace: std::path::PathBuf) -> FileWriteTool {
249        let security = Arc::new(SecurityPolicy {
250            autonomy: AutonomyLevel::Supervised,
251            workspace_dir: workspace,
252            ..SecurityPolicy::default()
253        });
254        FileWriteTool::new(security)
255    }
256
257    /// Wraps `FileWriteTool` with the production `PathGuardedTool` + `RateLimitedTool`
258    /// stack, mirroring the registration in `zeroclaw-runtime::tools::mod`. Use this
259    /// in tests that exercise path-allowlist or rate-limit behavior.
260    fn wrapped_tool(workspace: std::path::PathBuf) -> Box<dyn Tool> {
261        let security = Arc::new(SecurityPolicy {
262            autonomy: AutonomyLevel::Supervised,
263            workspace_dir: workspace,
264            ..SecurityPolicy::default()
265        });
266        Box::new(RateLimitedTool::new(
267            PathGuardedTool::new(FileWriteTool::new(security.clone()), security.clone()),
268            security,
269        ))
270    }
271
272    fn test_tool_with(
273        workspace: std::path::PathBuf,
274        autonomy: AutonomyLevel,
275        max_actions_per_hour: u32,
276    ) -> FileWriteTool {
277        let security = Arc::new(SecurityPolicy {
278            autonomy,
279            workspace_dir: workspace,
280            max_actions_per_hour,
281            ..SecurityPolicy::default()
282        });
283        FileWriteTool::new(security)
284    }
285
286    fn ephemeral_tool(workspace: std::path::PathBuf) -> FileWriteTool {
287        let security = Arc::new(SecurityPolicy {
288            autonomy: AutonomyLevel::Supervised,
289            workspace_dir: workspace,
290            ..SecurityPolicy::default()
291        });
292        FileWriteTool::new_with_persistence(security, false)
293    }
294
295    #[test]
296    fn file_write_name() {
297        let tool = test_tool(std::env::temp_dir());
298        assert_eq!(tool.name(), "file_write");
299    }
300
301    #[test]
302    fn file_write_schema_has_path_and_content() {
303        let tool = test_tool(std::env::temp_dir());
304        let schema = tool.parameters_schema();
305        assert!(schema["properties"]["path"].is_object());
306        assert!(schema["properties"]["content"].is_object());
307        let required = schema["required"].as_array().unwrap();
308        assert!(required.contains(&json!("path")));
309        assert!(required.contains(&json!("content")));
310    }
311
312    #[tokio::test]
313    async fn file_write_creates_file() {
314        let dir = std::env::temp_dir().join("zeroclaw_test_file_write");
315        let _ = tokio::fs::remove_dir_all(&dir).await;
316        tokio::fs::create_dir_all(&dir).await.unwrap();
317
318        let tool = test_tool(dir.clone());
319        let result = tool
320            .execute(json!({"path": "out.txt", "content": "written!"}))
321            .await
322            .unwrap();
323        assert!(result.success);
324        assert!(result.output.contains("8 bytes"));
325
326        let content = tokio::fs::read_to_string(dir.join("out.txt"))
327            .await
328            .unwrap();
329        assert_eq!(content, "written!");
330
331        let _ = tokio::fs::remove_dir_all(&dir).await;
332    }
333
334    #[tokio::test]
335    async fn file_write_creates_parent_dirs() {
336        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_nested");
337        let _ = tokio::fs::remove_dir_all(&dir).await;
338        tokio::fs::create_dir_all(&dir).await.unwrap();
339
340        let tool = test_tool(dir.clone());
341        let result = tool
342            .execute(json!({"path": "a/b/c/deep.txt", "content": "deep"}))
343            .await
344            .unwrap();
345        assert!(result.success);
346
347        let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt"))
348            .await
349            .unwrap();
350        assert_eq!(content, "deep");
351
352        let _ = tokio::fs::remove_dir_all(&dir).await;
353    }
354
355    #[tokio::test]
356    async fn file_write_normalizes_workspace_prefixed_relative_path() {
357        let root = std::env::temp_dir().join("zeroclaw_test_file_write_workspace_prefixed");
358        let workspace = root.join("workspace");
359        let _ = tokio::fs::remove_dir_all(&root).await;
360        tokio::fs::create_dir_all(&workspace).await.unwrap();
361
362        let tool = test_tool(workspace.clone());
363        let workspace_prefixed = workspace
364            .strip_prefix(std::path::Path::new("/"))
365            .unwrap()
366            .join("nested/out.txt");
367        let result = tool
368            .execute(json!({
369                "path": workspace_prefixed.to_string_lossy(),
370                "content": "written!"
371            }))
372            .await
373            .unwrap();
374        assert!(result.success);
375
376        let content = tokio::fs::read_to_string(workspace.join("nested/out.txt"))
377            .await
378            .unwrap();
379        assert_eq!(content, "written!");
380        assert!(!workspace.join(workspace_prefixed).exists());
381
382        let _ = tokio::fs::remove_dir_all(&root).await;
383    }
384
385    #[tokio::test]
386    async fn file_write_overwrites_existing() {
387        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite");
388        let _ = tokio::fs::remove_dir_all(&dir).await;
389        tokio::fs::create_dir_all(&dir).await.unwrap();
390        tokio::fs::write(dir.join("exist.txt"), "old")
391            .await
392            .unwrap();
393
394        let tool = test_tool(dir.clone());
395        let result = tool
396            .execute(json!({"path": "exist.txt", "content": "new"}))
397            .await
398            .unwrap();
399        assert!(result.success);
400
401        let content = tokio::fs::read_to_string(dir.join("exist.txt"))
402            .await
403            .unwrap();
404        assert_eq!(content, "new");
405
406        let _ = tokio::fs::remove_dir_all(&dir).await;
407    }
408
409    #[tokio::test]
410    async fn file_write_blocks_path_traversal() {
411        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_traversal");
412        let _ = tokio::fs::remove_dir_all(&dir).await;
413        tokio::fs::create_dir_all(&dir).await.unwrap();
414
415        let tool = wrapped_tool(dir.clone());
416        let result = tool
417            .execute(json!({"path": "../../etc/evil", "content": "bad"}))
418            .await
419            .unwrap();
420        assert!(!result.success);
421        assert!(
422            result.error.as_ref().unwrap().contains("Path blocked"),
423            "expected 'Path blocked' error, got: {:?}",
424            result.error
425        );
426
427        let _ = tokio::fs::remove_dir_all(&dir).await;
428    }
429
430    #[tokio::test]
431    async fn file_write_blocks_absolute_path() {
432        let tool = wrapped_tool(std::env::temp_dir());
433        let result = tool
434            .execute(json!({"path": "/etc/evil", "content": "bad"}))
435            .await
436            .unwrap();
437        assert!(!result.success);
438        assert!(
439            result.error.as_ref().unwrap().contains("Path blocked"),
440            "expected 'Path blocked' error, got: {:?}",
441            result.error
442        );
443    }
444
445    #[tokio::test]
446    async fn file_write_missing_path_param() {
447        let tool = test_tool(std::env::temp_dir());
448        let result = tool.execute(json!({"content": "data"})).await;
449        assert!(result.is_err());
450    }
451
452    #[tokio::test]
453    async fn file_write_missing_content_param() {
454        let tool = test_tool(std::env::temp_dir());
455        let result = tool.execute(json!({"path": "file.txt"})).await;
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn file_write_schema_has_encoding() {
461        let tool = test_tool(std::env::temp_dir());
462        let schema = tool.parameters_schema();
463        assert!(schema["properties"]["encoding"].is_object());
464    }
465
466    #[tokio::test]
467    async fn file_write_base64_writes_decoded_bytes() {
468        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_base64");
469        let _ = tokio::fs::remove_dir_all(&dir).await;
470        tokio::fs::create_dir_all(&dir).await.unwrap();
471
472        // Bytes that are NOT valid UTF-8 — proves we persist raw bytes, not text.
473        let raw: Vec<u8> = vec![0x00, 0x01, 0xFF, 0xFE, b'P', b'K', 0x03, 0x04];
474        use base64::Engine;
475        let encoded = base64::engine::general_purpose::STANDARD.encode(&raw);
476
477        let tool = test_tool(dir.clone());
478        let result = tool
479            .execute(json!({"path": "out.bin", "content": encoded, "encoding": "base64"}))
480            .await
481            .unwrap();
482        assert!(result.success, "error: {:?}", result.error);
483        assert!(result.output.contains(&format!("{} bytes", raw.len())));
484
485        let written = tokio::fs::read(dir.join("out.bin")).await.unwrap();
486        assert_eq!(written, raw, "base64 write must persist exact raw bytes");
487
488        let _ = tokio::fs::remove_dir_all(&dir).await;
489    }
490
491    #[tokio::test]
492    async fn file_write_base64_invalid_content_errors() {
493        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_base64_invalid");
494        let _ = tokio::fs::remove_dir_all(&dir).await;
495        tokio::fs::create_dir_all(&dir).await.unwrap();
496
497        let tool = test_tool(dir.clone());
498        let result = tool
499            .execute(
500                json!({"path": "out.bin", "content": "not!valid!base64!", "encoding": "base64"}),
501            )
502            .await
503            .unwrap();
504        assert!(!result.success);
505        assert!(
506            result
507                .error
508                .as_deref()
509                .unwrap_or("")
510                .contains("Invalid base64")
511        );
512        assert!(
513            !dir.join("out.bin").exists(),
514            "no file must be written on decode failure"
515        );
516
517        let _ = tokio::fs::remove_dir_all(&dir).await;
518    }
519
520    #[tokio::test]
521    async fn file_write_unsupported_encoding_errors() {
522        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_bad_encoding");
523        let _ = tokio::fs::remove_dir_all(&dir).await;
524        tokio::fs::create_dir_all(&dir).await.unwrap();
525
526        let tool = test_tool(dir.clone());
527        let result = tool
528            .execute(json!({"path": "out.txt", "content": "hi", "encoding": "hex"}))
529            .await
530            .unwrap();
531        assert!(!result.success);
532        assert!(
533            result
534                .error
535                .as_deref()
536                .unwrap_or("")
537                .contains("Unsupported encoding")
538        );
539
540        let _ = tokio::fs::remove_dir_all(&dir).await;
541    }
542
543    /// Rejected writes (invalid base64 / unsupported encoding) targeting a
544    /// missing nested parent must fail WITHOUT mutating the workspace — no
545    /// file and, crucially, no parent directory may be created.
546    #[tokio::test]
547    async fn file_write_rejected_encoding_does_not_create_parent_dirs() {
548        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_no_dir_on_reject");
549        let _ = tokio::fs::remove_dir_all(&dir).await;
550        tokio::fs::create_dir_all(&dir).await.unwrap();
551
552        let tool = test_tool(dir.clone());
553
554        // Invalid base64 into a missing nested parent.
555        let result = tool
556            .execute(json!({
557                "path": "nested/out.bin",
558                "content": "not!valid!base64!",
559                "encoding": "base64"
560            }))
561            .await
562            .unwrap();
563        assert!(!result.success);
564        assert!(
565            result
566                .error
567                .as_deref()
568                .unwrap_or("")
569                .contains("Invalid base64")
570        );
571        assert!(
572            !dir.join("nested").exists(),
573            "rejected base64 write must not create the parent directory"
574        );
575        assert!(!dir.join("nested/out.bin").exists());
576
577        // Unsupported encoding into a (different) missing nested parent.
578        let result = tool
579            .execute(json!({
580                "path": "nested2/out.txt",
581                "content": "hi",
582                "encoding": "hex"
583            }))
584            .await
585            .unwrap();
586        assert!(!result.success);
587        assert!(
588            result
589                .error
590                .as_deref()
591                .unwrap_or("")
592                .contains("Unsupported encoding")
593        );
594        assert!(
595            !dir.join("nested2").exists(),
596            "unsupported encoding must not create the parent directory"
597        );
598        assert!(!dir.join("nested2/out.txt").exists());
599
600        let _ = tokio::fs::remove_dir_all(&dir).await;
601    }
602
603    #[tokio::test]
604    async fn file_write_base64_still_blocks_path_traversal() {
605        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_base64_traversal");
606        let _ = tokio::fs::remove_dir_all(&dir).await;
607        tokio::fs::create_dir_all(&dir).await.unwrap();
608
609        use base64::Engine;
610        let encoded = base64::engine::general_purpose::STANDARD.encode(b"bad");
611        let tool = wrapped_tool(dir.clone());
612        let result = tool
613            .execute(json!({"path": "../../etc/evil", "content": encoded, "encoding": "base64"}))
614            .await
615            .unwrap();
616        assert!(!result.success);
617        assert!(result.error.as_ref().unwrap().contains("Path blocked"));
618
619        let _ = tokio::fs::remove_dir_all(&dir).await;
620    }
621
622    #[tokio::test]
623    async fn file_write_empty_content() {
624        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_empty");
625        let _ = tokio::fs::remove_dir_all(&dir).await;
626        tokio::fs::create_dir_all(&dir).await.unwrap();
627
628        let tool = test_tool(dir.clone());
629        let result = tool
630            .execute(json!({"path": "empty.txt", "content": ""}))
631            .await
632            .unwrap();
633        assert!(result.success);
634        assert!(result.output.contains("0 bytes"));
635
636        let _ = tokio::fs::remove_dir_all(&dir).await;
637    }
638
639    #[cfg(unix)]
640    #[tokio::test]
641    async fn file_write_blocks_symlink_escape() {
642        use std::os::unix::fs::symlink;
643
644        let root = std::env::temp_dir().join("zeroclaw_test_file_write_symlink_escape");
645        let workspace = root.join("workspace");
646        let outside = root.join("outside");
647
648        let _ = tokio::fs::remove_dir_all(&root).await;
649        tokio::fs::create_dir_all(&workspace).await.unwrap();
650        tokio::fs::create_dir_all(&outside).await.unwrap();
651
652        symlink(&outside, workspace.join("escape_dir")).unwrap();
653
654        let tool = test_tool(workspace.clone());
655        let result = tool
656            .execute(json!({"path": "escape_dir/hijack.txt", "content": "bad"}))
657            .await
658            .unwrap();
659
660        assert!(!result.success);
661        assert!(
662            result
663                .error
664                .as_deref()
665                .unwrap_or("")
666                .contains("escapes workspace")
667        );
668        assert!(!outside.join("hijack.txt").exists());
669
670        let _ = tokio::fs::remove_dir_all(&root).await;
671    }
672
673    #[tokio::test]
674    async fn file_write_blocks_ephemeral_runtime() {
675        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_ephemeral");
676        let _ = tokio::fs::remove_dir_all(&dir).await;
677        tokio::fs::create_dir_all(&dir).await.unwrap();
678
679        let tool = ephemeral_tool(dir.clone());
680        let result = tool
681            .execute(json!({"path": "out.txt", "content": "should-block"}))
682            .await
683            .unwrap();
684
685        assert!(!result.success);
686        assert!(
687            result
688                .error
689                .as_deref()
690                .unwrap_or("")
691                .contains("ephemeral workspace"),
692            "error should mention ephemeral workspace, got: {:?}",
693            result.error
694        );
695        assert!(
696            !dir.join("out.txt").exists(),
697            "no file should be written in ephemeral mode"
698        );
699
700        let _ = tokio::fs::remove_dir_all(&dir).await;
701    }
702
703    #[tokio::test]
704    async fn file_write_blocks_readonly_mode() {
705        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_readonly");
706        let _ = tokio::fs::remove_dir_all(&dir).await;
707        tokio::fs::create_dir_all(&dir).await.unwrap();
708
709        let tool = test_tool_with(dir.clone(), AutonomyLevel::ReadOnly, 20);
710        let result = tool
711            .execute(json!({"path": "out.txt", "content": "should-block"}))
712            .await
713            .unwrap();
714
715        assert!(!result.success);
716        assert!(result.error.as_deref().unwrap_or("").contains("read-only"));
717        assert!(!dir.join("out.txt").exists());
718
719        let _ = tokio::fs::remove_dir_all(&dir).await;
720    }
721
722    #[cfg(unix)]
723    #[tokio::test]
724    async fn file_write_blocks_symlink_target_file() {
725        use std::os::unix::fs::symlink;
726
727        let root = std::env::temp_dir().join("zeroclaw_test_file_write_symlink_target");
728        let workspace = root.join("workspace");
729        let outside = root.join("outside");
730
731        let _ = tokio::fs::remove_dir_all(&root).await;
732        tokio::fs::create_dir_all(&workspace).await.unwrap();
733        tokio::fs::create_dir_all(&outside).await.unwrap();
734
735        tokio::fs::write(outside.join("target.txt"), "original")
736            .await
737            .unwrap();
738        symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
739
740        let tool = test_tool(workspace.clone());
741        let result = tool
742            .execute(json!({"path": "linked.txt", "content": "overwritten"}))
743            .await
744            .unwrap();
745
746        assert!(!result.success, "writing through symlink must be blocked");
747        assert!(
748            result.error.as_deref().unwrap_or("").contains("symlink"),
749            "error should mention symlink"
750        );
751
752        let content = tokio::fs::read_to_string(outside.join("target.txt"))
753            .await
754            .unwrap();
755        assert_eq!(content, "original", "original file must not be modified");
756
757        let _ = tokio::fs::remove_dir_all(&root).await;
758    }
759
760    #[tokio::test]
761    async fn file_write_absolute_path_in_workspace() {
762        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_abs_path");
763        let _ = tokio::fs::remove_dir_all(&dir).await;
764        tokio::fs::create_dir_all(&dir).await.unwrap();
765
766        // Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
767        let dir = tokio::fs::canonicalize(&dir).await.unwrap();
768
769        let tool = test_tool(dir.clone());
770
771        let abs_path = dir.join("abs_test.txt");
772        let result = tool
773            .execute(
774                json!({"path": abs_path.to_string_lossy().to_string(), "content": "absolute!"}),
775            )
776            .await
777            .unwrap();
778
779        assert!(
780            result.success,
781            "writing via absolute workspace path should succeed, error: {:?}",
782            result.error
783        );
784
785        let content = tokio::fs::read_to_string(dir.join("abs_test.txt"))
786            .await
787            .unwrap();
788        assert_eq!(content, "absolute!");
789
790        let _ = tokio::fs::remove_dir_all(&dir).await;
791    }
792
793    #[tokio::test]
794    async fn file_write_blocks_null_byte_in_path() {
795        let dir = std::env::temp_dir().join("zeroclaw_test_file_write_null");
796        let _ = tokio::fs::remove_dir_all(&dir).await;
797        tokio::fs::create_dir_all(&dir).await.unwrap();
798
799        let tool = test_tool(dir.clone());
800        let result = tool
801            .execute(json!({"path": "file\u{0000}.txt", "content": "bad"}))
802            .await
803            .unwrap();
804        assert!(!result.success, "paths with null bytes must be blocked");
805
806        let _ = tokio::fs::remove_dir_all(&dir).await;
807    }
808
809    #[tokio::test]
810    async fn file_write_blocks_path_outside_workspace() {
811        let root = std::env::temp_dir().join("zeroclaw_test_file_write_outside_workspace");
812        let workspace = root.join("workspace");
813        let outside_file = root.join("outside.txt");
814        let _ = tokio::fs::remove_dir_all(&root).await;
815        tokio::fs::create_dir_all(&workspace).await.unwrap();
816
817        let tool = test_tool(workspace.clone());
818        let result = tool
819            .execute(json!({
820                "path": outside_file.to_string_lossy(),
821                "content": "should-block"
822            }))
823            .await
824            .unwrap();
825
826        assert!(!result.success);
827        assert!(!outside_file.exists());
828
829        let _ = tokio::fs::remove_dir_all(&root).await;
830    }
831}