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:
2026-01-10 11:16:05 +01:00
co-authored by Claude Opus 4.5
parent 3b58fa4f8b
commit d0fa5b38a7
8 changed files with 1350 additions and 4 deletions
@@ -8,8 +8,11 @@ from pydantic_ai import Agent, RunContext
from src.domains.agents.base import AgentContext
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
from src.domains.tools.search.grep import GrepContentTool
from src.domains.tools.shell.bash import BashReadOnlyTool
from src.domains.tools.shell.bash_full import BashTool
def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
@@ -161,3 +164,110 @@ def register_explore_tools(agent: Agent[AgentContext, str]) -> None:
timeout=min(timeout, ctx.deps.timeout_seconds)
)
return result.to_string()
# === Write-capable tools ===
@agent.tool
async def edit_file(
ctx: RunContext[AgentContext],
file_path: str,
old_string: str,
new_string: str,
replace_all: bool = False
) -> str:
"""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 (appear exactly once).
Returns:
Success message with diff preview, or error.
IMPORTANT:
- old_string must exactly match file content (including whitespace)
- By default, old_string must appear exactly once (for safety)
- Always read the file first to verify exact content before editing
"""
tool = EditFileTool(allowed_paths=ctx.deps.allowed_paths)
result = await tool.execute(
file_path=file_path,
old_string=old_string,
new_string=new_string,
replace_all=replace_all
)
return result.to_string()
@agent.tool
async def write_file(
ctx: RunContext[AgentContext],
file_path: str,
content: str
) -> str:
"""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.
IMPORTANT:
- Parent directory must exist (use mkdir first if needed)
- For editing existing files, prefer edit_file instead
- Will overwrite existing files without confirmation
"""
tool = WriteFileTool(allowed_paths=ctx.deps.allowed_paths)
result = await tool.execute(
file_path=file_path,
content=content
)
return result.to_string()
@agent.tool
async def bash(
ctx: RunContext[AgentContext],
command: str,
cwd: str | None = None,
timeout: int = 60
) -> str:
"""Execute a bash command with write capabilities.
ALLOWED:
- File operations: ls, find, mkdir, touch, cp, mv, rm (single files)
- Git (full): git add, git commit, git checkout, git merge, git pull
- Python: python, pip install, pytest, mypy, ruff
- Text processing: grep, awk, sed, sort
- Command chaining: && and || are allowed
FORBIDDEN:
- sudo, su (privilege escalation)
- Network: curl, wget, ssh, scp, rsync
- Dangerous: rm -rf, chmod 777, dd, mkfs
Args:
command: The bash command to execute
cwd: Working directory (default: agent working directory)
timeout: Timeout in seconds (default: 60)
Returns:
Command output or error message.
Examples:
- "mkdir -p src/utils" creates directory
- "git add . && git commit -m 'fix: bug'" commits changes
- "pytest tests/ -v" runs tests
- "rm old_file.py" removes single file
"""
tool = BashTool(allowed_paths=ctx.deps.allowed_paths)
working_dir = cwd or ctx.deps.working_dir
result = await tool.execute(
command=command,
cwd=working_dir,
timeout=min(timeout, ctx.deps.timeout_seconds)
)
return result.to_string()