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
7pub struct FileWriteTool {
9 security: Arc<SecurityPolicy>,
10 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 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 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 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 tokio::fs::create_dir_all(parent).await?;
167
168 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 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 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 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 #[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 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 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 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}