mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +00:00
* feat(channels/signal): add attachment upload support
- Add attachments field to OutgoingResponse for carrying file paths
- Add with_attachments() builder method to OutgoingResponse
- Update build_rpc_params() to include attachments array in JSON-RPC
- Update respond() and broadcast() to handle attachments:
- Text + attachments: sends text first, then each attachment
- Attachments only: sends each attachment with path as message
- Text only: original behavior (no change)
- Add tests for build_rpc_params with attachments
- Add tests for OutgoingResponse attachment builder
This enables the Signal channel to send files via signal-cli daemon's
JSON-RPC send method, matching the nullclaw implementation.
Risk: Low - uses existing JSON-RPC infrastructure
Tests: 85 signal tests pass, 1543 lib tests pass
* feat(tools): add message tool for cross-channel messaging
Add a new 'message' tool that allows the agent to send messages to
any connected channel (signal, telegram, slack, etc.) with optional
file attachments.
Features:
- Send messages to specific channel + target combinations
- Support for attachments (file paths)
- E.164 validation delegated to channel (signal expects +number,
telegram accepts username/chat_id, slack uses #channels)
- Helpful error messages showing available channels on failure
Tool schema:
- content: message text (required)
- channel: target channel name (optional, defaults to current channel)
- target: recipient (E.164, group ID, chat ID) (optional, defaults to
current user/group chat)
- attachments: optional file paths to send
This complements the recently added attachment upload support for the
Signal channel by giving the agent a proper way to specify attachments
when sending messages.
Tests: 4 new tests for message tool schema
Risk: Low - new tool with no breaking changes
Tests: All 1547 lib tests pass, clippy clean
* feat(llm): add conversation context to system prompt for Signal
Add conversation_context HashMap to Reasoning struct to pass channel-specific
metadata (sender phone, sender UUID, group ID) to the LLM. This helps the
agent know who/group it's talking to, preventing it from hallucinating
phone numbers or sending to wrong recipients.
Changes:
- Add conversation_context field and with_conversation_data() builder method
- Add build_conversation_section() to include current conversation info in system prompt
- Update dispatcher to extract Signal metadata (sender, sender_uuid, group) and pass to Reasoning
- Add signal_sender_uuid to Signal channel metadata for privacy mode users
* feat(tools): add secure attachment path validation with sandbox enforcement
Implement robust path validation for message tool attachments to prevent
directory traversal attacks and unauthorized file access. Attachments are
now sandboxed to ~/.ironclaw/ by default.
Key changes:
- Create shared path_utils module with validate_path() and is_path_safe_basic()
- Extract normalize_lexical() from file.rs for reuse
- MessageTool now enforces sandbox at ~/.ironclaw/ for all attachments
- Path validation includes: traversal detection, canonicalization, symlink resolution
- Error messages reveal the allowed sandbox directory for user clarity
Security improvements:
- Blocks path traversal attacks (../, URL-encoded, null bytes)
- Canonicalizes paths to resolve symlinks before validation
- Walks up to nearest existing ancestor for non-existent paths
- Prevents escape from sandbox directory
Backward compatibility:
- File tools continue to work with their configured base_dir
- Message tool defaults to ~/.ironclaw/ sandbox
- Tests updated to create files within sandbox
Tests added:
- path_utils module tests (9 tests for validation logic)
- message tool attachment validation tests
- All 1571 existing tests pass
* fix(channels/signal): use robust path validation with full security coverage
Signal channel's validate_attachment_paths() now uses path_utils::validate_path()
for consistent, secure path validation.
Fixes:
- Replaced weak path.contains('..') check with robust validate_path()
- validate_path() now includes is_path_safe_basic() as first-pass filter to
block null bytes and URL-encoded traversal sequences (%2e%2e%2f)
- Error message now shows allowed sandbox directory (~/.ironclaw/)
Security coverage:
- Path traversal: ../, foo/../bar, ../../etc/passwd ✓
- URL-encoded traversal: %2e%2e%2fetc/passwd ✓
- Null byte injection: file\0.txt ✓
- Paths outside sandbox: /tmp/evil.txt ✓
- Symlink escape attempts (via canonicalization) ✓
Tests added:
- validate_attachment_paths_rejects_path_outside_sandbox
- validate_attachment_paths_rejects_url_encoded_traversal
- validate_attachment_paths_rejects_null_byte
- Fixed broken assertion in rejects_double_dot test
* fix(llm): add Signal channel to build_channel_section to include message tool hint
The catch-all '_' arm was returning early before the message_tool_hint
section was constructed, which meant Signal users never got the
'## Proactive Messaging' section with examples for:
- Using attachments parameter
- Targeting different users/groups
- Cross-channel messaging
Now Signal will include the full message_tool_hint section with usage examples.
* fix(tools): use async locks in register_message_tools to prevent silent failures
The method was using register_sync which calls try_write() on self.tools.
If the lock was held, try_write() would return Err and silently skip
adding the tool to the registry, while self.message_tool already held
a reference. This creates an inconsistent state.
Fix: use async write locks directly instead of register_sync to ensure
the tool is always registered or the method fails explicitly.
* refactor(dispatcher): use Channel trait for conversation context
Replace hardcoded 'if message.channel == signal' block with generic
conversation_context() method on the Channel trait. This allows any
channel to provide context (sender, group, etc.) without hardcoding
channel names.
Changes:
- Add conversation_context() method to Channel trait (default: empty)
- Implement for SignalChannel: extracts sender, sender_uuid, group
- Add get_channel() to ChannelManager (returns Arc<dyn Channel>)
- Change ChannelManager storage from Box to Arc for shared access
- Update dispatcher to use new trait method
- Add tests for conversation_context extraction
Other channels (Telegram, Slack, Discord) can now implement this
method to provide conversation context without code changes in dispatcher.
* fix(tests): split message_tool_with_attachments into sandbox and channel tests
The original test was passing for the wrong reason - it expected an error
because the channel doesn't exist, but actually failed earlier during sandbox
validation because /tmp paths are outside ~/.ironclaw/.
Split into two tests:
- message_tool_with_attachments_outside_sandbox: verifies sandbox rejection
with explicit error message check
- message_tool_with_attachments_inside_sandbox_no_channel: uses files within
sandbox (like message_tool_passes_attachment_to_broadcast does) and verifies
the channel-related error message
* security(message tool): add rate limiting, approval requirements, and audit logging
The message tool can send to ANY connected channel/target making it a significant
abuse vector if the LLM is compromised or prompt-injected. This commit adds:
1. Rate limiting: 10 messages/minute, 100/hour per user
2. Approval requirement: Always requires approval for cross-channel messages
(when channel differs from the default conversation channel)
3. Audit logging: Every successful message send is logged with channel,
target, and attachment count
The approval logic:
- If channel param is provided and differs from default -> Always require approval
- If no default channel is set and explicit channel provided -> Always require approval
- Otherwise (using default channel) -> UnlessAutoApproved
* fix(message tool): return explicit error for malformed attachments array
Previously, malformed attachments like {"attachments": [123, true]} would be
silently ignored via .ok().unwrap_or_default(), leaving users confused
when attachments weren't sent.
Now returns explicit error: "Invalid attachments format: ..."
* fix(message tool): verify attachment files exist before sending
Previously, non-existent paths would pass sandbox validation and surface
as confusing Signal RPC errors. Now returns clear "Attachment file not found" error.
* fix(test): create sandbox directory if it doesn't exist for CI
The test validate_attachment_paths_accepts_normal_paths uses
tempfile::tempdir_in() which requires the parent directory to exist.
In CI, ~/.ironclaw doesn't exist, causing test failure.
873 lines
26 KiB
Rust
873 lines
26 KiB
Rust
//! File operation tools for reading, writing, and navigating the filesystem.
|
|
//!
|
|
//! These tools provide controlled access to the filesystem with:
|
|
//! - Path validation and sandboxing
|
|
//! - Size limits on read/write operations
|
|
//! - Support for common development tasks
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use async_trait::async_trait;
|
|
use tokio::fs;
|
|
|
|
use crate::context::JobContext;
|
|
use crate::tools::builtin::path_utils::validate_path;
|
|
use crate::tools::tool::{
|
|
ApprovalRequirement, Tool, ToolDomain, ToolError, ToolOutput, require_str,
|
|
};
|
|
use crate::workspace::paths as ws_paths;
|
|
|
|
/// Well-known workspace filenames that must go through memory_write, not write_file.
|
|
///
|
|
/// If the LLM tries to write one of these via the filesystem tool we reject
|
|
/// immediately and point it at the correct tool.
|
|
const WORKSPACE_FILES: &[&str] = &[
|
|
ws_paths::HEARTBEAT,
|
|
ws_paths::MEMORY,
|
|
ws_paths::IDENTITY,
|
|
ws_paths::SOUL,
|
|
ws_paths::AGENTS,
|
|
ws_paths::USER,
|
|
ws_paths::README,
|
|
];
|
|
|
|
/// Check whether `path` resolves to a workspace file that should be written
|
|
/// through `memory_write` instead of `write_file`.
|
|
fn is_workspace_path(path: &str) -> bool {
|
|
let filename = std::path::Path::new(path)
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.unwrap_or(path);
|
|
|
|
WORKSPACE_FILES.contains(&filename)
|
|
|| path.starts_with("daily/")
|
|
|| path.starts_with("context/")
|
|
}
|
|
|
|
/// Maximum file size for reading (1MB).
|
|
const MAX_READ_SIZE: u64 = 1024 * 1024;
|
|
|
|
/// Maximum file size for writing (5MB).
|
|
const MAX_WRITE_SIZE: usize = 5 * 1024 * 1024;
|
|
|
|
/// Maximum directory listing entries.
|
|
const MAX_DIR_ENTRIES: usize = 500;
|
|
|
|
/// Read file contents tool.
|
|
#[derive(Debug, Default)]
|
|
pub struct ReadFileTool {
|
|
base_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl ReadFileTool {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_base_dir(mut self, dir: PathBuf) -> Self {
|
|
self.base_dir = Some(dir);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ReadFileTool {
|
|
fn name(&self) -> &str {
|
|
"read_file"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Read a file from the LOCAL FILESYSTEM. NOT for workspace memory paths \
|
|
(use memory_read for those). Returns file content as text. \
|
|
For large files, you can specify offset and limit to read a portion."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Path to the file to read"
|
|
},
|
|
"offset": {
|
|
"type": "integer",
|
|
"description": "Line number to start reading from (1-indexed, optional)"
|
|
},
|
|
"limit": {
|
|
"type": "integer",
|
|
"description": "Maximum number of lines to read (optional)"
|
|
}
|
|
},
|
|
"required": ["path"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let path_str = require_str(¶ms, "path")?;
|
|
|
|
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
|
let limit = params.get("limit").and_then(|v| v.as_u64());
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
let path = validate_path(path_str, self.base_dir.as_deref())?;
|
|
|
|
// Check file size
|
|
let metadata = fs::metadata(&path)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Cannot access file: {}", e)))?;
|
|
|
|
if metadata.len() > MAX_READ_SIZE {
|
|
return Err(ToolError::ExecutionFailed(format!(
|
|
"File too large ({} bytes). Maximum is {} bytes. Use offset/limit for partial reads.",
|
|
metadata.len(),
|
|
MAX_READ_SIZE
|
|
)));
|
|
}
|
|
|
|
// Read file
|
|
let content = fs::read_to_string(&path)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read file: {}", e)))?;
|
|
|
|
// Apply offset and limit
|
|
let lines: Vec<&str> = content.lines().collect();
|
|
let total_lines = lines.len();
|
|
|
|
let start_line = if offset > 0 {
|
|
offset.saturating_sub(1)
|
|
} else {
|
|
0
|
|
};
|
|
let end_line = if let Some(lim) = limit {
|
|
(start_line + lim as usize).min(total_lines)
|
|
} else {
|
|
total_lines
|
|
};
|
|
|
|
let selected_lines: Vec<String> = lines[start_line..end_line]
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, line)| format!("{:>6}│ {}", start_line + i + 1, line))
|
|
.collect();
|
|
|
|
let result = serde_json::json!({
|
|
"content": selected_lines.join("\n"),
|
|
"total_lines": total_lines,
|
|
"lines_shown": end_line - start_line,
|
|
"path": path.display().to_string()
|
|
});
|
|
|
|
Ok(ToolOutput::success(result, start.elapsed()))
|
|
}
|
|
|
|
fn requires_sanitization(&self) -> bool {
|
|
true // File content could contain anything
|
|
}
|
|
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
|
|
fn domain(&self) -> ToolDomain {
|
|
ToolDomain::Container
|
|
}
|
|
}
|
|
|
|
/// Write file contents tool.
|
|
#[derive(Debug, Default)]
|
|
pub struct WriteFileTool {
|
|
base_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl WriteFileTool {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_base_dir(mut self, dir: PathBuf) -> Self {
|
|
self.base_dir = Some(dir);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for WriteFileTool {
|
|
fn name(&self) -> &str {
|
|
"write_file"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Write content to a file on the LOCAL FILESYSTEM. NOT for workspace memory \
|
|
(use memory_write for that). Creates the file if it doesn't exist, overwrites if it does. \
|
|
Parent directories are created automatically. Use apply_patch for targeted edits."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Path to the file to write"
|
|
},
|
|
"content": {
|
|
"type": "string",
|
|
"description": "Content to write to the file"
|
|
}
|
|
},
|
|
"required": ["path", "content"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let path_str = require_str(¶ms, "path")?;
|
|
|
|
// Reject workspace paths: these live in the database, not on disk.
|
|
if is_workspace_path(path_str) {
|
|
return Err(ToolError::InvalidParameters(format!(
|
|
"'{}' is a workspace memory file. Use the memory_write tool instead of write_file. \
|
|
For HEARTBEAT.md use target='heartbeat', for MEMORY.md use target='memory'.",
|
|
path_str
|
|
)));
|
|
}
|
|
|
|
let content = require_str(¶ms, "content")?;
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
// Check content size
|
|
if content.len() > MAX_WRITE_SIZE {
|
|
return Err(ToolError::InvalidParameters(format!(
|
|
"Content too large ({} bytes). Maximum is {} bytes.",
|
|
content.len(),
|
|
MAX_WRITE_SIZE
|
|
)));
|
|
}
|
|
|
|
let path = validate_path(path_str, self.base_dir.as_deref())?;
|
|
|
|
// Create parent directories
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).await.map_err(|e| {
|
|
ToolError::ExecutionFailed(format!("Failed to create directories: {}", e))
|
|
})?;
|
|
}
|
|
|
|
// Write file
|
|
fs::write(&path, content)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to write file: {}", e)))?;
|
|
|
|
let result = serde_json::json!({
|
|
"path": path.display().to_string(),
|
|
"bytes_written": content.len(),
|
|
"success": true
|
|
});
|
|
|
|
Ok(ToolOutput::success(result, start.elapsed()))
|
|
}
|
|
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
|
|
fn requires_sanitization(&self) -> bool {
|
|
false // We're writing, not reading external data
|
|
}
|
|
|
|
fn domain(&self) -> ToolDomain {
|
|
ToolDomain::Container
|
|
}
|
|
|
|
fn rate_limit_config(&self) -> Option<crate::tools::tool::ToolRateLimitConfig> {
|
|
Some(crate::tools::tool::ToolRateLimitConfig::new(20, 200))
|
|
}
|
|
}
|
|
|
|
/// List directory contents tool.
|
|
#[derive(Debug, Default)]
|
|
pub struct ListDirTool {
|
|
base_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl ListDirTool {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_base_dir(mut self, dir: PathBuf) -> Self {
|
|
self.base_dir = Some(dir);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ListDirTool {
|
|
fn name(&self) -> &str {
|
|
"list_dir"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"List contents of a directory on the LOCAL FILESYSTEM. NOT for workspace memory \
|
|
(use memory_tree for that). Shows files and subdirectories with their sizes."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Path to the directory to list (defaults to current directory)"
|
|
},
|
|
"recursive": {
|
|
"type": "boolean",
|
|
"description": "If true, list contents recursively (default false)"
|
|
},
|
|
"max_depth": {
|
|
"type": "integer",
|
|
"description": "Maximum depth for recursive listing (default 3)"
|
|
}
|
|
},
|
|
"required": []
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let path_str = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
|
|
|
|
let recursive = params
|
|
.get("recursive")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
let max_depth = params
|
|
.get("max_depth")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(3) as usize;
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
let path = validate_path(path_str, self.base_dir.as_deref())?;
|
|
|
|
let mut entries = Vec::new();
|
|
list_dir_inner(&path, &path, recursive, max_depth, 0, &mut entries).await?;
|
|
|
|
// Sort entries
|
|
entries.sort_by(|a, b| {
|
|
let a_is_dir = a.ends_with('/');
|
|
let b_is_dir = b.ends_with('/');
|
|
match (a_is_dir, b_is_dir) {
|
|
(true, false) => std::cmp::Ordering::Less,
|
|
(false, true) => std::cmp::Ordering::Greater,
|
|
_ => a.cmp(b),
|
|
}
|
|
});
|
|
|
|
let truncated = entries.len() > MAX_DIR_ENTRIES;
|
|
if truncated {
|
|
entries.truncate(MAX_DIR_ENTRIES);
|
|
}
|
|
|
|
let result = serde_json::json!({
|
|
"path": path.display().to_string(),
|
|
"entries": entries,
|
|
"count": entries.len(),
|
|
"truncated": truncated
|
|
});
|
|
|
|
Ok(ToolOutput::success(result, start.elapsed()))
|
|
}
|
|
|
|
fn requires_sanitization(&self) -> bool {
|
|
false // Directory listings are safe
|
|
}
|
|
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
|
|
fn domain(&self) -> ToolDomain {
|
|
ToolDomain::Container
|
|
}
|
|
}
|
|
|
|
/// Recursively list directory contents.
|
|
async fn list_dir_inner(
|
|
base: &Path,
|
|
path: &Path,
|
|
recursive: bool,
|
|
max_depth: usize,
|
|
current_depth: usize,
|
|
entries: &mut Vec<String>,
|
|
) -> Result<(), ToolError> {
|
|
if entries.len() >= MAX_DIR_ENTRIES {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut dir = fs::read_dir(path)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read directory: {}", e)))?;
|
|
|
|
while let Some(entry) = dir
|
|
.next_entry()
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read entry: {}", e)))?
|
|
{
|
|
if entries.len() >= MAX_DIR_ENTRIES {
|
|
break;
|
|
}
|
|
|
|
let entry_path = entry.path();
|
|
let relative = entry_path
|
|
.strip_prefix(base)
|
|
.unwrap_or(&entry_path)
|
|
.to_string_lossy();
|
|
|
|
let metadata = entry.metadata().await.ok();
|
|
let is_dir = metadata.as_ref().is_some_and(|m| m.is_dir());
|
|
|
|
let display = if is_dir {
|
|
format!("{}/", relative)
|
|
} else {
|
|
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
|
format!("{} ({})", relative, format_size(size))
|
|
};
|
|
|
|
entries.push(display);
|
|
|
|
if recursive && is_dir && current_depth < max_depth {
|
|
// Skip common non-essential directories
|
|
let name = entry.file_name();
|
|
let name_str = name.to_string_lossy();
|
|
if !matches!(
|
|
name_str.as_ref(),
|
|
"node_modules" | "target" | ".git" | "__pycache__" | "venv" | ".venv"
|
|
) {
|
|
Box::pin(list_dir_inner(
|
|
base,
|
|
&entry_path,
|
|
recursive,
|
|
max_depth,
|
|
current_depth + 1,
|
|
entries,
|
|
))
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Format file size in human-readable form.
|
|
fn format_size(bytes: u64) -> String {
|
|
const KB: u64 = 1024;
|
|
const MB: u64 = KB * 1024;
|
|
const GB: u64 = MB * 1024;
|
|
|
|
if bytes >= GB {
|
|
format!("{:.1}GB", bytes as f64 / GB as f64)
|
|
} else if bytes >= MB {
|
|
format!("{:.1}MB", bytes as f64 / MB as f64)
|
|
} else if bytes >= KB {
|
|
format!("{:.1}KB", bytes as f64 / KB as f64)
|
|
} else {
|
|
format!("{}B", bytes)
|
|
}
|
|
}
|
|
|
|
/// Apply patch tool for targeted file edits.
|
|
#[derive(Debug, Default)]
|
|
pub struct ApplyPatchTool {
|
|
base_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl ApplyPatchTool {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_base_dir(mut self, dir: PathBuf) -> Self {
|
|
self.base_dir = Some(dir);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for ApplyPatchTool {
|
|
fn name(&self) -> &str {
|
|
"apply_patch"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Apply targeted edits to a file using search/replace. Finds the exact 'old_string' \
|
|
and replaces it with 'new_string'. Use for surgical code changes without rewriting entire files. \
|
|
The old_string must match exactly (including whitespace and indentation)."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Path to the file to edit"
|
|
},
|
|
"old_string": {
|
|
"type": "string",
|
|
"description": "The exact string to find and replace"
|
|
},
|
|
"new_string": {
|
|
"type": "string",
|
|
"description": "The string to replace it with"
|
|
},
|
|
"replace_all": {
|
|
"type": "boolean",
|
|
"description": "If true, replace all occurrences (default false, replaces first only)"
|
|
}
|
|
},
|
|
"required": ["path", "old_string", "new_string"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let path_str = require_str(¶ms, "path")?;
|
|
|
|
let old_string = require_str(¶ms, "old_string")?;
|
|
|
|
let new_string = require_str(¶ms, "new_string")?;
|
|
|
|
let replace_all = params
|
|
.get("replace_all")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false);
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
let path = validate_path(path_str, self.base_dir.as_deref())?;
|
|
|
|
// Read current content
|
|
let content = fs::read_to_string(&path)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read file: {}", e)))?;
|
|
|
|
// Check if old_string exists
|
|
if !content.contains(old_string) {
|
|
return Err(ToolError::ExecutionFailed(format!(
|
|
"Could not find the specified text in {}. Make sure old_string matches exactly.",
|
|
path.display()
|
|
)));
|
|
}
|
|
|
|
// Apply replacement
|
|
let new_content = if replace_all {
|
|
content.replace(old_string, new_string)
|
|
} else {
|
|
content.replacen(old_string, new_string, 1)
|
|
};
|
|
|
|
// Count replacements
|
|
let replacements = if replace_all {
|
|
content.matches(old_string).count()
|
|
} else {
|
|
1
|
|
};
|
|
|
|
// Write back
|
|
fs::write(&path, &new_content)
|
|
.await
|
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to write file: {}", e)))?;
|
|
|
|
let result = serde_json::json!({
|
|
"path": path.display().to_string(),
|
|
"replacements": replacements,
|
|
"success": true
|
|
});
|
|
|
|
Ok(ToolOutput::success(result, start.elapsed()))
|
|
}
|
|
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
}
|
|
|
|
fn requires_sanitization(&self) -> bool {
|
|
false // We're writing, not reading external data
|
|
}
|
|
|
|
fn domain(&self) -> ToolDomain {
|
|
ToolDomain::Container
|
|
}
|
|
|
|
fn rate_limit_config(&self) -> Option<crate::tools::tool::ToolRateLimitConfig> {
|
|
Some(crate::tools::tool::ToolRateLimitConfig::new(20, 200))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::tools::builtin::path_utils::normalize_lexical;
|
|
use tempfile::TempDir;
|
|
|
|
#[tokio::test]
|
|
async fn test_read_file() {
|
|
let dir = TempDir::new().unwrap();
|
|
let file_path = dir.path().join("test.txt");
|
|
std::fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
|
|
|
|
let tool = ReadFileTool::new().with_base_dir(dir.path().to_path_buf());
|
|
let ctx = JobContext::default();
|
|
|
|
let result = tool
|
|
.execute(
|
|
serde_json::json!({"path": file_path.to_str().unwrap()}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let content = result.result.get("content").unwrap().as_str().unwrap();
|
|
assert!(content.contains("line 1"));
|
|
assert!(content.contains("line 2"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_file() {
|
|
let dir = TempDir::new().unwrap();
|
|
let file_path = dir.path().join("new_file.txt");
|
|
|
|
let tool = WriteFileTool::new().with_base_dir(dir.path().to_path_buf());
|
|
let ctx = JobContext::default();
|
|
|
|
let result = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"path": file_path.to_str().unwrap(),
|
|
"content": "hello world"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.result.get("success").unwrap().as_bool().unwrap());
|
|
assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "hello world");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_apply_patch() {
|
|
let dir = TempDir::new().unwrap();
|
|
let file_path = dir.path().join("code.rs");
|
|
std::fs::write(&file_path, "fn main() {\n println!(\"old\");\n}\n").unwrap();
|
|
|
|
let tool = ApplyPatchTool::new().with_base_dir(dir.path().to_path_buf());
|
|
let ctx = JobContext::default();
|
|
|
|
let result = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"path": file_path.to_str().unwrap(),
|
|
"old_string": "println!(\"old\")",
|
|
"new_string": "println!(\"new\")"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.result.get("success").unwrap().as_bool().unwrap());
|
|
let content = std::fs::read_to_string(&file_path).unwrap();
|
|
assert!(content.contains("println!(\"new\")"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_file_rejects_workspace_paths() {
|
|
let dir = TempDir::new().unwrap();
|
|
let tool = WriteFileTool::new().with_base_dir(dir.path().to_path_buf());
|
|
let ctx = JobContext::default();
|
|
|
|
let workspace_files = &[
|
|
"HEARTBEAT.md",
|
|
"MEMORY.md",
|
|
"IDENTITY.md",
|
|
"SOUL.md",
|
|
"AGENTS.md",
|
|
"USER.md",
|
|
"README.md",
|
|
];
|
|
|
|
for filename in workspace_files {
|
|
let path = dir.path().join(filename);
|
|
let err = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"path": path.to_str().unwrap(),
|
|
"content": "test"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("memory_write"),
|
|
"Rejection for {} should mention memory_write, got: {}",
|
|
filename,
|
|
msg
|
|
);
|
|
}
|
|
|
|
// daily/ and context/ prefixes should also be rejected
|
|
for prefix_path in &["daily/2024-01-15.md", "context/vision.md"] {
|
|
let err = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"path": prefix_path,
|
|
"content": "test"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(
|
|
err.to_string().contains("memory_write"),
|
|
"Rejection for {} should mention memory_write",
|
|
prefix_path
|
|
);
|
|
}
|
|
|
|
// Regular files should still work
|
|
let regular_path = dir.path().join("normal.txt");
|
|
let result = tool
|
|
.execute(
|
|
serde_json::json!({
|
|
"path": regular_path.to_str().unwrap(),
|
|
"content": "fine"
|
|
}),
|
|
&ctx,
|
|
)
|
|
.await;
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_list_dir() {
|
|
let dir = TempDir::new().unwrap();
|
|
std::fs::write(dir.path().join("file1.txt"), "content").unwrap();
|
|
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
|
|
|
let tool = ListDirTool::new();
|
|
let ctx = JobContext::default();
|
|
|
|
let result = tool
|
|
.execute(
|
|
serde_json::json!({"path": dir.path().to_str().unwrap()}),
|
|
&ctx,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let entries = result.result.get("entries").unwrap().as_array().unwrap();
|
|
assert!(entries.len() >= 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_lexical() {
|
|
// Basic .. resolution
|
|
assert_eq!(
|
|
normalize_lexical(Path::new("/a/b/../c")),
|
|
PathBuf::from("/a/c")
|
|
);
|
|
// Multiple .. components
|
|
assert_eq!(
|
|
normalize_lexical(Path::new("/a/b/c/../../d")),
|
|
PathBuf::from("/a/d")
|
|
);
|
|
// . components stripped
|
|
assert_eq!(
|
|
normalize_lexical(Path::new("/a/./b/./c")),
|
|
PathBuf::from("/a/b/c")
|
|
);
|
|
// Cannot escape root
|
|
assert_eq!(
|
|
normalize_lexical(Path::new("/a/../../..")),
|
|
PathBuf::from("/")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_path_rejects_traversal_nonexistent_parent() {
|
|
// The critical test: writing to ../../outside/newdir/file with base_dir
|
|
// set should be rejected even when the parent directory does not exist
|
|
// (i.e. canonicalize() cannot resolve it).
|
|
let dir = TempDir::new().unwrap();
|
|
let evil_path = format!(
|
|
"{}/../../outside/newdir/file.txt",
|
|
dir.path().to_str().unwrap()
|
|
);
|
|
let result = validate_path(&evil_path, Some(dir.path()));
|
|
assert!(
|
|
result.is_err(),
|
|
"Should reject traversal via non-existent parent, got: {:?}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_path_rejects_relative_traversal() {
|
|
let dir = TempDir::new().unwrap();
|
|
let result = validate_path("../../etc/passwd", Some(dir.path()));
|
|
assert!(
|
|
result.is_err(),
|
|
"Should reject relative traversal, got: {:?}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_path_allows_valid_nested_write() {
|
|
let dir = TempDir::new().unwrap();
|
|
let result = validate_path("subdir/newfile.txt", Some(dir.path()));
|
|
assert!(
|
|
result.is_ok(),
|
|
"Should allow nested writes within sandbox: {:?}",
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_path_allows_dot_dot_within_sandbox() {
|
|
// a/b/../c resolves to a/c which is still inside the sandbox
|
|
let dir = TempDir::new().unwrap();
|
|
std::fs::create_dir_all(dir.path().join("a/b")).unwrap();
|
|
let result = validate_path("a/b/../c.txt", Some(dir.path()));
|
|
assert!(
|
|
result.is_ok(),
|
|
"Should allow .. that stays within sandbox: {:?}",
|
|
result
|
|
);
|
|
}
|
|
}
|