feat: add coding tools (edit_file, write_file, bash)
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>
This commit is contained in:
@@ -3,5 +3,7 @@ File operation tools.
|
||||
"""
|
||||
from src.domains.tools.file.read import ReadFileTool
|
||||
from src.domains.tools.file.glob import GlobFilesTool
|
||||
from src.domains.tools.file.edit import EditFileTool
|
||||
from src.domains.tools.file.write import WriteFileTool
|
||||
|
||||
__all__ = ["ReadFileTool", "GlobFilesTool"]
|
||||
__all__ = ["ReadFileTool", "GlobFilesTool", "EditFileTool", "WriteFileTool"]
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
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}")
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
File writing tool for creating and overwriting files.
|
||||
"""
|
||||
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__)
|
||||
|
||||
|
||||
class WriteFileTool(BaseTool):
|
||||
"""
|
||||
Create new files or overwrite existing files.
|
||||
|
||||
Validates paths are within allowed directories and enforces size limits.
|
||||
"""
|
||||
|
||||
name = "write_file"
|
||||
description = """Create a new file or overwrite an existing file.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file to create/write
|
||||
content: The content to write to the file
|
||||
|
||||
Returns:
|
||||
Success message with file path and size, or error.
|
||||
Includes a warning if overwriting an existing file.
|
||||
|
||||
IMPORTANT:
|
||||
- Use absolute paths only
|
||||
- Parent directory must exist (will not create directories)
|
||||
- Will overwrite existing files without confirmation
|
||||
- For targeted edits to existing files, use edit_file instead
|
||||
- Maximum content size: 1MB
|
||||
|
||||
Examples:
|
||||
- Create new module: file_path="/project/src/utils.py", content="def helper(): pass"
|
||||
- Create config: file_path="/project/config.json", content='{"key": "value"}'
|
||||
- Create test: file_path="/project/tests/test_new.py", content="def test_example(): assert True"
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allowed_paths: list[str] | None = None,
|
||||
max_content_size: int = 1_000_000, # 1MB
|
||||
):
|
||||
"""
|
||||
Initialize WriteFileTool.
|
||||
|
||||
Args:
|
||||
allowed_paths: List of allowed directory prefixes (empty = no restrictions)
|
||||
max_content_size: Maximum content size in bytes
|
||||
"""
|
||||
self.allowed_paths = allowed_paths or []
|
||||
self.max_content_size = max_content_size
|
||||
|
||||
@logged()
|
||||
async def execute(
|
||||
self,
|
||||
file_path: str,
|
||||
content: str
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Write content to a file.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
ToolResult with success info or error
|
||||
"""
|
||||
path = Path(file_path).resolve()
|
||||
|
||||
# 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 content size
|
||||
content_bytes = len(content.encode('utf-8'))
|
||||
if content_bytes > self.max_content_size:
|
||||
return self._error(
|
||||
f"Content too large ({content_bytes} bytes). "
|
||||
f"Max: {self.max_content_size} bytes"
|
||||
)
|
||||
|
||||
# Check parent directory exists
|
||||
if not path.parent.exists():
|
||||
return self._error(
|
||||
f"Parent directory does not exist: {path.parent}. "
|
||||
f"Create it first with mkdir."
|
||||
)
|
||||
|
||||
if not path.parent.is_dir():
|
||||
return self._error(f"Parent path is not a directory: {path.parent}")
|
||||
|
||||
# Check if we're overwriting
|
||||
overwritten = path.exists() and path.is_file()
|
||||
|
||||
try:
|
||||
# Write the file
|
||||
async with aiofiles.open(path, 'w', encoding='utf-8') as f:
|
||||
await f.write(content)
|
||||
|
||||
# Count lines for metadata
|
||||
lines = content.count('\n') + (1 if content and not content.endswith('\n') else 0)
|
||||
|
||||
status = "Overwrote" if overwritten else "Created"
|
||||
return self._success(
|
||||
data=f"{status} {path} ({content_bytes} bytes, {lines} lines)",
|
||||
file_path=str(path),
|
||||
file_size=content_bytes,
|
||||
lines=lines,
|
||||
overwritten=overwritten
|
||||
)
|
||||
|
||||
except PermissionError:
|
||||
return self._error(f"Permission denied: {file_path}")
|
||||
except OSError as e:
|
||||
return self._error(f"OS error writing file: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error writing file: {file_path}")
|
||||
return self._error(f"Error writing file: {e}")
|
||||
Reference in New Issue
Block a user