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