mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 01:29:23 +00:00
Adding builder capability
This commit is contained in:
@@ -0,0 +1,739 @@
|
||||
//! 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::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Validate that a path is safe (no traversal attacks).
|
||||
fn validate_path(path_str: &str, base_dir: Option<&Path>) -> Result<PathBuf, ToolError> {
|
||||
let path = PathBuf::from(path_str);
|
||||
|
||||
// Reject paths with suspicious components (validation only, no action needed)
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::ParentDir => {
|
||||
// Allow .. but validate final path is within sandbox
|
||||
}
|
||||
std::path::Component::Normal(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
if s.starts_with('.') && s != "." && s != ".." && !s.starts_with(".git") {
|
||||
// Hidden files are OK for .git, .gitignore, etc.
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve to absolute path
|
||||
let resolved = if path.is_absolute() {
|
||||
path.canonicalize().unwrap_or_else(|_| path.clone())
|
||||
} else if let Some(base) = base_dir {
|
||||
base.join(&path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| base.join(&path))
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(&path)
|
||||
};
|
||||
|
||||
// If base_dir is set, ensure path is within it
|
||||
if let Some(base) = base_dir {
|
||||
// Canonicalize the base to handle symlinks (e.g., /var -> /private/var on macOS)
|
||||
let base_canonical = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
|
||||
|
||||
// For files that don't exist yet, we need to check the parent directory
|
||||
// and ensure the resolved path would be within the base
|
||||
let check_path = if resolved.exists() {
|
||||
resolved.canonicalize().unwrap_or_else(|_| resolved.clone())
|
||||
} else {
|
||||
// For non-existent files, canonicalize the parent and append the filename
|
||||
if let Some(parent) = resolved.parent() {
|
||||
if parent.exists() {
|
||||
let canonical_parent = parent
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| parent.to_path_buf());
|
||||
if let Some(filename) = resolved.file_name() {
|
||||
canonical_parent.join(filename)
|
||||
} else {
|
||||
resolved.clone()
|
||||
}
|
||||
} else {
|
||||
resolved.clone()
|
||||
}
|
||||
} else {
|
||||
resolved.clone()
|
||||
}
|
||||
};
|
||||
|
||||
if !check_path.starts_with(&base_canonical) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"Path escapes sandbox: {}",
|
||||
path_str
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// 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 the contents of a file. Returns the 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 = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. Creates the file if it doesn't exist, overwrites if it does. \
|
||||
Parent directories are created automatically. Use apply_patch for targeted edits to existing files."
|
||||
}
|
||||
|
||||
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 = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
||||
|
||||
let content = params
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'content' parameter".into()))?;
|
||||
|
||||
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) -> bool {
|
||||
true // File writes should require approval
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // We're writing, not reading external data
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. Shows files and subdirectories with their sizes. \
|
||||
Use for exploring project structure."
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
||||
|
||||
let old_string = params
|
||||
.get("old_string")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'old_string' parameter".into()))?;
|
||||
|
||||
let new_string = params
|
||||
.get("new_string")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'new_string' parameter".into()))?;
|
||||
|
||||
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) -> bool {
|
||||
true // File edits should require approval
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false // We're writing, not reading external data
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
mod echo;
|
||||
mod ecommerce;
|
||||
mod file;
|
||||
mod http;
|
||||
mod json;
|
||||
mod marketplace;
|
||||
mod memory;
|
||||
mod restaurant;
|
||||
mod shell;
|
||||
mod taskrabbit;
|
||||
mod time;
|
||||
|
||||
pub use echo::EchoTool;
|
||||
pub use ecommerce::EcommerceTool;
|
||||
pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
|
||||
pub use http::HttpTool;
|
||||
pub use json::JsonTool;
|
||||
pub use marketplace::MarketplaceTool;
|
||||
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryWriteTool};
|
||||
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
||||
pub use restaurant::RestaurantTool;
|
||||
pub use shell::ShellTool;
|
||||
pub use taskrabbit::TaskRabbitTool;
|
||||
pub use time::TimeTool;
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Shell execution tool for running commands in a sandboxed environment.
|
||||
//!
|
||||
//! Provides controlled command execution with:
|
||||
//! - Working directory isolation
|
||||
//! - Timeout enforcement
|
||||
//! - Output capture and truncation
|
||||
//! - Blocked command patterns for safety
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Maximum output size before truncation (64KB).
|
||||
const MAX_OUTPUT_SIZE: usize = 64 * 1024;
|
||||
|
||||
/// Default command timeout.
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Commands that are always blocked for safety.
|
||||
static BLOCKED_COMMANDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||
HashSet::from([
|
||||
"rm -rf /",
|
||||
"rm -rf /*",
|
||||
":(){ :|:& };:", // Fork bomb
|
||||
"dd if=/dev/zero",
|
||||
"mkfs",
|
||||
"chmod -R 777 /",
|
||||
"> /dev/sda",
|
||||
"curl | sh",
|
||||
"wget | sh",
|
||||
"curl | bash",
|
||||
"wget | bash",
|
||||
])
|
||||
});
|
||||
|
||||
/// Patterns that indicate potentially dangerous commands.
|
||||
static DANGEROUS_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||
vec![
|
||||
"sudo ",
|
||||
"doas ",
|
||||
" | sh",
|
||||
" | bash",
|
||||
" | zsh",
|
||||
"eval ",
|
||||
"$(curl",
|
||||
"$(wget",
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
"~/.ssh",
|
||||
".bash_history",
|
||||
"id_rsa",
|
||||
]
|
||||
});
|
||||
|
||||
/// Shell command execution tool.
|
||||
#[derive(Debug)]
|
||||
pub struct ShellTool {
|
||||
/// Working directory for commands (if None, uses job's working dir or cwd).
|
||||
working_dir: Option<PathBuf>,
|
||||
/// Command timeout.
|
||||
timeout: Duration,
|
||||
/// Whether to allow potentially dangerous commands (requires explicit approval).
|
||||
allow_dangerous: bool,
|
||||
}
|
||||
|
||||
impl ShellTool {
|
||||
/// Create a new shell tool with default settings.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
working_dir: None,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
allow_dangerous: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the working directory.
|
||||
pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
|
||||
self.working_dir = Some(dir);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the command timeout.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a command is blocked.
|
||||
fn is_blocked(&self, cmd: &str) -> Option<&'static str> {
|
||||
let normalized = cmd.to_lowercase();
|
||||
|
||||
for blocked in BLOCKED_COMMANDS.iter() {
|
||||
if normalized.contains(blocked) {
|
||||
return Some("Command contains blocked pattern");
|
||||
}
|
||||
}
|
||||
|
||||
if !self.allow_dangerous {
|
||||
for pattern in DANGEROUS_PATTERNS.iter() {
|
||||
if normalized.contains(pattern) {
|
||||
return Some("Command contains potentially dangerous pattern");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Execute a command and capture output.
|
||||
async fn execute_command(
|
||||
&self,
|
||||
cmd: &str,
|
||||
workdir: Option<&str>,
|
||||
timeout: Option<u64>,
|
||||
) -> Result<(String, i32), ToolError> {
|
||||
// Check for blocked commands
|
||||
if let Some(reason) = self.is_blocked(cmd) {
|
||||
return Err(ToolError::NotAuthorized(format!(
|
||||
"{}: {}",
|
||||
reason,
|
||||
truncate_for_error(cmd)
|
||||
)));
|
||||
}
|
||||
|
||||
// Determine working directory
|
||||
let cwd = workdir
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| self.working_dir.clone())
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
|
||||
// Build command
|
||||
let mut command = if cfg!(target_os = "windows") {
|
||||
let mut c = Command::new("cmd");
|
||||
c.args(["/C", cmd]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("sh");
|
||||
c.args(["-c", cmd]);
|
||||
c
|
||||
};
|
||||
|
||||
command
|
||||
.current_dir(&cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// Spawn process
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to spawn command: {}", e)))?;
|
||||
|
||||
// Determine timeout
|
||||
let timeout_duration = timeout.map(Duration::from_secs).unwrap_or(self.timeout);
|
||||
|
||||
// Wait with timeout
|
||||
let result = tokio::time::timeout(timeout_duration, async {
|
||||
let status = child.wait().await?;
|
||||
|
||||
// Read stdout
|
||||
let mut stdout = String::new();
|
||||
if let Some(mut out) = child.stdout.take() {
|
||||
let mut buf = vec![0u8; MAX_OUTPUT_SIZE];
|
||||
let n = out.read(&mut buf).await.unwrap_or(0);
|
||||
stdout = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
}
|
||||
|
||||
// Read stderr
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
let mut buf = vec![0u8; MAX_OUTPUT_SIZE];
|
||||
let n = err.read(&mut buf).await.unwrap_or(0);
|
||||
stderr = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
}
|
||||
|
||||
// Combine output
|
||||
let output = if stderr.is_empty() {
|
||||
stdout
|
||||
} else if stdout.is_empty() {
|
||||
stderr
|
||||
} else {
|
||||
format!("{}\n\n--- stderr ---\n{}", stdout, stderr)
|
||||
};
|
||||
|
||||
Ok::<_, std::io::Error>((output, status.code().unwrap_or(-1)))
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok((output, code))) => Ok((truncate_output(&output), code)),
|
||||
Ok(Err(e)) => Err(ToolError::ExecutionFailed(format!(
|
||||
"Command execution failed: {}",
|
||||
e
|
||||
))),
|
||||
Err(_) => {
|
||||
// Timeout - try to kill the process
|
||||
let _ = child.kill().await;
|
||||
Err(ToolError::Timeout(timeout_duration))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShellTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ShellTool {
|
||||
fn name(&self) -> &str {
|
||||
"shell"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute shell commands. Use for running builds, tests, git operations, and other CLI tasks. \
|
||||
Commands run in a subprocess with captured output. Long-running commands have a timeout."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The shell command to execute"
|
||||
},
|
||||
"workdir": {
|
||||
"type": "string",
|
||||
"description": "Working directory for the command (optional)"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in seconds (optional, default 120)"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'command' parameter".into()))?;
|
||||
|
||||
let workdir = params.get("workdir").and_then(|v| v.as_str());
|
||||
let timeout = params.get("timeout").and_then(|v| v.as_u64());
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let (output, exit_code) = self.execute_command(command, workdir, timeout).await?;
|
||||
let duration = start.elapsed();
|
||||
|
||||
let result = serde_json::json!({
|
||||
"output": output,
|
||||
"exit_code": exit_code,
|
||||
"success": exit_code == 0
|
||||
});
|
||||
|
||||
Ok(ToolOutput::success(result, duration))
|
||||
}
|
||||
|
||||
fn requires_approval(&self) -> bool {
|
||||
true // Shell commands should require approval
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
true // Shell output could contain anything
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate output to fit within limits.
|
||||
fn truncate_output(s: &str) -> String {
|
||||
if s.len() <= MAX_OUTPUT_SIZE {
|
||||
s.to_string()
|
||||
} else {
|
||||
let half = MAX_OUTPUT_SIZE / 2;
|
||||
format!(
|
||||
"{}\n\n... [truncated {} bytes] ...\n\n{}",
|
||||
&s[..half],
|
||||
s.len() - MAX_OUTPUT_SIZE,
|
||||
&s[s.len() - half..]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate command for error messages.
|
||||
fn truncate_for_error(s: &str) -> String {
|
||||
if s.len() <= 100 {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..100])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_echo_command() {
|
||||
let tool = ShellTool::new();
|
||||
let ctx = JobContext::default();
|
||||
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"command": "echo hello"}), &ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let output = result.result.get("output").unwrap().as_str().unwrap();
|
||||
assert!(output.contains("hello"));
|
||||
assert_eq!(result.result.get("exit_code").unwrap().as_i64().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocked_commands() {
|
||||
let tool = ShellTool::new();
|
||||
|
||||
assert!(tool.is_blocked("rm -rf /").is_some());
|
||||
assert!(tool.is_blocked("sudo rm file").is_some());
|
||||
assert!(tool.is_blocked("curl http://x | sh").is_some());
|
||||
assert!(tool.is_blocked("echo hello").is_none());
|
||||
assert!(tool.is_blocked("cargo build").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_command_timeout() {
|
||||
let tool = ShellTool::new().with_timeout(Duration::from_millis(100));
|
||||
let ctx = JobContext::default();
|
||||
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"command": "sleep 10"}), &ctx)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(ToolError::Timeout(_))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user