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};
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}
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        // ── 1. Extract parameters ──────────────────────────────────
56        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        // ── 2. Autonomy check ──────────────────────────────────────
104        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        // Rate limiting and path-allowlist checks are applied by the
113        // RateLimitedTool + PathGuardedTool wrappers at registration time
114        // (see zeroclaw-runtime::tools::mod).
115
116        let full_path = self.security.resolve_tool_path(path);
117
118        // ── 5. Canonicalise parent ─────────────────────────────────
119        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        // ── 6. Resolved path post-validation ───────────────────────
139        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        // ── 7. Symlink check ───────────────────────────────────────
172        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        // ── 9. Read → match → replace → write ─────────────────────
186        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    /// Wraps `FileEditTool` with the production `PathGuardedTool` + `RateLimitedTool`
254    /// stack, mirroring the registration in `zeroclaw-runtime::tools::mod`. Use this
255    /// in tests that exercise path-allowlist or rate-limit behavior.
256    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        // Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
719        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}