Skip to main content

construct/tools/
file_edit.rs

1use super::traits::{Tool, ToolResult};
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
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
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        // ── 2. Autonomy check ──────────────────────────────────────
80        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        // ── 3. Rate limit check ────────────────────────────────────
89        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        // ── 4. Path pre-validation ─────────────────────────────────
98        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        // ── 5. Canonicalize parent ─────────────────────────────────
109        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        // ── 6. Resolved path post-validation ───────────────────────
129        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        // ── 7. Symlink check ───────────────────────────────────────
162        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        // ── 8. Record action ───────────────────────────────────────
176        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        // ── 9. Read → match → replace → write ─────────────────────
185        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        // File should be unchanged
334        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        // File should be unchanged
371        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        // Canonicalize so the workspace dir matches resolved paths on macOS (/private/var/…)
733        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        // Pass an absolute path that is within the workspace
742        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}