Skip to main content

zeroclaw_tools/
file_edit.rs

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
7/// Edit a file by replacing an exact string match with new content.
8///
9/// Uses `old_string` → `new_string` precise replacement within the workspace.
10/// The `old_string` must appear exactly once in the file (zero matches = not
11/// found, multiple matches = ambiguous). `new_string` may be empty to delete
12/// the matched text. Security checks mirror [`super::file_write::FileWriteTool`].
13pub struct FileEditTool {
14    security: Arc<SecurityPolicy>,
15    /// Whether edits to the workspace persist on the host filesystem. `false`
16    /// on an ephemeral runtime (Docker tmpfs / no volume mount), where the
17    /// rewritten file succeeds inside the container but is invisible on the
18    /// host and discarded at session end. When `false`, successful edits carry
19    /// a loud ephemeral-workspace warning. Mirrors
20    /// [`super::file_write::FileWriteTool`]. See issue #4627.
21    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    /// Construct with an explicit persistence flag derived from the active
33    /// runtime adapter's `has_filesystem_access()`. Mirrors
34    /// [`super::file_write::FileWriteTool::new_with_persistence`].
35    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        // A successful edit on an ephemeral runtime rewrites a file that never
77        // reaches the host and is lost at session end; warn loudly (issue #4627).
78        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    /// Perform the exact-string replacement edit. The ephemeral workspace
87    /// warning is applied by the `Tool::execute` wrapper above.
88    async fn edit_file(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
89        // ── 1. Extract parameters ──────────────────────────────────
90        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        // ── 2. Autonomy check ──────────────────────────────────────
138        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        // Rate limiting and path-allowlist checks are applied by the
147        // RateLimitedTool + PathGuardedTool wrappers at registration time
148        // (see zeroclaw-runtime::tools::mod).
149
150        let full_path = self.security.resolve_tool_path(path);
151
152        // ── 5. Canonicalise parent ─────────────────────────────────
153        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        // ── 6. Resolved path post-validation ───────────────────────
173        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        // ── 7. Symlink check ───────────────────────────────────────
206        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        // ── 9. Read → match → replace → write ─────────────────────
220        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
271/// Build an actionable error when `old_string` has zero exact matches.
272///
273/// The common failure is a leading-whitespace mismatch (indentation width or
274/// tabs-vs-spaces) where the text is otherwise identical. A bare "not found"
275/// gives the caller nothing to act on and invites blind retries. When the only
276/// difference is leading whitespace, say so explicitly so the caller can fix
277/// indentation in one shot instead of guessing.
278fn 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    /// Wraps `FileEditTool` with the production `PathGuardedTool` + `RateLimitedTool`
323    /// stack, mirroring the registration in `zeroclaw-runtime::tools::mod`. Use this
324    /// in tests that exercise path-allowlist or rate-limit behavior.
325    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    // ── Ephemeral-workspace warning (issue #4627) ────────────────
367
368    /// A successful edit on an ephemeral runtime rewrites a file that won't
369    /// persist; the output carries a loud warning while preserving the status.
370    #[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    /// A failed edit performs no write — not data loss — so no banner is added.
401    #[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    /// On a persistent runtime (the default) no warning is attached.
429    #[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        // File uses 4-space indent; old_string uses 5-space. Same content
456        // otherwise — the diagnostic must point at indentation, not say "not found".
457        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        // Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
910        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}