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 FileEditTool {
14 security: Arc<SecurityPolicy>,
15}
16
17impl FileEditTool {
18 pub fn new(security: Arc<SecurityPolicy>) -> Self {
19 Self { security }
20 }
21}
22
23#[async_trait]
24impl Tool for FileEditTool {
25 fn name(&self) -> &str {
26 "file_edit"
27 }
28
29 fn description(&self) -> &str {
30 "Edit a file by replacing an exact string match with new content"
31 }
32
33 fn parameters_schema(&self) -> serde_json::Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "path": {
38 "type": "string",
39 "description": "Path to the file. Relative paths resolve from workspace; outside paths require policy allowlist."
40 },
41 "old_string": {
42 "type": "string",
43 "description": "The exact text to find and replace (must appear exactly once in the file)"
44 },
45 "new_string": {
46 "type": "string",
47 "description": "The replacement text (empty string to delete the matched text)"
48 }
49 },
50 "required": ["path", "old_string", "new_string"]
51 })
52 }
53
54 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
55 let path = args.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
57 ::zeroclaw_log::record!(
58 WARN,
59 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
60 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
61 .with_attrs(::serde_json::json!({"param": "path"})),
62 "file_edit: missing path parameter"
63 );
64 anyhow::Error::msg("Missing 'path' parameter")
65 })?;
66
67 let old_string = args
68 .get("old_string")
69 .and_then(|v| v.as_str())
70 .ok_or_else(|| {
71 ::zeroclaw_log::record!(
72 WARN,
73 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
74 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
75 .with_attrs(::serde_json::json!({"param": "old_string"})),
76 "file_edit: missing old_string parameter"
77 );
78 anyhow::Error::msg("Missing 'old_string' parameter")
79 })?;
80
81 let new_string = args
82 .get("new_string")
83 .and_then(|v| v.as_str())
84 .ok_or_else(|| {
85 ::zeroclaw_log::record!(
86 WARN,
87 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
88 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
89 .with_attrs(::serde_json::json!({"param": "new_string"})),
90 "file_edit: missing new_string parameter"
91 );
92 anyhow::Error::msg("Missing 'new_string' parameter")
93 })?;
94
95 if old_string.is_empty() {
96 return Ok(ToolResult {
97 success: false,
98 output: String::new(),
99 error: Some("old_string must not be empty".into()),
100 });
101 }
102
103 if !self.security.can_act() {
105 return Ok(ToolResult {
106 success: false,
107 output: String::new(),
108 error: Some("Action blocked: autonomy is read-only".into()),
109 });
110 }
111
112 let full_path = self.security.resolve_tool_path(path);
117
118 let Some(parent) = full_path.parent() else {
120 return Ok(ToolResult {
121 success: false,
122 output: String::new(),
123 error: Some("Invalid path: missing parent directory".into()),
124 });
125 };
126
127 let resolved_parent = match tokio::fs::canonicalize(parent).await {
128 Ok(p) => p,
129 Err(e) => {
130 return Ok(ToolResult {
131 success: false,
132 output: String::new(),
133 error: Some(format!("Failed to resolve file path: {e}")),
134 });
135 }
136 };
137
138 if !self.security.is_resolved_path_allowed(&resolved_parent) {
140 return Ok(ToolResult {
141 success: false,
142 output: String::new(),
143 error: Some(
144 self.security
145 .resolved_path_violation_message(&resolved_parent),
146 ),
147 });
148 }
149
150 let Some(file_name) = full_path.file_name() else {
151 return Ok(ToolResult {
152 success: false,
153 output: String::new(),
154 error: Some("Invalid path: missing file name".into()),
155 });
156 };
157
158 let resolved_target = resolved_parent.join(file_name);
159
160 if self.security.is_runtime_config_path(&resolved_target) {
161 return Ok(ToolResult {
162 success: false,
163 output: String::new(),
164 error: Some(
165 self.security
166 .runtime_config_violation_message(&resolved_target),
167 ),
168 });
169 }
170
171 if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await
173 && meta.file_type().is_symlink()
174 {
175 return Ok(ToolResult {
176 success: false,
177 output: String::new(),
178 error: Some(format!(
179 "Refusing to edit through symlink: {}",
180 resolved_target.display()
181 )),
182 });
183 }
184
185 let content = match tokio::fs::read_to_string(&resolved_target).await {
187 Ok(c) => c,
188 Err(e) => {
189 return Ok(ToolResult {
190 success: false,
191 output: String::new(),
192 error: Some(format!("Failed to read file: {e}")),
193 });
194 }
195 };
196
197 let match_count = content.matches(old_string).count();
198
199 if match_count == 0 {
200 return Ok(ToolResult {
201 success: false,
202 output: String::new(),
203 error: Some("old_string not found in file".into()),
204 });
205 }
206
207 if match_count > 1 {
208 return Ok(ToolResult {
209 success: false,
210 output: String::new(),
211 error: Some(format!(
212 "old_string matches {match_count} times; must match exactly once"
213 )),
214 });
215 }
216
217 let new_content = content.replacen(old_string, new_string, 1);
218
219 match tokio::fs::write(&resolved_target, &new_content).await {
220 Ok(()) => Ok(ToolResult {
221 success: true,
222 output: format!(
223 "Edited {path}: replaced 1 occurrence ({} bytes)",
224 new_content.len()
225 ),
226 error: None,
227 }),
228 Err(e) => Ok(ToolResult {
229 success: false,
230 output: String::new(),
231 error: Some(format!("Failed to write file: {e}")),
232 }),
233 }
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::wrappers::{PathGuardedTool, RateLimitedTool};
241 use zeroclaw_config::autonomy::AutonomyLevel;
242 use zeroclaw_config::policy::SecurityPolicy;
243
244 fn test_tool(workspace: std::path::PathBuf) -> FileEditTool {
245 let security = Arc::new(SecurityPolicy {
246 autonomy: AutonomyLevel::Supervised,
247 workspace_dir: workspace,
248 ..SecurityPolicy::default()
249 });
250 FileEditTool::new(security)
251 }
252
253 fn wrapped_tool(workspace: std::path::PathBuf) -> Box<dyn Tool> {
257 let security = Arc::new(SecurityPolicy {
258 autonomy: AutonomyLevel::Supervised,
259 workspace_dir: workspace,
260 ..SecurityPolicy::default()
261 });
262 Box::new(RateLimitedTool::new(
263 PathGuardedTool::new(FileEditTool::new(security.clone()), security.clone()),
264 security,
265 ))
266 }
267
268 fn test_tool_with(
269 workspace: std::path::PathBuf,
270 autonomy: AutonomyLevel,
271 max_actions_per_hour: u32,
272 ) -> FileEditTool {
273 let security = Arc::new(SecurityPolicy {
274 autonomy,
275 workspace_dir: workspace,
276 max_actions_per_hour,
277 ..SecurityPolicy::default()
278 });
279 FileEditTool::new(security)
280 }
281
282 #[test]
283 fn file_edit_name() {
284 let tool = test_tool(std::env::temp_dir());
285 assert_eq!(tool.name(), "file_edit");
286 }
287
288 #[test]
289 fn file_edit_schema_has_required_params() {
290 let tool = test_tool(std::env::temp_dir());
291 let schema = tool.parameters_schema();
292 assert!(schema["properties"]["path"].is_object());
293 assert!(schema["properties"]["old_string"].is_object());
294 assert!(schema["properties"]["new_string"].is_object());
295 let required = schema["required"].as_array().unwrap();
296 assert!(required.contains(&json!("path")));
297 assert!(required.contains(&json!("old_string")));
298 assert!(required.contains(&json!("new_string")));
299 }
300
301 #[tokio::test]
302 async fn file_edit_replaces_single_match() {
303 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_single");
304 let _ = tokio::fs::remove_dir_all(&dir).await;
305 tokio::fs::create_dir_all(&dir).await.unwrap();
306 tokio::fs::write(dir.join("test.txt"), "hello world")
307 .await
308 .unwrap();
309
310 let tool = test_tool(dir.clone());
311 let result = tool
312 .execute(json!({
313 "path": "test.txt",
314 "old_string": "hello",
315 "new_string": "goodbye"
316 }))
317 .await
318 .unwrap();
319
320 assert!(result.success, "edit should succeed: {:?}", result.error);
321 assert!(result.output.contains("replaced 1 occurrence"));
322
323 let content = tokio::fs::read_to_string(dir.join("test.txt"))
324 .await
325 .unwrap();
326 assert_eq!(content, "goodbye world");
327
328 let _ = tokio::fs::remove_dir_all(&dir).await;
329 }
330
331 #[tokio::test]
332 async fn file_edit_not_found() {
333 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_notfound");
334 let _ = tokio::fs::remove_dir_all(&dir).await;
335 tokio::fs::create_dir_all(&dir).await.unwrap();
336 tokio::fs::write(dir.join("test.txt"), "hello world")
337 .await
338 .unwrap();
339
340 let tool = test_tool(dir.clone());
341 let result = tool
342 .execute(json!({
343 "path": "test.txt",
344 "old_string": "nonexistent",
345 "new_string": "replacement"
346 }))
347 .await
348 .unwrap();
349
350 assert!(!result.success);
351 assert!(result.error.as_deref().unwrap_or("").contains("not found"));
352
353 let content = tokio::fs::read_to_string(dir.join("test.txt"))
354 .await
355 .unwrap();
356 assert_eq!(content, "hello world");
357
358 let _ = tokio::fs::remove_dir_all(&dir).await;
359 }
360
361 #[tokio::test]
362 async fn file_edit_multiple_matches() {
363 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_multi");
364 let _ = tokio::fs::remove_dir_all(&dir).await;
365 tokio::fs::create_dir_all(&dir).await.unwrap();
366 tokio::fs::write(dir.join("test.txt"), "aaa bbb aaa")
367 .await
368 .unwrap();
369
370 let tool = test_tool(dir.clone());
371 let result = tool
372 .execute(json!({
373 "path": "test.txt",
374 "old_string": "aaa",
375 "new_string": "ccc"
376 }))
377 .await
378 .unwrap();
379
380 assert!(!result.success);
381 assert!(
382 result
383 .error
384 .as_deref()
385 .unwrap_or("")
386 .contains("matches 2 times")
387 );
388
389 let content = tokio::fs::read_to_string(dir.join("test.txt"))
390 .await
391 .unwrap();
392 assert_eq!(content, "aaa bbb aaa");
393
394 let _ = tokio::fs::remove_dir_all(&dir).await;
395 }
396
397 #[tokio::test]
398 async fn file_edit_delete_via_empty_new_string() {
399 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_delete");
400 let _ = tokio::fs::remove_dir_all(&dir).await;
401 tokio::fs::create_dir_all(&dir).await.unwrap();
402 tokio::fs::write(dir.join("test.txt"), "keep remove keep")
403 .await
404 .unwrap();
405
406 let tool = test_tool(dir.clone());
407 let result = tool
408 .execute(json!({
409 "path": "test.txt",
410 "old_string": " remove",
411 "new_string": ""
412 }))
413 .await
414 .unwrap();
415
416 assert!(
417 result.success,
418 "delete edit should succeed: {:?}",
419 result.error
420 );
421
422 let content = tokio::fs::read_to_string(dir.join("test.txt"))
423 .await
424 .unwrap();
425 assert_eq!(content, "keep keep");
426
427 let _ = tokio::fs::remove_dir_all(&dir).await;
428 }
429
430 #[tokio::test]
431 async fn file_edit_missing_path_param() {
432 let tool = test_tool(std::env::temp_dir());
433 let result = tool
434 .execute(json!({"old_string": "a", "new_string": "b"}))
435 .await;
436 assert!(result.is_err());
437 }
438
439 #[tokio::test]
440 async fn file_edit_missing_old_string_param() {
441 let tool = test_tool(std::env::temp_dir());
442 let result = tool
443 .execute(json!({"path": "f.txt", "new_string": "b"}))
444 .await;
445 assert!(result.is_err());
446 }
447
448 #[tokio::test]
449 async fn file_edit_missing_new_string_param() {
450 let tool = test_tool(std::env::temp_dir());
451 let result = tool
452 .execute(json!({"path": "f.txt", "old_string": "a"}))
453 .await;
454 assert!(result.is_err());
455 }
456
457 #[tokio::test]
458 async fn file_edit_rejects_empty_old_string() {
459 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_empty_old_string");
460 let _ = tokio::fs::remove_dir_all(&dir).await;
461 tokio::fs::create_dir_all(&dir).await.unwrap();
462 tokio::fs::write(dir.join("test.txt"), "hello")
463 .await
464 .unwrap();
465
466 let tool = test_tool(dir.clone());
467 let result = tool
468 .execute(json!({
469 "path": "test.txt",
470 "old_string": "",
471 "new_string": "x"
472 }))
473 .await
474 .unwrap();
475
476 assert!(!result.success);
477 assert!(
478 result
479 .error
480 .as_deref()
481 .unwrap_or("")
482 .contains("must not be empty")
483 );
484
485 let content = tokio::fs::read_to_string(dir.join("test.txt"))
486 .await
487 .unwrap();
488 assert_eq!(content, "hello");
489
490 let _ = tokio::fs::remove_dir_all(&dir).await;
491 }
492
493 #[tokio::test]
494 async fn file_edit_blocks_path_traversal() {
495 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_traversal");
496 let _ = tokio::fs::remove_dir_all(&dir).await;
497 tokio::fs::create_dir_all(&dir).await.unwrap();
498
499 let tool = wrapped_tool(dir.clone());
500 let result = tool
501 .execute(json!({
502 "path": "../../etc/passwd",
503 "old_string": "root",
504 "new_string": "hacked"
505 }))
506 .await
507 .unwrap();
508
509 assert!(!result.success);
510 assert!(
511 result.error.as_ref().unwrap().contains("Path blocked"),
512 "expected 'Path blocked' error, got: {:?}",
513 result.error
514 );
515
516 let _ = tokio::fs::remove_dir_all(&dir).await;
517 }
518
519 #[tokio::test]
520 async fn file_edit_blocks_absolute_path() {
521 let tool = wrapped_tool(std::env::temp_dir());
522 let result = tool
523 .execute(json!({
524 "path": "/etc/passwd",
525 "old_string": "root",
526 "new_string": "hacked"
527 }))
528 .await
529 .unwrap();
530
531 assert!(!result.success);
532 assert!(
533 result.error.as_ref().unwrap().contains("Path blocked"),
534 "expected 'Path blocked' error, got: {:?}",
535 result.error
536 );
537 }
538
539 #[tokio::test]
540 async fn file_edit_normalizes_workspace_prefixed_relative_path() {
541 let root = std::env::temp_dir().join("zeroclaw_test_file_edit_workspace_prefixed");
542 let workspace = root.join("workspace");
543 let _ = tokio::fs::remove_dir_all(&root).await;
544 tokio::fs::create_dir_all(workspace.join("nested"))
545 .await
546 .unwrap();
547 tokio::fs::write(workspace.join("nested/target.txt"), "hello world")
548 .await
549 .unwrap();
550
551 let tool = test_tool(workspace.clone());
552 let workspace_prefixed = workspace
553 .strip_prefix(std::path::Path::new("/"))
554 .unwrap()
555 .join("nested/target.txt");
556 let result = tool
557 .execute(json!({
558 "path": workspace_prefixed.to_string_lossy(),
559 "old_string": "world",
560 "new_string": "zeroclaw"
561 }))
562 .await
563 .unwrap();
564
565 assert!(result.success);
566 let content = tokio::fs::read_to_string(workspace.join("nested/target.txt"))
567 .await
568 .unwrap();
569 assert_eq!(content, "hello zeroclaw");
570 assert!(!workspace.join(workspace_prefixed).exists());
571
572 let _ = tokio::fs::remove_dir_all(&root).await;
573 }
574
575 #[cfg(unix)]
576 #[tokio::test]
577 async fn file_edit_blocks_symlink_escape() {
578 use std::os::unix::fs::symlink;
579
580 let root = std::env::temp_dir().join("zeroclaw_test_file_edit_symlink_escape");
581 let workspace = root.join("workspace");
582 let outside = root.join("outside");
583
584 let _ = tokio::fs::remove_dir_all(&root).await;
585 tokio::fs::create_dir_all(&workspace).await.unwrap();
586 tokio::fs::create_dir_all(&outside).await.unwrap();
587
588 symlink(&outside, workspace.join("escape_dir")).unwrap();
589
590 let tool = test_tool(workspace.clone());
591 let result = tool
592 .execute(json!({
593 "path": "escape_dir/target.txt",
594 "old_string": "a",
595 "new_string": "b"
596 }))
597 .await
598 .unwrap();
599
600 assert!(!result.success);
601 assert!(
602 result
603 .error
604 .as_deref()
605 .unwrap_or("")
606 .contains("escapes workspace")
607 );
608
609 let _ = tokio::fs::remove_dir_all(&root).await;
610 }
611
612 #[cfg(unix)]
613 #[tokio::test]
614 async fn file_edit_blocks_symlink_target_file() {
615 use std::os::unix::fs::symlink;
616
617 let root = std::env::temp_dir().join("zeroclaw_test_file_edit_symlink_target");
618 let workspace = root.join("workspace");
619 let outside = root.join("outside");
620
621 let _ = tokio::fs::remove_dir_all(&root).await;
622 tokio::fs::create_dir_all(&workspace).await.unwrap();
623 tokio::fs::create_dir_all(&outside).await.unwrap();
624
625 tokio::fs::write(outside.join("target.txt"), "original")
626 .await
627 .unwrap();
628 symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap();
629
630 let tool = test_tool(workspace.clone());
631 let result = tool
632 .execute(json!({
633 "path": "linked.txt",
634 "old_string": "original",
635 "new_string": "hacked"
636 }))
637 .await
638 .unwrap();
639
640 assert!(!result.success, "editing through symlink must be blocked");
641 assert!(
642 result.error.as_deref().unwrap_or("").contains("symlink"),
643 "error should mention symlink"
644 );
645
646 let content = tokio::fs::read_to_string(outside.join("target.txt"))
647 .await
648 .unwrap();
649 assert_eq!(content, "original", "original file must not be modified");
650
651 let _ = tokio::fs::remove_dir_all(&root).await;
652 }
653
654 #[tokio::test]
655 async fn file_edit_blocks_readonly_mode() {
656 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_readonly");
657 let _ = tokio::fs::remove_dir_all(&dir).await;
658 tokio::fs::create_dir_all(&dir).await.unwrap();
659 tokio::fs::write(dir.join("test.txt"), "hello")
660 .await
661 .unwrap();
662
663 let tool = test_tool_with(dir.clone(), AutonomyLevel::ReadOnly, 20);
664 let result = tool
665 .execute(json!({
666 "path": "test.txt",
667 "old_string": "hello",
668 "new_string": "world"
669 }))
670 .await
671 .unwrap();
672
673 assert!(!result.success);
674 assert!(result.error.as_deref().unwrap_or("").contains("read-only"));
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_nonexistent_file() {
686 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_nofile");
687 let _ = tokio::fs::remove_dir_all(&dir).await;
688 tokio::fs::create_dir_all(&dir).await.unwrap();
689
690 let tool = test_tool(dir.clone());
691 let result = tool
692 .execute(json!({
693 "path": "missing.txt",
694 "old_string": "a",
695 "new_string": "b"
696 }))
697 .await
698 .unwrap();
699
700 assert!(!result.success);
701 assert!(
702 result
703 .error
704 .as_deref()
705 .unwrap_or("")
706 .contains("Failed to read file")
707 );
708
709 let _ = tokio::fs::remove_dir_all(&dir).await;
710 }
711
712 #[tokio::test]
713 async fn file_edit_absolute_path_in_workspace() {
714 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_abs_path");
715 let _ = tokio::fs::remove_dir_all(&dir).await;
716 tokio::fs::create_dir_all(&dir).await.unwrap();
717
718 let dir = tokio::fs::canonicalize(&dir).await.unwrap();
720
721 tokio::fs::write(dir.join("target.txt"), "old content")
722 .await
723 .unwrap();
724
725 let tool = test_tool(dir.clone());
726
727 let abs_path = dir.join("target.txt");
728 let result = tool
729 .execute(json!({
730 "path": abs_path.to_string_lossy().to_string(),
731 "old_string": "old content",
732 "new_string": "new content"
733 }))
734 .await
735 .unwrap();
736
737 assert!(
738 result.success,
739 "editing via absolute workspace path should succeed, error: {:?}",
740 result.error
741 );
742
743 let content = tokio::fs::read_to_string(dir.join("target.txt"))
744 .await
745 .unwrap();
746 assert_eq!(content, "new content");
747
748 let _ = tokio::fs::remove_dir_all(&dir).await;
749 }
750
751 #[tokio::test]
752 async fn file_edit_blocks_null_byte_in_path() {
753 let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_null_byte");
754 let _ = tokio::fs::remove_dir_all(&dir).await;
755 tokio::fs::create_dir_all(&dir).await.unwrap();
756
757 let tool = wrapped_tool(dir.clone());
758 let result = tool
759 .execute(json!({
760 "path": "test\0evil.txt",
761 "old_string": "old",
762 "new_string": "new"
763 }))
764 .await
765 .unwrap();
766 assert!(!result.success);
767 assert!(
768 result.error.as_ref().unwrap().contains("Path blocked"),
769 "expected 'Path blocked' error, got: {:?}",
770 result.error
771 );
772
773 let _ = tokio::fs::remove_dir_all(&dir).await;
774 }
775
776 #[tokio::test]
777 async fn file_edit_blocks_path_outside_workspace() {
778 let root = std::env::temp_dir().join("zeroclaw_test_file_edit_outside_workspace");
779 let workspace = root.join("workspace");
780 let outside = root.join("outside.txt");
781 let _ = tokio::fs::remove_dir_all(&root).await;
782 tokio::fs::create_dir_all(&workspace).await.unwrap();
783 tokio::fs::write(&outside, "original").await.unwrap();
784
785 let tool = test_tool(workspace.clone());
786 let result = tool
787 .execute(json!({
788 "path": outside.to_string_lossy(),
789 "old_string": "original",
790 "new_string": "hacked"
791 }))
792 .await
793 .unwrap();
794
795 assert!(!result.success);
796 let content = tokio::fs::read_to_string(&outside).await.unwrap();
797 assert_eq!(
798 content, "original",
799 "file outside workspace must not be modified"
800 );
801
802 let _ = tokio::fs::remove_dir_all(&root).await;
803 }
804}