New tools for code modification: - EditFileTool: find-and-replace with safety checks (unique match required) - WriteFileTool: create/overwrite files with path validation - BashTool: full bash with controlled write access Security controls on BashTool: - Allowed: mkdir, touch, cp, mv, rm (single files), git, pip, pytest - Forbidden: sudo, curl, wget, ssh, rm -rf, chmod 777 Includes 39 new tests (78 total now passing). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
195 lines
6.5 KiB
Python
195 lines
6.5 KiB
Python
"""
|
|
File editing tool with find-and-replace functionality.
|
|
"""
|
|
import difflib
|
|
import aiofiles
|
|
from pathlib import Path
|
|
|
|
from src.domains.tools.base import BaseTool, ToolResult
|
|
from src.shared.logging import logged, get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Binary file extensions to skip
|
|
BINARY_EXTENSIONS = {
|
|
'.pyc', '.pyo', '.so', '.o', '.a', '.lib', '.dll', '.exe',
|
|
'.bin', '.dat', '.db', '.sqlite', '.sqlite3',
|
|
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.bmp', '.webp',
|
|
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
|
|
'.mp3', '.mp4', '.avi', '.mov', '.wav',
|
|
'.woff', '.woff2', '.ttf', '.eot',
|
|
}
|
|
|
|
|
|
class EditFileTool(BaseTool):
|
|
"""
|
|
Edit files using find-and-replace.
|
|
|
|
Safely modifies files by finding exact text matches and replacing them.
|
|
Includes safety checks to prevent accidental edits.
|
|
"""
|
|
|
|
name = "edit_file"
|
|
description = """Make targeted edits to a file using find-and-replace.
|
|
|
|
Args:
|
|
file_path: Absolute path to the file to edit
|
|
old_string: The exact text to find and replace (must exist in file)
|
|
new_string: The replacement text
|
|
replace_all: If True, replace all occurrences. If False (default),
|
|
old_string must be unique in the file (appear exactly once).
|
|
|
|
Returns:
|
|
Success message with diff preview showing changes, or error.
|
|
|
|
IMPORTANT:
|
|
- The old_string must exactly match text in the file (including whitespace/indentation)
|
|
- By default, old_string must appear exactly once in the file (for safety)
|
|
- Use replace_all=True only when you intentionally want to replace all occurrences
|
|
- Always read the file first to verify exact content before editing
|
|
- Cannot edit binary files
|
|
|
|
Examples:
|
|
- Fix a bug: old_string="return x + y", new_string="return x * y"
|
|
- Rename function: old_string="def old_name(", new_string="def new_name("
|
|
- Add import: old_string="import os", new_string="import os\\nimport sys"
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
allowed_paths: list[str] | None = None,
|
|
max_file_size: int = 1_000_000, # 1MB
|
|
):
|
|
"""
|
|
Initialize EditFileTool.
|
|
|
|
Args:
|
|
allowed_paths: List of allowed directory prefixes (empty = no restrictions)
|
|
max_file_size: Maximum file size to edit in bytes
|
|
"""
|
|
self.allowed_paths = allowed_paths or []
|
|
self.max_file_size = max_file_size
|
|
|
|
def _is_binary_file(self, path: Path) -> bool:
|
|
"""Check if file is likely binary based on extension."""
|
|
return path.suffix.lower() in BINARY_EXTENSIONS
|
|
|
|
def _generate_diff(
|
|
self,
|
|
original: str,
|
|
modified: str,
|
|
file_path: str
|
|
) -> str:
|
|
"""Generate a unified diff between original and modified content."""
|
|
original_lines = original.splitlines(keepends=True)
|
|
modified_lines = modified.splitlines(keepends=True)
|
|
|
|
diff = difflib.unified_diff(
|
|
original_lines,
|
|
modified_lines,
|
|
fromfile=f"a/{Path(file_path).name}",
|
|
tofile=f"b/{Path(file_path).name}",
|
|
lineterm=""
|
|
)
|
|
return "".join(diff)
|
|
|
|
@logged()
|
|
async def execute(
|
|
self,
|
|
file_path: str,
|
|
old_string: str,
|
|
new_string: str,
|
|
replace_all: bool = False
|
|
) -> ToolResult:
|
|
"""
|
|
Edit a file by replacing old_string with new_string.
|
|
|
|
Args:
|
|
file_path: Absolute path to the file
|
|
old_string: Text to find (must exist)
|
|
new_string: Replacement text
|
|
replace_all: Replace all occurrences (default: False)
|
|
|
|
Returns:
|
|
ToolResult with diff preview or error
|
|
"""
|
|
path = Path(file_path)
|
|
|
|
# Validate path is allowed
|
|
if not self._validate_path(path, self.allowed_paths):
|
|
return self._error(f"Path not in allowed paths: {file_path}")
|
|
|
|
# Check file exists
|
|
if not path.exists():
|
|
return self._error(f"File not found: {file_path}")
|
|
|
|
if not path.is_file():
|
|
return self._error(f"Not a file: {file_path}")
|
|
|
|
# Check for binary files
|
|
if self._is_binary_file(path):
|
|
return self._error(f"Cannot edit binary file: {file_path}")
|
|
|
|
# Check file size
|
|
file_size = path.stat().st_size
|
|
if file_size > self.max_file_size:
|
|
return self._error(
|
|
f"File too large ({file_size} bytes). Max: {self.max_file_size} bytes"
|
|
)
|
|
|
|
# Validate inputs
|
|
if not old_string:
|
|
return self._error("old_string cannot be empty")
|
|
|
|
if old_string == new_string:
|
|
return self._error("old_string and new_string are identical")
|
|
|
|
try:
|
|
# Read file content
|
|
async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f:
|
|
content = await f.read()
|
|
|
|
# Check if old_string exists
|
|
count = content.count(old_string)
|
|
if count == 0:
|
|
return self._error(
|
|
f"old_string not found in file. "
|
|
f"Make sure to match exact whitespace and indentation."
|
|
)
|
|
|
|
# Check uniqueness if replace_all is False
|
|
if not replace_all and count > 1:
|
|
return self._error(
|
|
f"old_string appears {count} times in file. "
|
|
f"Use replace_all=True to replace all, or provide a more specific string."
|
|
)
|
|
|
|
# Perform replacement
|
|
if replace_all:
|
|
modified = content.replace(old_string, new_string)
|
|
else:
|
|
modified = content.replace(old_string, new_string, 1)
|
|
|
|
# Generate diff for preview
|
|
diff = self._generate_diff(content, modified, file_path)
|
|
|
|
# Write modified content
|
|
async with aiofiles.open(path, 'w', encoding='utf-8') as f:
|
|
await f.write(modified)
|
|
|
|
replacements = count if replace_all else 1
|
|
return self._success(
|
|
data=f"Successfully edited {file_path}\n\n{diff}",
|
|
replacements=replacements,
|
|
file_path=str(path.resolve())
|
|
)
|
|
|
|
except PermissionError:
|
|
return self._error(f"Permission denied: {file_path}")
|
|
except UnicodeDecodeError as e:
|
|
return self._error(f"Unable to decode file (binary?): {e}")
|
|
except Exception as e:
|
|
logger.exception(f"Error editing file: {file_path}")
|
|
return self._error(f"Error editing file: {e}")
|