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 crate::autonomy::AutonomyPolicy::check_hard_links(&dest.to_string_lossy())?;
117
118 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}