Skip to main content

agentzero_tools/
file_edit.rs

1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::{anyhow, Context};
3use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::{Component, Path, PathBuf};
6use tokio::fs;
7
8#[derive(Debug, Deserialize)]
9struct FileEditInput {
10    path: String,
11    edits: Vec<Edit>,
12    #[serde(default)]
13    dry_run: bool,
14}
15
16#[derive(Debug, Deserialize)]
17struct Edit {
18    old_text: String,
19    new_text: String,
20}
21
22pub struct FileEditTool {
23    allowed_root: PathBuf,
24    max_file_bytes: u64,
25}
26
27impl FileEditTool {
28    pub fn new(allowed_root: PathBuf, max_file_bytes: u64) -> Self {
29        Self {
30            allowed_root,
31            max_file_bytes,
32        }
33    }
34
35    fn resolve_path(&self, input_path: &str, workspace_root: &str) -> anyhow::Result<PathBuf> {
36        if input_path.trim().is_empty() {
37            return Err(anyhow!("file_edit.path is required"));
38        }
39        let relative = Path::new(input_path);
40        if relative.is_absolute() {
41            return Err(anyhow!("absolute paths are not allowed"));
42        }
43        if relative
44            .components()
45            .any(|c| matches!(c, Component::ParentDir))
46        {
47            return Err(anyhow!("path traversal is not allowed"));
48        }
49
50        let joined = Path::new(workspace_root).join(relative);
51        let canonical = joined
52            .canonicalize()
53            .with_context(|| format!("unable to resolve path: {input_path}"))?;
54        let canonical_root = self
55            .allowed_root
56            .canonicalize()
57            .context("unable to resolve allowed root")?;
58        if !canonical.starts_with(&canonical_root) {
59            return Err(anyhow!("path is outside allowed root"));
60        }
61        Ok(canonical)
62    }
63}
64
65#[async_trait]
66impl Tool for FileEditTool {
67    fn name(&self) -> &'static str {
68        "file_edit"
69    }
70
71    fn description(&self) -> &'static str {
72        "Apply surgical text edits to a file by replacing exact old_text matches with new_text. Supports multiple edits and dry-run mode."
73    }
74
75    fn input_schema(&self) -> Option<serde_json::Value> {
76        Some(serde_json::json!({
77            "type": "object",
78            "properties": {
79                "path": {
80                    "type": "string",
81                    "description": "Path to the file to edit"
82                },
83                "edits": {
84                    "type": "array",
85                    "items": {
86                        "type": "object",
87                        "properties": {
88                            "old_text": { "type": "string", "description": "Exact text to find" },
89                            "new_text": { "type": "string", "description": "Replacement text" }
90                        },
91                        "required": ["old_text", "new_text"]
92                    },
93                    "description": "Array of search-and-replace edits"
94                },
95                "dry_run": {
96                    "type": "boolean",
97                    "description": "If true, show what would change without modifying the file"
98                }
99            },
100            "required": ["path", "edits"]
101        }))
102    }
103
104    async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
105        let request: FileEditInput = serde_json::from_str(input).context(
106            "file_edit expects JSON: {\"path\", \"edits\": [{\"old_text\", \"new_text\"}], \"dry_run\"}",
107        )?;
108
109        if request.edits.is_empty() {
110            return Err(anyhow!("edits array must not be empty"));
111        }
112
113        let dest = self.resolve_path(&request.path, &ctx.workspace_root)?;
114
115        // B7: Hard-link guard.
116        crate::autonomy::AutonomyPolicy::check_hard_links(&dest.to_string_lossy())?;
117
118        // B7: Sensitive file detection.
119        if !ctx.allow_sensitive_file_writes
120            && crate::autonomy::is_sensitive_path(&dest.to_string_lossy())
121        {
122            return Err(anyhow!(
123                "refusing to edit sensitive file: {}",
124                dest.display()
125            ));
126        }
127
128        let content = fs::read_to_string(&dest)
129            .await
130            .with_context(|| format!("failed to read file: {}", request.path))?;
131
132        if content.len() as u64 > self.max_file_bytes {
133            return Err(anyhow!(
134                "file is too large ({} bytes, max {})",
135                content.len(),
136                self.max_file_bytes
137            ));
138        }
139
140        let mut result = content;
141        for (i, edit) in request.edits.iter().enumerate() {
142            if edit.old_text.is_empty() {
143                return Err(anyhow!("edit {} has empty old_text", i + 1));
144            }
145            if edit.old_text == edit.new_text {
146                return Err(anyhow!(
147                    "edit {} has identical old_text and new_text",
148                    i + 1
149                ));
150            }
151
152            let count = result.matches(&edit.old_text).count();
153            if count == 0 {
154                return Err(anyhow!("edit {}: old_text not found in file", i + 1));
155            }
156            if count > 1 {
157                return Err(anyhow!(
158                    "edit {}: old_text matches {} locations (must be unique)",
159                    i + 1,
160                    count
161                ));
162            }
163
164            result = result.replacen(&edit.old_text, &edit.new_text, 1);
165        }
166
167        if request.dry_run {
168            return Ok(ToolResult {
169                output: format!(
170                    "dry_run=true path={} edits={}",
171                    request.path,
172                    request.edits.len()
173                ),
174            });
175        }
176
177        fs::write(&dest, &result)
178            .await
179            .with_context(|| format!("failed to write file: {}", request.path))?;
180
181        Ok(ToolResult {
182            output: format!(
183                "path={} edits={} bytes={}",
184                request.path,
185                request.edits.len(),
186                result.len()
187            ),
188        })
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::FileEditTool;
195    use agentzero_core::{Tool, ToolContext};
196    use std::fs;
197    use std::path::{Path, PathBuf};
198    use std::sync::atomic::{AtomicU64, Ordering};
199    use std::time::{SystemTime, UNIX_EPOCH};
200
201    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
202
203    fn temp_dir() -> PathBuf {
204        let nanos = SystemTime::now()
205            .duration_since(UNIX_EPOCH)
206            .expect("clock")
207            .as_nanos();
208        let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
209        let dir = std::env::temp_dir().join(format!(
210            "agentzero-file-edit-{}-{nanos}-{seq}",
211            std::process::id()
212        ));
213        fs::create_dir_all(&dir).expect("temp dir should be created");
214        dir
215    }
216
217    fn tool(dir: &Path) -> FileEditTool {
218        FileEditTool::new(dir.to_path_buf(), 256 * 1024)
219    }
220
221    #[tokio::test]
222    async fn file_edit_single_replacement() {
223        let dir = temp_dir();
224        fs::write(
225            dir.join("test.rs"),
226            "fn main() {\n    println!(\"hello\");\n}\n",
227        )
228        .unwrap();
229        let input = r#"{"path":"test.rs","edits":[{"old_text":"hello","new_text":"world"}]}"#;
230        let result = tool(&dir)
231            .execute(input, &ToolContext::new(dir.to_string_lossy().to_string()))
232            .await
233            .expect("edit should succeed");
234        assert!(result.output.contains("edits=1"));
235        let content = fs::read_to_string(dir.join("test.rs")).unwrap();
236        assert!(content.contains("world"));
237        assert!(!content.contains("hello"));
238        fs::remove_dir_all(dir).ok();
239    }
240
241    #[tokio::test]
242    async fn file_edit_multiple_edits() {
243        let dir = temp_dir();
244        fs::write(dir.join("test.txt"), "aaa\nbbb\nccc\n").unwrap();
245        let input = r#"{"path":"test.txt","edits":[{"old_text":"aaa","new_text":"AAA"},{"old_text":"ccc","new_text":"CCC"}]}"#;
246        let result = tool(&dir)
247            .execute(input, &ToolContext::new(dir.to_string_lossy().to_string()))
248            .await
249            .expect("multi-edit should succeed");
250        assert!(result.output.contains("edits=2"));
251        let content = fs::read_to_string(dir.join("test.txt")).unwrap();
252        assert!(content.contains("AAA") && content.contains("CCC"));
253        fs::remove_dir_all(dir).ok();
254    }
255
256    #[tokio::test]
257    async fn file_edit_dry_run_no_write() {
258        let dir = temp_dir();
259        fs::write(dir.join("test.txt"), "original").unwrap();
260        let input = r#"{"path":"test.txt","edits":[{"old_text":"original","new_text":"modified"}],"dry_run":true}"#;
261        let result = tool(&dir)
262            .execute(input, &ToolContext::new(dir.to_string_lossy().to_string()))
263            .await
264            .expect("dry_run should succeed");
265        assert!(result.output.contains("dry_run=true"));
266        assert_eq!(
267            fs::read_to_string(dir.join("test.txt")).unwrap(),
268            "original"
269        );
270        fs::remove_dir_all(dir).ok();
271    }
272
273    #[tokio::test]
274    async fn file_edit_rejects_not_found_negative_path() {
275        let dir = temp_dir();
276        fs::write(dir.join("test.txt"), "content").unwrap();
277        let input = r#"{"path":"test.txt","edits":[{"old_text":"nonexistent","new_text":"x"}]}"#;
278        let err = tool(&dir)
279            .execute(input, &ToolContext::new(dir.to_string_lossy().to_string()))
280            .await
281            .expect_err("old_text not found should fail");
282        assert!(err.to_string().contains("not found in file"));
283        fs::remove_dir_all(dir).ok();
284    }
285
286    #[tokio::test]
287    async fn file_edit_rejects_ambiguous_match_negative_path() {
288        let dir = temp_dir();
289        fs::write(dir.join("test.txt"), "aaa\naaa\n").unwrap();
290        let input = r#"{"path":"test.txt","edits":[{"old_text":"aaa","new_text":"bbb"}]}"#;
291        let err = tool(&dir)
292            .execute(input, &ToolContext::new(dir.to_string_lossy().to_string()))
293            .await
294            .expect_err("ambiguous match should fail");
295        assert!(err.to_string().contains("matches 2 locations"));
296        fs::remove_dir_all(dir).ok();
297    }
298
299    #[tokio::test]
300    async fn file_edit_rejects_path_traversal_negative_path() {
301        let dir = temp_dir();
302        let input = r#"{"path":"../escape.txt","edits":[{"old_text":"a","new_text":"b"}]}"#;
303        let err = tool(&dir)
304            .execute(input, &ToolContext::new(dir.to_string_lossy().to_string()))
305            .await
306            .expect_err("path traversal should be denied");
307        assert!(err.to_string().contains("path traversal"));
308        fs::remove_dir_all(dir).ok();
309    }
310
311    #[tokio::test]
312    async fn file_edit_rejects_empty_edits_negative_path() {
313        let dir = temp_dir();
314        let input = r#"{"path":"test.txt","edits":[]}"#;
315        let err = tool(&dir)
316            .execute(input, &ToolContext::new(dir.to_string_lossy().to_string()))
317            .await
318            .expect_err("empty edits should fail");
319        assert!(err.to_string().contains("edits array must not be empty"));
320        fs::remove_dir_all(dir).ok();
321    }
322}