1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use zeroclaw_api::tool::{Tool, ToolResult, with_ephemeral_workspace_warning};
5use zeroclaw_config::policy::SecurityPolicy;
6
7pub struct FileEditTool {
14 security: Arc<SecurityPolicy>,
15 persistent_writes: bool,
22}
23
24impl FileEditTool {
25 pub fn new(security: Arc<SecurityPolicy>) -> Self {
26 Self {
27 security,
28 persistent_writes: true,
29 }
30 }
31
32 pub fn new_with_persistence(security: Arc<SecurityPolicy>, persistent_writes: bool) -> Self {
36 Self {
37 security,
38 persistent_writes,
39 }
40 }
41}
42
43#[async_trait]
44impl Tool for FileEditTool {
45 fn name(&self) -> &str {
46 "file_edit"
47 }
48
49 fn description(&self) -> &str {
50 "Edit a file by replacing an exact string match with new content"
51 }
52
53 fn parameters_schema(&self) -> serde_json::Value {
54 json!({
55 "type": "object",
56 "properties": {
57 "path": {
58 "type": "string",
59 "description": "Path to the file. Relative paths resolve from workspace; outside paths require policy allowlist."
60 },
61 "old_string": {
62 "type": "string",
63 "description": "The exact text to find and replace (must appear exactly once in the file)"
64 },
65 "new_string": {
66 "type": "string",
67 "description": "The replacement text (empty string to delete the matched text)"
68 }
69 },
70 "required": ["path", "old_string", "new_string"]
71 })
72 }
73
74 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
75 let mut result = self.edit_file(args).await?;
76 if !self.persistent_writes && result.success {
79 result.output = with_ephemeral_workspace_warning(&result.output);
80 }
81 Ok(result)
82 }
83}
84
85impl FileEditTool {
86 async fn edit_file(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
89 let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
91 ::zeroclaw_log::record!(
92 WARN,
93 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
94 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
95 .with_attrs(::serde_json::json!({"param": "path"})),
96 "file_edit: missing path parameter"
97 );
98 anyhow::Error::msg("Missing 'path' parameter")
99 })?;
100
101 let old_string = args
102 .get("old_string")
103 .and_then(|v| v.as_str())
104 .ok_or_else(|| {
105 ::zeroclaw_log::record!(
106 WARN,
107 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
108 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
109 .with_attrs(::serde_json::json!({"param": "old_string"})),
110 "file_edit: missing old_string parameter"
111 );
112 anyhow::Error::msg("Missing 'old_string' parameter")
113 })?;
114
115 let new_string = args
116 .get("new_string")
117 .and_then(|v| v.as_str())
118 .ok_or_else(|| {
119 ::zeroclaw_log::record!(
120 WARN,
121 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
122 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
123 .with_attrs(::serde_json::json!({"param": "new_string"})),
124 "file_edit: missing new_string parameter"
125 );
126 anyhow::Error::msg("Missing 'new_string' parameter")
127 })?;
128
129 if old_string.is_empty() {
130 return Ok(ToolResult {
131 success: false,
132 output: String::new(),
133 error: Some("old_string must not be empty".into()),
134 });
135 }
136
137 if !self.security.can_act() {
139 return Ok(ToolResult {
140 success: false,
141 output: String::new(),
142 error: Some("Action blocked: autonomy is read-only".into()),
143 });
144 }
145
146 let full_path = self.security.resolve_tool_path(path);
151
152 let Some(parent) = full_path.parent() else {
154 return Ok(ToolResult {
155 success: false,
156 output: String::new(),
157 error: Some("Invalid path: missing parent directory".into()),
158 });
159 };
160
161 let resolved_parent = match tokio::fs::canonicalize(parent).await {
162 Ok(p) => p,
163 Err(e) => {
164 return Ok(ToolResult {
165 success: false,
166 output: String::new(),
167 error: Some(format!("Failed to resolve file path: {e}")),
168 });
169 }
170 };
171
172 if !self.security.is_resolved_path_allowed(&resolved_parent) {
174 return Ok(ToolResult {
175 success: false,
176 output: String::new(),
177 error: Some(
178 self.security
179 .resolved_path_violation_message(&resolved_parent),
180 ),
181 });
182 }
183
184 let Some(file_name) = full_path.file_name() else {
185 return Ok(ToolResult {
186 success: false,
187 output: String::new(),
188 error: Some("Invalid path: missing file name".into()),
189 });
190 };
191
192 let resolved_target = resolved_parent.join(file_name);
193
194 if self.security.is_runtime_config_path(&resolved_target) {
195 return Ok(ToolResult {
196 success: false,
197 output: String::new(),
198 error: Some(
199 self.security
200 .runtime_config_violation_message(&resolved_target),
201 ),
202 });
203 }
204
205 if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await
207 && meta.file_type().is_symlink()
208 {
209 return Ok(ToolResult {
210 success: false,
211 output: String::new(),
212 error: Some(format!(
213 "Refusing to edit through symlink: {}",
214 resolved_target.display()
215 )),
216 });
217 }
218
219 let content = match tokio::fs::read_to_string(&resolved_target).await {
221 Ok(c) => c,
222 Err(e) => {
223 return Ok(ToolResult {
224 success: false,
225 output: String::new(),
226 error: Some(format!("Failed to read file: {e}")),
227 });
228 }
229 };
230
231 let match_count = content.matches(old_string).count();
232
233 if match_count == 0 {
234 return Ok(ToolResult {
235 success: false,
236 output: String::new(),
237 error: Some(no_match_diagnostic(&content, old_string)),
238 });
239 }
240
241 if match_count > 1 {
242 return Ok(ToolResult {
243 success: false,
244 output: String::new(),
245 error: Some(format!(
246 "old_string matches {match_count} times; must match exactly once"
247 )),
248 });
249 }
250
251 let new_content = content.replacen(old_string, new_string, 1);
252
253 match tokio::fs::write(&resolved_target, &new_content).await {
254 Ok(()) => Ok(ToolResult {
255 success: true,
256 output: format!(
257 "Edited {path}: replaced 1 occurrence ({} bytes)",
258 new_content.len()
259 ),
260 error: None,
261 }),
262 Err(e) => Ok(ToolResult {
263 success: false,
264 output: String::new(),
265 error: Some(format!("Failed to write file: {e}")),
266 }),
267 }
268 }
269}
270
271fn no_match_diagnostic(content: &str, old_string: &str) -> String {
279 fn strip_leading_ws(s: &str) -> String {
280 s.lines()
281 .map(str::trim_start)
282 .collect::<Vec<_>>()
283 .join("\n")
284 }
285
286 let needle_norm = strip_leading_ws(old_string);
287 let haystack_norm = strip_leading_ws(content);
288 let near = haystack_norm.matches(needle_norm.as_str()).count();
289
290 match near {
291 0 => "old_string not found in file".to_string(),
292 1 => "old_string not found exactly: a block matching it ignoring leading \
293 whitespace exists exactly once. The difference is indentation \
294 (width, or tabs vs spaces). Re-read the target region and copy its \
295 leading whitespace exactly, then retry."
296 .to_string(),
297 n => format!(
298 "old_string not found exactly: {n} blocks match it when leading \
299 whitespace is ignored. Indentation differs and the target is \
300 ambiguous. Re-read the region, copy exact indentation, and include \
301 enough surrounding lines to make the match unique."
302 ),
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::wrappers::{PathGuardedTool, RateLimitedTool};
310 use zeroclaw_config::autonomy::AutonomyLevel;
311 use zeroclaw_config::policy::SecurityPolicy;
312
313 fn test_tool(workspace: std::path::PathBuf) -> FileEditTool {
314 let security = Arc::new(SecurityPolicy {
315 autonomy: AutonomyLevel::Supervised,
316 workspace_dir: workspace,
317 ..SecurityPolicy::default()
318 });
319 FileEditTool::new(security)
320 }
321
322 fn wrapped_tool(workspace: std::path::PathBuf) -> Box<dyn Tool> {
326 let security = Arc::new(SecurityPolicy {
327 autonomy: AutonomyLevel::Supervised,
328 workspace_dir: workspace,
329 ..SecurityPolicy::default()
330 });
331 Box::new(RateLimitedTool::new(
332 PathGuardedTool::new(FileEditTool::new(security.clone()), security.clone()),
333 security,
334 ))
335 }
336
337 fn test_tool_with(
338 workspace: std::path::PathBuf,
339 autonomy: AutonomyLevel,
340 max_actions_per_hour: u32,
341 ) -> FileEditTool {
342 let security = Arc::new(SecurityPolicy {
343 autonomy,
344 workspace_dir: workspace,
345 max_actions_per_hour,
346 ..SecurityPolicy::default()
347 });
348 FileEditTool::new(security)
349 }
350
351 fn ephemeral_tool(workspace: std::path::PathBuf) -> FileEditTool {
352 let security = Arc::new(SecurityPolicy {
353 autonomy: AutonomyLevel::Supervised,
354 workspace_dir: workspace,
355 ..SecurityPolicy::default()
356 });
357 FileEditTool::new_with_persistence(security, false)
358 }
359
360 #[test]
361 fn file_edit_name() {
362 let tool = test_tool(std::env::temp_dir());
363 assert_eq!(tool.name(), "file_edit");
364 }
365
366 #[tokio::test]
371 async fn file_edit_warns_on_ephemeral_workspace() {
372 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_ephemeral");
373 let _ = tokio::fs::remove_dir_all(&dir).await;
374 tokio::fs::create_dir_all(&dir).await.unwrap();
375 tokio::fs::write(dir.join("doc.txt"), "hello world")
376 .await
377 .unwrap();
378
379 let tool = ephemeral_tool(dir.clone());
380 let result = tool
381 .execute(json!({"path": "doc.txt", "old_string": "world", "new_string": "there"}))
382 .await
383 .unwrap();
384 assert!(result.success, "error: {:?}", result.error);
385 assert!(
386 result.output.contains("EPHEMERAL WORKSPACE"),
387 "ephemeral warning must be present, got: {}",
388 result.output
389 );
390 assert!(result.output.contains("mount_workspace"));
391 assert!(
392 result.output.contains("Edited"),
393 "original edit status must be preserved, got: {}",
394 result.output
395 );
396
397 let _ = tokio::fs::remove_dir_all(&dir).await;
398 }
399
400 #[tokio::test]
402 async fn file_edit_failure_not_warned_on_ephemeral_workspace() {
403 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_ephemeral_fail");
404 let _ = tokio::fs::remove_dir_all(&dir).await;
405 tokio::fs::create_dir_all(&dir).await.unwrap();
406 tokio::fs::write(dir.join("doc.txt"), "hello world")
407 .await
408 .unwrap();
409
410 let tool = ephemeral_tool(dir.clone());
411 let result = tool
412 .execute(json!({"path": "doc.txt", "old_string": "absent", "new_string": "x"}))
413 .await
414 .unwrap();
415 assert!(!result.success);
416 assert!(!result.output.contains("EPHEMERAL WORKSPACE"));
417 assert!(
418 !result
419 .error
420 .as_deref()
421 .unwrap_or("")
422 .contains("EPHEMERAL WORKSPACE")
423 );
424
425 let _ = tokio::fs::remove_dir_all(&dir).await;
426 }
427
428 #[tokio::test]
430 async fn file_edit_no_warning_when_persistent() {
431 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_persistent");
432 let _ = tokio::fs::remove_dir_all(&dir).await;
433 tokio::fs::create_dir_all(&dir).await.unwrap();
434 tokio::fs::write(dir.join("doc.txt"), "hello world")
435 .await
436 .unwrap();
437
438 let tool = test_tool(dir.clone());
439 let result = tool
440 .execute(json!({"path": "doc.txt", "old_string": "world", "new_string": "there"}))
441 .await
442 .unwrap();
443 assert!(result.success, "error: {:?}", result.error);
444 assert!(
445 !result.output.contains("EPHEMERAL WORKSPACE"),
446 "no ephemeral warning expected on a persistent runtime, got: {}",
447 result.output
448 );
449
450 let _ = tokio::fs::remove_dir_all(&dir).await;
451 }
452
453 #[test]
454 fn no_match_diagnostic_flags_unique_whitespace_only_difference() {
455 let content = "fn main() {\n let x = 1;\n}\n";
458 let old = " let x = 1;";
459 let msg = no_match_diagnostic(content, old);
460 assert!(msg.contains("ignoring leading whitespace"), "got: {msg}");
461 assert!(msg.contains("indentation"), "got: {msg}");
462 }
463
464 #[test]
465 fn no_match_diagnostic_plain_not_found_when_no_near_match() {
466 let content = "fn main() {}\n";
467 let msg = no_match_diagnostic(content, "totally unrelated text");
468 assert_eq!(msg, "old_string not found in file");
469 }
470
471 #[test]
472 fn no_match_diagnostic_flags_ambiguous_whitespace_matches() {
473 let content = " a = 1;\n a = 1;\n";
474 let msg = no_match_diagnostic(content, "a = 1;");
475 assert!(msg.contains("blocks match"), "got: {msg}");
476 assert!(msg.contains("ambiguous"), "got: {msg}");
477 }
478
479 #[test]
480 fn file_edit_schema_has_required_params() {
481 let tool = test_tool(std::env::temp_dir());
482 let schema = tool.parameters_schema();
483 assert!(schema["properties"]["path"].is_object());
484 assert!(schema["properties"]["old_string"].is_object());
485 assert!(schema["properties"]["new_string"].is_object());
486 let required = schema["required"].as_array().unwrap();
487 assert!(required.contains(&json!("path")));
488 assert!(required.contains(&json!("old_string")));
489 assert!(required.contains(&json!("new_string")));
490 }
491
492 #[tokio::test]
493 async fn file_edit_replaces_single_match() {
494 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_single");
495 let _ = tokio::fs::remove_dir_all(&dir).await;
496 tokio::fs::create_dir_all(&dir).await.unwrap();
497 tokio::fs::write(dir.join("test.txt"), "hello world")
498 .await
499 .unwrap();
500
501 let tool = test_tool(dir.clone());
502 let result = tool
503 .execute(json!({
504 "path": "test.txt",
505 "old_string": "hello",
506 "new_string": "goodbye"
507 }))
508 .await
509 .unwrap();
510
511 assert!(result.success, "edit should succeed: {:?}", result.error);
512 assert!(result.output.contains("replaced 1 occurrence"));
513
514 let content = tokio::fs::read_to_string(dir.join("test.txt"))
515 .await
516 .unwrap();
517 assert_eq!(content, "goodbye world");
518
519 let _ = tokio::fs::remove_dir_all(&dir).await;
520 }
521
522 #[tokio::test]
523 async fn file_edit_not_found() {
524 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_notfound");
525 let _ = tokio::fs::remove_dir_all(&dir).await;
526 tokio::fs::create_dir_all(&dir).await.unwrap();
527 tokio::fs::write(dir.join("test.txt"), "hello world")
528 .await
529 .unwrap();
530
531 let tool = test_tool(dir.clone());
532 let result = tool
533 .execute(json!({
534 "path": "test.txt",
535 "old_string": "nonexistent",
536 "new_string": "replacement"
537 }))
538 .await
539 .unwrap();
540
541 assert!(!result.success);
542 assert!(result.error.as_deref().unwrap_or("").contains("not found"));
543
544 let content = tokio::fs::read_to_string(dir.join("test.txt"))
545 .await
546 .unwrap();
547 assert_eq!(content, "hello world");
548
549 let _ = tokio::fs::remove_dir_all(&dir).await;
550 }
551
552 #[tokio::test]
553 async fn file_edit_multiple_matches() {
554 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_multi");
555 let _ = tokio::fs::remove_dir_all(&dir).await;
556 tokio::fs::create_dir_all(&dir).await.unwrap();
557 tokio::fs::write(dir.join("test.txt"), "aaa bbb aaa")
558 .await
559 .unwrap();
560
561 let tool = test_tool(dir.clone());
562 let result = tool
563 .execute(json!({
564 "path": "test.txt",
565 "old_string": "aaa",
566 "new_string": "ccc"
567 }))
568 .await
569 .unwrap();
570
571 assert!(!result.success);
572 assert!(
573 result
574 .error
575 .as_deref()
576 .unwrap_or("")
577 .contains("matches 2 times")
578 );
579
580 let content = tokio::fs::read_to_string(dir.join("test.txt"))
581 .await
582 .unwrap();
583 assert_eq!(content, "aaa bbb aaa");
584
585 let _ = tokio::fs::remove_dir_all(&dir).await;
586 }
587
588 #[tokio::test]
589 async fn file_edit_delete_via_empty_new_string() {
590 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_delete");
591 let _ = tokio::fs::remove_dir_all(&dir).await;
592 tokio::fs::create_dir_all(&dir).await.unwrap();
593 tokio::fs::write(dir.join("test.txt"), "keep remove keep")
594 .await
595 .unwrap();
596
597 let tool = test_tool(dir.clone());
598 let result = tool
599 .execute(json!({
600 "path": "test.txt",
601 "old_string": " remove",
602 "new_string": ""
603 }))
604 .await
605 .unwrap();
606
607 assert!(
608 result.success,
609 "delete edit should succeed: {:?}",
610 result.error
611 );
612
613 let content = tokio::fs::read_to_string(dir.join("test.txt"))
614 .await
615 .unwrap();
616 assert_eq!(content, "keep keep");
617
618 let _ = tokio::fs::remove_dir_all(&dir).await;
619 }
620
621 #[tokio::test]
622 async fn file_edit_missing_path_param() {
623 let tool = test_tool(std::env::temp_dir());
624 let result = tool
625 .execute(json!({"old_string": "a", "new_string": "b"}))
626 .await;
627 assert!(result.is_err());
628 }
629
630 #[tokio::test]
631 async fn file_edit_missing_old_string_param() {
632 let tool = test_tool(std::env::temp_dir());
633 let result = tool
634 .execute(json!({"path": "f.txt", "new_string": "b"}))
635 .await;
636 assert!(result.is_err());
637 }
638
639 #[tokio::test]
640 async fn file_edit_missing_new_string_param() {
641 let tool = test_tool(std::env::temp_dir());
642 let result = tool
643 .execute(json!({"path": "f.txt", "old_string": "a"}))
644 .await;
645 assert!(result.is_err());
646 }
647
648 #[tokio::test]
649 async fn file_edit_rejects_empty_old_string() {
650 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_empty_old_string");
651 let _ = tokio::fs::remove_dir_all(&dir).await;
652 tokio::fs::create_dir_all(&dir).await.unwrap();
653 tokio::fs::write(dir.join("test.txt"), "hello")
654 .await
655 .unwrap();
656
657 let tool = test_tool(dir.clone());
658 let result = tool
659 .execute(json!({
660 "path": "test.txt",
661 "old_string": "",
662 "new_string": "x"
663 }))
664 .await
665 .unwrap();
666
667 assert!(!result.success);
668 assert!(
669 result
670 .error
671 .as_deref()
672 .unwrap_or("")
673 .contains("must not be empty")
674 );
675
676 let content = tokio::fs::read_to_string(dir.join("test.txt"))
677 .await
678 .unwrap();
679 assert_eq!(content, "hello");
680
681 let _ = tokio::fs::remove_dir_all(&dir).await;
682 }
683
684 #[tokio::test]
685 async fn file_edit_blocks_path_traversal() {
686 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_traversal");
687 let _ = tokio::fs::remove_dir_all(&dir).await;
688 tokio::fs::create_dir_all(&dir).await.unwrap();
689
690 let tool = wrapped_tool(dir.clone());
691 let result = tool
692 .execute(json!({
693 "path": "../../etc/passwd",
694 "old_string": "root",
695 "new_string": "hacked"
696 }))
697 .await
698 .unwrap();
699
700 assert!(!result.success);
701 assert!(
702 result.error.as_ref().unwrap().contains("Path blocked"),
703 "expected 'Path blocked' error, got: {:?}",
704 result.error
705 );
706
707 let _ = tokio::fs::remove_dir_all(&dir).await;
708 }
709
710 #[tokio::test]
711 async fn file_edit_blocks_absolute_path() {
712 let tool = wrapped_tool(std::env::temp_dir());
713 let result = tool
714 .execute(json!({
715 "path": "/etc/passwd",
716 "old_string": "root",
717 "new_string": "hacked"
718 }))
719 .await
720 .unwrap();
721
722 assert!(!result.success);
723 assert!(
724 result.error.as_ref().unwrap().contains("Path blocked"),
725 "expected 'Path blocked' error, got: {:?}",
726 result.error
727 );
728 }
729
730 #[tokio::test]
731 async fn file_edit_normalizes_workspace_prefixed_relative_path() {
732 let root = std::env::temp_dir().join("zeroclaw_test_file_edit_workspace_prefixed");
733 let workspace = root.join("workspace");
734 let _ = tokio::fs::remove_dir_all(&root).await;
735 tokio::fs::create_dir_all(workspace.join("nested"))
736 .await
737 .unwrap();
738 tokio::fs::write(workspace.join("nested/target.txt"), "hello world")
739 .await
740 .unwrap();
741
742 let tool = test_tool(workspace.clone());
743 let workspace_prefixed = workspace
744 .strip_prefix(std::path::Path::new("/"))
745 .unwrap()
746 .join("nested/target.txt");
747 let result = tool
748 .execute(json!({
749 "path": workspace_prefixed.to_string_lossy(),
750 "old_string": "world",
751 "new_string": "zeroclaw"
752 }))
753 .await
754 .unwrap();
755
756 assert!(result.success);
757 let content = tokio::fs::read_to_string(workspace.join("nested/target.txt"))
758 .await
759 .unwrap();
760 assert_eq!(content, "hello zeroclaw");
761 assert!(!workspace.join(workspace_prefixed).exists());
762
763 let _ = tokio::fs::remove_dir_all(&root).await;
764 }
765
766 #[cfg(unix)]
767 #[tokio::test]
768 async fn file_edit_blocks_symlink_escape() {
769 use std::os::unix::fs::symlink;
770
771 let root = std::env::temp_dir().join("zeroclaw_test_file_edit_symlink_escape");
772 let workspace = root.join("workspace");
773 let outside = root.join("outside");
774
775 let _ = tokio::fs::remove_dir_all(&root).await;
776 tokio::fs::create_dir_all(&workspace).await.unwrap();
777 tokio::fs::create_dir_all(&outside).await.unwrap();
778
779 symlink(&outside, workspace.join("escape_dir")).unwrap();
780
781 let tool = test_tool(workspace.clone());
782 let result = tool
783 .execute(json!({
784 "path": "escape_dir/target.txt",
785 "old_string": "a",
786 "new_string": "b"
787 }))
788 .await
789 .unwrap();
790
791 assert!(!result.success);
792 assert!(
793 result
794 .error
795 .as_deref()
796 .unwrap_or("")
797 .contains("escapes workspace")
798 );
799
800 let _ = tokio::fs::remove_dir_all(&root).await;
801 }
802
803 #[cfg(unix)]
804 #[tokio::test]
805 async fn file_edit_blocks_symlink_target_file() {
806 use std::os::unix::fs::symlink;
807
808 let root = std::env::temp_dir().join("zeroclaw_test_file_edit_symlink_target");
809 let workspace = root.join("workspace");
810 let outside = root.join("outside");
811
812 let _ = tokio::fs::remove_dir_all(&root).await;
813 tokio::fs::create_dir_all(&workspace).await.unwrap();
814 tokio::fs::create_dir_all(&outside).await.unwrap();
815
816 tokio::fs::write(outside.join("target.txt"), "original")
817 .await
818 .unwrap();
819 symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
820
821 let tool = test_tool(workspace.clone());
822 let result = tool
823 .execute(json!({
824 "path": "linked.txt",
825 "old_string": "original",
826 "new_string": "hacked"
827 }))
828 .await
829 .unwrap();
830
831 assert!(!result.success, "editing through symlink must be blocked");
832 assert!(
833 result.error.as_deref().unwrap_or("").contains("symlink"),
834 "error should mention symlink"
835 );
836
837 let content = tokio::fs::read_to_string(outside.join("target.txt"))
838 .await
839 .unwrap();
840 assert_eq!(content, "original", "original file must not be modified");
841
842 let _ = tokio::fs::remove_dir_all(&root).await;
843 }
844
845 #[tokio::test]
846 async fn file_edit_blocks_readonly_mode() {
847 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_readonly");
848 let _ = tokio::fs::remove_dir_all(&dir).await;
849 tokio::fs::create_dir_all(&dir).await.unwrap();
850 tokio::fs::write(dir.join("test.txt"), "hello")
851 .await
852 .unwrap();
853
854 let tool = test_tool_with(dir.clone(), AutonomyLevel::ReadOnly, 20);
855 let result = tool
856 .execute(json!({
857 "path": "test.txt",
858 "old_string": "hello",
859 "new_string": "world"
860 }))
861 .await
862 .unwrap();
863
864 assert!(!result.success);
865 assert!(result.error.as_deref().unwrap_or("").contains("read-only"));
866
867 let content = tokio::fs::read_to_string(dir.join("test.txt"))
868 .await
869 .unwrap();
870 assert_eq!(content, "hello");
871
872 let _ = tokio::fs::remove_dir_all(&dir).await;
873 }
874
875 #[tokio::test]
876 async fn file_edit_nonexistent_file() {
877 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_nofile");
878 let _ = tokio::fs::remove_dir_all(&dir).await;
879 tokio::fs::create_dir_all(&dir).await.unwrap();
880
881 let tool = test_tool(dir.clone());
882 let result = tool
883 .execute(json!({
884 "path": "missing.txt",
885 "old_string": "a",
886 "new_string": "b"
887 }))
888 .await
889 .unwrap();
890
891 assert!(!result.success);
892 assert!(
893 result
894 .error
895 .as_deref()
896 .unwrap_or("")
897 .contains("Failed to read file")
898 );
899
900 let _ = tokio::fs::remove_dir_all(&dir).await;
901 }
902
903 #[tokio::test]
904 async fn file_edit_absolute_path_in_workspace() {
905 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_abs_path");
906 let _ = tokio::fs::remove_dir_all(&dir).await;
907 tokio::fs::create_dir_all(&dir).await.unwrap();
908
909 let dir = tokio::fs::canonicalize(&dir).await.unwrap();
911
912 tokio::fs::write(dir.join("target.txt"), "old content")
913 .await
914 .unwrap();
915
916 let tool = test_tool(dir.clone());
917
918 let abs_path = dir.join("target.txt");
919 let result = tool
920 .execute(json!({
921 "path": abs_path.to_string_lossy().to_string(),
922 "old_string": "old content",
923 "new_string": "new content"
924 }))
925 .await
926 .unwrap();
927
928 assert!(
929 result.success,
930 "editing via absolute workspace path should succeed, error: {:?}",
931 result.error
932 );
933
934 let content = tokio::fs::read_to_string(dir.join("target.txt"))
935 .await
936 .unwrap();
937 assert_eq!(content, "new content");
938
939 let _ = tokio::fs::remove_dir_all(&dir).await;
940 }
941
942 #[tokio::test]
943 async fn file_edit_blocks_null_byte_in_path() {
944 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_null_byte");
945 let _ = tokio::fs::remove_dir_all(&dir).await;
946 tokio::fs::create_dir_all(&dir).await.unwrap();
947
948 let tool = wrapped_tool(dir.clone());
949 let result = tool
950 .execute(json!({
951 "path": "test\0evil.txt",
952 "old_string": "old",
953 "new_string": "new"
954 }))
955 .await
956 .unwrap();
957 assert!(!result.success);
958 assert!(
959 result.error.as_ref().unwrap().contains("Path blocked"),
960 "expected 'Path blocked' error, got: {:?}",
961 result.error
962 );
963
964 let _ = tokio::fs::remove_dir_all(&dir).await;
965 }
966
967 #[tokio::test]
968 async fn file_edit_blocks_path_outside_workspace() {
969 let root = std::env::temp_dir().join("zeroclaw_test_file_edit_outside_workspace");
970 let workspace = root.join("workspace");
971 let outside = root.join("outside.txt");
972 let _ = tokio::fs::remove_dir_all(&root).await;
973 tokio::fs::create_dir_all(&workspace).await.unwrap();
974 tokio::fs::write(&outside, "original").await.unwrap();
975
976 let tool = test_tool(workspace.clone());
977 let result = tool
978 .execute(json!({
979 "path": outside.to_string_lossy(),
980 "old_string": "original",
981 "new_string": "hacked"
982 }))
983 .await
984 .unwrap();
985
986 assert!(!result.success);
987 let content = tokio::fs::read_to_string(&outside).await.unwrap();
988 assert_eq!(
989 content, "original",
990 "file outside workspace must not be modified"
991 );
992
993 let _ = tokio::fs::remove_dir_all(&root).await;
994 }
995}