From 34ec78896867b0288557a34d009e27cd94a85783 Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Fri, 20 Feb 2026 12:15:00 +0800 Subject: [PATCH] feat(tools): add file_edit tool for precise in-place text replacement --- src/tools/file_edit.rs | 626 +++++++++++++++++++++++++++++++++++++++++ src/tools/mod.rs | 7 +- 2 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 src/tools/file_edit.rs diff --git a/src/tools/file_edit.rs b/src/tools/file_edit.rs new file mode 100644 index 000000000..d9b25614a --- /dev/null +++ b/src/tools/file_edit.rs @@ -0,0 +1,626 @@ +use super::traits::{Tool, ToolResult}; +use crate::security::SecurityPolicy; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Edit a file by replacing an exact string match with new content. +/// +/// Uses `old_string` → `new_string` precise replacement within the workspace. +/// The `old_string` must appear exactly once in the file (zero matches = not +/// found, multiple matches = ambiguous). `new_string` may be empty to delete +/// the matched text. Security checks mirror [`super::file_write::FileWriteTool`]. +pub struct FileEditTool { + security: Arc, +} + +impl FileEditTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for FileEditTool { + fn name(&self) -> &str { + "file_edit" + } + + fn description(&self) -> &str { + "Edit a file by replacing an exact string match with new content" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path to the file within the workspace" + }, + "old_string": { + "type": "string", + "description": "The exact text to find and replace (must appear exactly once in the file)" + }, + "new_string": { + "type": "string", + "description": "The replacement text (empty string to delete the matched text)" + } + }, + "required": ["path", "old_string", "new_string"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + // ── 1. Extract parameters ────────────────────────────────── + let path = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; + + let old_string = args + .get("old_string") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'old_string' parameter"))?; + + let new_string = args + .get("new_string") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'new_string' parameter"))?; + + // ── 2. Autonomy check ────────────────────────────────────── + if !self.security.can_act() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Action blocked: autonomy is read-only".into()), + }); + } + + // ── 3. Rate limit check ──────────────────────────────────── + if self.security.is_rate_limited() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Rate limit exceeded: too many actions in the last hour".into()), + }); + } + + // ── 4. Path pre-validation ───────────────────────────────── + if !self.security.is_path_allowed(path) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Path not allowed by security policy: {path}")), + }); + } + + let full_path = self.security.workspace_dir.join(path); + + // ── 5. Canonicalize parent ───────────────────────────────── + let Some(parent) = full_path.parent() else { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Invalid path: missing parent directory".into()), + }); + }; + + let resolved_parent = match tokio::fs::canonicalize(parent).await { + Ok(p) => p, + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to resolve file path: {e}")), + }); + } + }; + + // ── 6. Resolved path post-validation ─────────────────────── + if !self.security.is_resolved_path_allowed(&resolved_parent) { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Resolved path escapes workspace: {}", + resolved_parent.display() + )), + }); + } + + let Some(file_name) = full_path.file_name() else { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Invalid path: missing file name".into()), + }); + }; + + let resolved_target = resolved_parent.join(file_name); + + // ── 7. Symlink check ─────────────────────────────────────── + if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { + if meta.file_type().is_symlink() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Refusing to edit through symlink: {}", + resolved_target.display() + )), + }); + } + } + + // ── 8. Record action ─────────────────────────────────────── + if !self.security.record_action() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Rate limit exceeded: action budget exhausted".into()), + }); + } + + // ── 9. Read → match → replace → write ───────────────────── + let content = match tokio::fs::read_to_string(&resolved_target).await { + Ok(c) => c, + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to read file: {e}")), + }); + } + }; + + let match_count = content.matches(old_string).count(); + + if match_count == 0 { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("old_string not found in file".into()), + }); + } + + if match_count > 1 { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "old_string matches {match_count} times; must match exactly once" + )), + }); + } + + let new_content = content.replacen(old_string, new_string, 1); + + match tokio::fs::write(&resolved_target, &new_content).await { + Ok(()) => Ok(ToolResult { + success: true, + output: format!( + "Edited {path}: replaced 1 occurrence ({} bytes)", + new_content.len() + ), + error: None, + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to write file: {e}")), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + fn test_security_with( + workspace: std::path::PathBuf, + autonomy: AutonomyLevel, + max_actions_per_hour: u32, + ) -> Arc { + Arc::new(SecurityPolicy { + autonomy, + workspace_dir: workspace, + max_actions_per_hour, + ..SecurityPolicy::default() + }) + } + + #[test] + fn file_edit_name() { + let tool = FileEditTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "file_edit"); + } + + #[test] + fn file_edit_schema_has_required_params() { + let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["path"].is_object()); + assert!(schema["properties"]["old_string"].is_object()); + assert!(schema["properties"]["new_string"].is_object()); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("path"))); + assert!(required.contains(&json!("old_string"))); + assert!(required.contains(&json!("new_string"))); + } + + #[tokio::test] + async fn file_edit_replaces_single_match() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_single"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "hello world") + .await + .unwrap(); + + let tool = FileEditTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "test.txt", + "old_string": "hello", + "new_string": "goodbye" + })) + .await + .unwrap(); + + assert!(result.success, "edit should succeed: {:?}", result.error); + assert!(result.output.contains("replaced 1 occurrence")); + + let content = tokio::fs::read_to_string(dir.join("test.txt")) + .await + .unwrap(); + assert_eq!(content, "goodbye world"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_edit_not_found() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_notfound"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "hello world") + .await + .unwrap(); + + let tool = FileEditTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "test.txt", + "old_string": "nonexistent", + "new_string": "replacement" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("not found")); + + // File should be unchanged + let content = tokio::fs::read_to_string(dir.join("test.txt")) + .await + .unwrap(); + assert_eq!(content, "hello world"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_edit_multiple_matches() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_multi"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "aaa bbb aaa") + .await + .unwrap(); + + let tool = FileEditTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "test.txt", + "old_string": "aaa", + "new_string": "ccc" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("matches 2 times")); + + // File should be unchanged + let content = tokio::fs::read_to_string(dir.join("test.txt")) + .await + .unwrap(); + assert_eq!(content, "aaa bbb aaa"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_edit_delete_via_empty_new_string() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_delete"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "keep remove keep") + .await + .unwrap(); + + let tool = FileEditTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "test.txt", + "old_string": " remove", + "new_string": "" + })) + .await + .unwrap(); + + assert!( + result.success, + "delete edit should succeed: {:?}", + result.error + ); + + let content = tokio::fs::read_to_string(dir.join("test.txt")) + .await + .unwrap(); + assert_eq!(content, "keep keep"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_edit_missing_path_param() { + let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let result = tool + .execute(json!({"old_string": "a", "new_string": "b"})) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn file_edit_missing_old_string_param() { + let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let result = tool + .execute(json!({"path": "f.txt", "new_string": "b"})) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn file_edit_missing_new_string_param() { + let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let result = tool + .execute(json!({"path": "f.txt", "old_string": "a"})) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn file_edit_blocks_path_traversal() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_traversal"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileEditTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "../../etc/passwd", + "old_string": "root", + "new_string": "hacked" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("not allowed")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_edit_blocks_absolute_path() { + let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let result = tool + .execute(json!({ + "path": "/etc/passwd", + "old_string": "root", + "new_string": "hacked" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.as_ref().unwrap().contains("not allowed")); + } + + #[cfg(unix)] + #[tokio::test] + async fn file_edit_blocks_symlink_escape() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join("zeroclaw_test_file_edit_symlink_escape"); + let workspace = root.join("workspace"); + let outside = root.join("outside"); + + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::create_dir_all(&outside).await.unwrap(); + + symlink(&outside, workspace.join("escape_dir")).unwrap(); + + let tool = FileEditTool::new(test_security(workspace.clone())); + let result = tool + .execute(json!({ + "path": "escape_dir/target.txt", + "old_string": "a", + "new_string": "b" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("escapes workspace")); + + let _ = tokio::fs::remove_dir_all(&root).await; + } + + #[cfg(unix)] + #[tokio::test] + async fn file_edit_blocks_symlink_target_file() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join("zeroclaw_test_file_edit_symlink_target"); + let workspace = root.join("workspace"); + let outside = root.join("outside"); + + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::create_dir_all(&outside).await.unwrap(); + + tokio::fs::write(outside.join("target.txt"), "original") + .await + .unwrap(); + symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap(); + + let tool = FileEditTool::new(test_security(workspace.clone())); + let result = tool + .execute(json!({ + "path": "linked.txt", + "old_string": "original", + "new_string": "hacked" + })) + .await + .unwrap(); + + assert!(!result.success, "editing through symlink must be blocked"); + assert!( + result.error.as_deref().unwrap_or("").contains("symlink"), + "error should mention symlink" + ); + + let content = tokio::fs::read_to_string(outside.join("target.txt")) + .await + .unwrap(); + assert_eq!(content, "original", "original file must not be modified"); + + let _ = tokio::fs::remove_dir_all(&root).await; + } + + #[tokio::test] + async fn file_edit_blocks_readonly_mode() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_readonly"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "hello") + .await + .unwrap(); + + let tool = FileEditTool::new(test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20)); + let result = tool + .execute(json!({ + "path": "test.txt", + "old_string": "hello", + "new_string": "world" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("read-only")); + + let content = tokio::fs::read_to_string(dir.join("test.txt")) + .await + .unwrap(); + assert_eq!(content, "hello"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_edit_blocks_when_rate_limited() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_rate_limited"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "hello") + .await + .unwrap(); + + let tool = FileEditTool::new(test_security_with( + dir.clone(), + AutonomyLevel::Supervised, + 0, + )); + let result = tool + .execute(json!({ + "path": "test.txt", + "old_string": "hello", + "new_string": "world" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Rate limit exceeded")); + + let content = tokio::fs::read_to_string(dir.join("test.txt")) + .await + .unwrap(); + assert_eq!(content, "hello"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_edit_nonexistent_file() { + let dir = std::env::temp_dir().join("zeroclaw_test_file_edit_nofile"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileEditTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "path": "missing.txt", + "old_string": "a", + "new_string": "b" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Failed to read file")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 698981fe3..9841ee899 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -25,6 +25,7 @@ pub mod cron_run; pub mod cron_runs; pub mod cron_update; pub mod delegate; +pub mod file_edit; pub mod file_read; pub mod file_write; pub mod git_operations; @@ -57,6 +58,7 @@ pub use cron_run::CronRunTool; pub use cron_runs::CronRunsTool; pub use cron_update::CronUpdateTool; pub use delegate::DelegateTool; +pub use file_edit::FileEditTool; pub use file_read::FileReadTool; pub use file_write::FileWriteTool; pub use git_operations::GitOperationsTool; @@ -138,6 +140,7 @@ pub fn default_tools_with_runtime( Box::new(ShellTool::new(security.clone(), runtime)), Box::new(FileReadTool::new(security.clone())), Box::new(FileWriteTool::new(security.clone())), + Box::new(FileEditTool::new(security.clone())), Box::new(GlobSearchTool::new(security)), ] } @@ -193,6 +196,7 @@ pub fn all_tools_with_runtime( Arc::new(ShellTool::new(security.clone(), runtime)), Arc::new(FileReadTool::new(security.clone())), Arc::new(FileWriteTool::new(security.clone())), + Arc::new(FileEditTool::new(security.clone())), Arc::new(GlobSearchTool::new(security.clone())), Arc::new(CronAddTool::new(config.clone(), security.clone())), Arc::new(CronListTool::new(config.clone())), @@ -329,7 +333,7 @@ mod tests { fn default_tools_has_expected_count() { let security = Arc::new(SecurityPolicy::default()); let tools = default_tools(security); - assert_eq!(tools.len(), 4); + assert_eq!(tools.len(), 5); } #[test] @@ -419,6 +423,7 @@ mod tests { assert!(names.contains(&"shell")); assert!(names.contains(&"file_read")); assert!(names.contains(&"file_write")); + assert!(names.contains(&"file_edit")); assert!(names.contains(&"glob_search")); }