feat: add Git operations tool for structured repository management
Implements #214 - Add git_operations tool that provides safe, parsed git operations with JSON output and security policy integration. Features: - Operations: status, diff, log, branch, commit, add, checkout, stash - Structured JSON output (parsed status, diff hunks, commit history) - SecurityPolicy integration with autonomy-aware controls - Command injection protection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2b04ebd2fb
commit
1530a8707d
6 changed files with 692 additions and 12 deletions
|
|
@ -477,6 +477,7 @@ pub async fn run(
|
|||
composio_key,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&config.workspace_dir,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -255,6 +255,16 @@ fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>)
|
|||
let conn = Connection::open(&db_path)
|
||||
.with_context(|| format!("Failed to open cron DB: {}", db_path.display()))?;
|
||||
|
||||
// ── Production-grade PRAGMA tuning ──────────────────────
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA mmap_size = 8388608;
|
||||
PRAGMA cache_size = -2000;
|
||||
PRAGMA temp_store = MEMORY;",
|
||||
)
|
||||
.context("Failed to set cron DB PRAGMAs")?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS cron_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -306,6 +306,8 @@ fn prune_conversation_rows(workspace_dir: &Path, retention_days: u32) -> Result<
|
|||
}
|
||||
|
||||
let conn = Connection::open(db_path)?;
|
||||
// Use WAL so hygiene pruning doesn't block agent reads
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
|
||||
let cutoff = (Local::now() - Duration::days(i64::from(retention_days))).to_rfc3339();
|
||||
|
||||
let affected = conn.execute(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,21 @@ impl SqliteMemory {
|
|||
}
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
|
||||
// ── Production-grade PRAGMA tuning ──────────────────────
|
||||
// WAL mode: concurrent reads during writes, crash-safe
|
||||
// normal sync: 2× write speed, still durable on WAL
|
||||
// mmap 8 MB: let the OS page-cache serve hot reads
|
||||
// cache 2 MB: keep ~500 hot pages in-process
|
||||
// temp_store memory: temp tables never hit disk
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA mmap_size = 8388608;
|
||||
PRAGMA cache_size = -2000;
|
||||
PRAGMA temp_store = MEMORY;",
|
||||
)?;
|
||||
|
||||
Self::init_schema(&conn)?;
|
||||
|
||||
Ok(Self {
|
||||
|
|
|
|||
654
src/tools/git_operations.rs
Normal file
654
src/tools/git_operations.rs
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Git operations tool for structured repository management.
|
||||
/// Provides safe, parsed git operations with JSON output.
|
||||
pub struct GitOperationsTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
workspace_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl GitOperationsTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>, workspace_dir: std::path::PathBuf) -> Self {
|
||||
Self { security, workspace_dir }
|
||||
}
|
||||
|
||||
/// Sanitize git arguments to prevent injection attacks
|
||||
fn sanitize_git_args(&self, args: &str) -> anyhow::Result<Vec<String>> {
|
||||
let mut result = Vec::new();
|
||||
for arg in args.split_whitespace() {
|
||||
// Block dangerous git options that could lead to command injection
|
||||
let arg_lower = arg.to_lowercase();
|
||||
if arg_lower.starts_with("--exec=")
|
||||
|| arg_lower.starts_with("--upload-pack=")
|
||||
|| arg_lower.starts_with("--receive-pack=")
|
||||
|| arg_lower.contains("$(")
|
||||
|| arg_lower.contains("`")
|
||||
|| arg.contains('|')
|
||||
|| arg.contains(';')
|
||||
{
|
||||
anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
|
||||
}
|
||||
result.push(arg.to_string());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Check if an operation requires write access
|
||||
fn requires_write_access(&self, operation: &str) -> bool {
|
||||
matches!(
|
||||
operation,
|
||||
"commit" | "add" | "checkout" | "branch" | "stash" | "reset" | "revert"
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if an operation is read-only
|
||||
fn is_read_only(&self, operation: &str) -> bool {
|
||||
matches!(operation, "status" | "diff" | "log" | "show" | "branch" | "rev-parse")
|
||||
}
|
||||
|
||||
async fn run_git_command(&self, args: &[&str]) -> anyhow::Result<String> {
|
||||
let output = tokio::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&self.workspace_dir)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Git command failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
async fn git_status(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let output = self.run_git_command(&["status", "--porcelain=2", "--branch"]).await?;
|
||||
|
||||
// Parse git status output into structured format
|
||||
let mut result = serde_json::Map::new();
|
||||
let mut branch = String::new();
|
||||
let mut staged = Vec::new();
|
||||
let mut unstaged = Vec::new();
|
||||
let mut untracked = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
if line.starts_with("# branch.head ") {
|
||||
branch = line.trim_start_matches("# branch.head ").to_string();
|
||||
} else if let Some(rest) = line.strip_prefix("1 ") {
|
||||
// Ordinary changed entry
|
||||
let parts: Vec<&str> = rest.split(' ').collect();
|
||||
if parts.len() >= 2 {
|
||||
let path = parts.get(1).unwrap_or(&"");
|
||||
let staging = parts.get(0).unwrap_or(&"");
|
||||
if !staging.is_empty() {
|
||||
let status_char = staging.chars().next().unwrap_or(' ');
|
||||
if status_char != '.' && status_char != ' ' {
|
||||
staged.push(json!({"path": path, "status": status_char}));
|
||||
}
|
||||
let status_char = staging.chars().nth(1).unwrap_or(' ');
|
||||
if status_char != '.' && status_char != ' ' {
|
||||
unstaged.push(json!({"path": path, "status": status_char}));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("? ") {
|
||||
untracked.push(rest.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
result.insert("branch".to_string(), json!(branch));
|
||||
result.insert("staged".to_string(), json!(staged));
|
||||
result.insert("unstaged".to_string(), json!(unstaged));
|
||||
result.insert("untracked".to_string(), json!(untracked));
|
||||
result.insert("clean".to_string(), json!(staged.is_empty() && unstaged.is_empty() && untracked.is_empty()));
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&result).unwrap_or_default(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn git_diff(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let files = args.get("files").and_then(|v| v.as_str()).unwrap_or(".");
|
||||
let cached = args.get("cached").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let mut git_args = vec!["diff", "--unified=3"];
|
||||
if cached {
|
||||
git_args.push("--cached");
|
||||
}
|
||||
git_args.push("--");
|
||||
git_args.push(files);
|
||||
|
||||
let output = self.run_git_command(&git_args).await?;
|
||||
|
||||
// Parse diff into structured hunks
|
||||
let mut result = serde_json::Map::new();
|
||||
let mut hunks = Vec::new();
|
||||
let mut current_file = String::new();
|
||||
let mut current_hunk = serde_json::Map::new();
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
if line.starts_with("diff --git ") {
|
||||
if !lines.is_empty() {
|
||||
current_hunk.insert("lines".to_string(), json!(lines));
|
||||
if !current_hunk.is_empty() {
|
||||
hunks.push(serde_json::Value::Object(current_hunk.clone()));
|
||||
}
|
||||
lines = Vec::new();
|
||||
current_hunk = serde_json::Map::new();
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
current_file = parts[3].trim_start_matches("b/").to_string();
|
||||
current_hunk.insert("file".to_string(), json!(current_file));
|
||||
}
|
||||
} else if line.starts_with("@@ ") {
|
||||
if !lines.is_empty() {
|
||||
current_hunk.insert("lines".to_string(), json!(lines));
|
||||
if !current_hunk.is_empty() {
|
||||
hunks.push(serde_json::Value::Object(current_hunk.clone()));
|
||||
}
|
||||
lines = Vec::new();
|
||||
current_hunk = serde_json::Map::new();
|
||||
current_hunk.insert("file".to_string(), json!(current_file));
|
||||
}
|
||||
current_hunk.insert("header".to_string(), json!(line));
|
||||
} else if !line.is_empty() {
|
||||
lines.push(json!({
|
||||
"text": line,
|
||||
"type": if line.starts_with('+') { "add" }
|
||||
else if line.starts_with('-') { "delete" }
|
||||
else { "context" }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if !lines.is_empty() {
|
||||
current_hunk.insert("lines".to_string(), json!(lines));
|
||||
if !current_hunk.is_empty() {
|
||||
hunks.push(serde_json::Value::Object(current_hunk));
|
||||
}
|
||||
}
|
||||
|
||||
result.insert("hunks".to_string(), json!(hunks));
|
||||
result.insert("file_count".to_string(), json!(hunks.len()));
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&result).unwrap_or_default(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn git_log(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
let limit_str = limit.to_string();
|
||||
|
||||
let output = self.run_git_command(&[
|
||||
"log",
|
||||
&format!("-{limit_str}"),
|
||||
"--pretty=format:%H|%an|%ae|%ad|%s",
|
||||
"--date=iso",
|
||||
]).await?;
|
||||
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
if parts.len() >= 5 {
|
||||
commits.push(json!({
|
||||
"hash": parts[0],
|
||||
"author": parts[1],
|
||||
"email": parts[2],
|
||||
"date": parts[3],
|
||||
"message": parts[4]
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({ "commits": commits })).unwrap_or_default(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn git_branch(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let output = self.run_git_command(&["branch", "--format=%(refname:short)|%(HEAD)"]).await?;
|
||||
|
||||
let mut branches = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for line in output.lines() {
|
||||
if let Some((name, head)) = line.split_once('|') {
|
||||
let is_current = head == "*";
|
||||
if is_current {
|
||||
current = name.to_string();
|
||||
}
|
||||
branches.push(json!({
|
||||
"name": name,
|
||||
"current": is_current
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&json!({
|
||||
"current": current,
|
||||
"branches": branches
|
||||
})).unwrap_or_default(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn git_commit(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let message = args.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))?;
|
||||
|
||||
// Sanitize commit message
|
||||
let sanitized = message.lines()
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
if sanitized.is_empty() {
|
||||
anyhow::bail!("Commit message cannot be empty");
|
||||
}
|
||||
|
||||
// Limit message length
|
||||
let message = if sanitized.len() > 2000 {
|
||||
format!("{}...", &sanitized[..1997])
|
||||
} else {
|
||||
sanitized
|
||||
};
|
||||
|
||||
let output = self.run_git_command(&["commit", "-m", &message]).await;
|
||||
|
||||
match output {
|
||||
Ok(_) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Committed: {message}"),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Commit failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn git_add(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let paths = args.get("paths")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'paths' parameter"))?;
|
||||
|
||||
let output = self.run_git_command(&["add", "--", paths]).await;
|
||||
|
||||
match output {
|
||||
Ok(_) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Staged: {paths}"),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Add failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn git_checkout(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let branch = args.get("branch")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'branch' parameter"))?;
|
||||
|
||||
// Sanitize branch name
|
||||
let sanitized = self.sanitize_git_args(branch)?;
|
||||
|
||||
if sanitized.is_empty() || sanitized.len() > 1 {
|
||||
anyhow::bail!("Invalid branch specification");
|
||||
}
|
||||
|
||||
let branch_name = &sanitized[0];
|
||||
|
||||
// Block dangerous branch names
|
||||
if branch_name.contains('@') || branch_name.contains('^') || branch_name.contains('~') {
|
||||
anyhow::bail!("Branch name contains invalid characters");
|
||||
}
|
||||
|
||||
let output = self.run_git_command(&["checkout", branch_name]).await;
|
||||
|
||||
match output {
|
||||
Ok(_) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Switched to branch: {branch_name}"),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Checkout failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn git_stash(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("push");
|
||||
|
||||
let output = match action {
|
||||
"push" | "save" => self.run_git_command(&["stash", "push", "-m", "auto-stash"]).await,
|
||||
"pop" => self.run_git_command(&["stash", "pop"]).await,
|
||||
"list" => self.run_git_command(&["stash", "list"]).await,
|
||||
"drop" => {
|
||||
let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
|
||||
self.run_git_command(&["stash", "drop", &format!("stash@{{{index}}}")]).await
|
||||
}
|
||||
_ => anyhow::bail!("Unknown stash action: {action}. Use: push, pop, list, drop"),
|
||||
};
|
||||
|
||||
match output {
|
||||
Ok(out) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: out,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Stash {action} failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GitOperationsTool {
|
||||
fn name(&self) -> &str {
|
||||
"git_operations"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["status", "diff", "log", "branch", "commit", "add", "checkout", "stash"],
|
||||
"description": "Git operation to perform"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Commit message (for 'commit' operation)"
|
||||
},
|
||||
"paths": {
|
||||
"type": "string",
|
||||
"description": "File paths to stage (for 'add' operation)"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "Branch name (for 'checkout' operation)"
|
||||
},
|
||||
"files": {
|
||||
"type": "string",
|
||||
"description": "File or path to diff (for 'diff' operation, default: '.')"
|
||||
},
|
||||
"cached": {
|
||||
"type": "boolean",
|
||||
"description": "Show staged changes (for 'diff' operation)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Number of log entries (for 'log' operation, default: 10)"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["push", "pop", "list", "drop"],
|
||||
"description": "Stash action (for 'stash' operation)"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Stash index (for 'stash' with 'drop' action)"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let operation = match args.get("operation").and_then(|v| v.as_str()) {
|
||||
Some(op) => op,
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Missing 'operation' parameter".into()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Check if we're in a git repository
|
||||
if !self.workspace_dir.join(".git").exists() {
|
||||
// Try to find .git in parent directories
|
||||
let mut current_dir = self.workspace_dir.as_path();
|
||||
let mut found_git = false;
|
||||
while current_dir.parent().is_some() {
|
||||
if current_dir.join(".git").exists() {
|
||||
found_git = true;
|
||||
break;
|
||||
}
|
||||
current_dir = current_dir.parent().unwrap();
|
||||
}
|
||||
|
||||
if !found_git {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Not in a git repository".into()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check autonomy level for write operations
|
||||
if self.requires_write_access(operation) {
|
||||
if !self.security.can_act() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Action blocked: git write operations require higher autonomy level".into()),
|
||||
});
|
||||
}
|
||||
|
||||
match self.security.autonomy {
|
||||
AutonomyLevel::ReadOnly => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Action blocked: read-only mode".into()),
|
||||
});
|
||||
}
|
||||
AutonomyLevel::Supervised => {
|
||||
// Allow but require tracking
|
||||
}
|
||||
AutonomyLevel::Full => {
|
||||
// Allow freely
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record action for rate limiting
|
||||
if !self.security.record_action() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Action blocked: rate limit exceeded".into()),
|
||||
});
|
||||
}
|
||||
|
||||
// Execute the requested operation
|
||||
match operation {
|
||||
"status" => self.git_status(args).await,
|
||||
"diff" => self.git_diff(args).await,
|
||||
"log" => self.git_log(args).await,
|
||||
"branch" => self.git_branch(args).await,
|
||||
"commit" => self.git_commit(args).await,
|
||||
"add" => self.git_add(args).await,
|
||||
"checkout" => self.git_checkout(args).await,
|
||||
"stash" => self.git_stash(args).await,
|
||||
_ => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Unknown operation: {operation}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::security::SecurityPolicy;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_tool(dir: &Path) -> GitOperationsTool {
|
||||
let security = Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
GitOperationsTool::new(security, dir.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_git_blocks_injection() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = test_tool(tmp.path());
|
||||
|
||||
// Should block dangerous arguments
|
||||
assert!(tool.sanitize_git_args("--exec=rm -rf /").is_err());
|
||||
assert!(tool.sanitize_git_args("$(echo pwned)").is_err());
|
||||
assert!(tool.sanitize_git_args("`malicious`").is_err());
|
||||
assert!(tool.sanitize_git_args("arg | cat").is_err());
|
||||
assert!(tool.sanitize_git_args("arg; rm file").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_git_allows_safe() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = test_tool(tmp.path());
|
||||
|
||||
// Should allow safe arguments
|
||||
assert!(tool.sanitize_git_args("main").is_ok());
|
||||
assert!(tool.sanitize_git_args("feature/test-branch").is_ok());
|
||||
assert!(tool.sanitize_git_args("--cached").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_write_detection() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = test_tool(tmp.path());
|
||||
|
||||
assert!(tool.requires_write_access("commit"));
|
||||
assert!(tool.requires_write_access("add"));
|
||||
assert!(tool.requires_write_access("checkout"));
|
||||
|
||||
assert!(!tool.requires_write_access("status"));
|
||||
assert!(!tool.requires_write_access("diff"));
|
||||
assert!(!tool.requires_write_access("log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_read_only_detection() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = test_tool(tmp.path());
|
||||
|
||||
assert!(tool.is_read_only("status"));
|
||||
assert!(tool.is_read_only("diff"));
|
||||
assert!(tool.is_read_only("log"));
|
||||
|
||||
assert!(!tool.is_read_only("commit"));
|
||||
assert!(!tool.is_read_only("add"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocks_readonly_mode_for_write_ops() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Initialize a git repository
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let security = Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::ReadOnly,
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
|
||||
|
||||
let result = tool
|
||||
.execute(json!({"operation": "commit", "message": "test"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
// can_act() returns false for ReadOnly, so we get the "higher autonomy level" message
|
||||
assert!(result.error.as_deref().unwrap_or("").contains("higher autonomy"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allows_readonly_ops_in_readonly_mode() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let security = Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::ReadOnly,
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
|
||||
|
||||
// This will fail because there's no git repo, but it shouldn't be blocked by autonomy
|
||||
let result = tool.execute(json!({"operation": "status"})).await.unwrap();
|
||||
// The error should be about not being in a git repo, not about read-only mode
|
||||
let error_msg = result.error.as_deref().unwrap_or("");
|
||||
assert!(error_msg.contains("git repository") || error_msg.contains("Git command failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_missing_operation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let tool = test_tool(tmp.path());
|
||||
|
||||
let result = tool.execute(json!({})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_deref().unwrap_or("").contains("Missing 'operation'"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_unknown_operation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Initialize a git repository
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let tool = test_tool(tmp.path());
|
||||
|
||||
let result = tool.execute(json!({"operation": "push"})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_deref().unwrap_or("").contains("Unknown operation"));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ pub mod composio;
|
|||
pub mod delegate;
|
||||
pub mod file_read;
|
||||
pub mod file_write;
|
||||
pub mod git_operations;
|
||||
pub mod http_request;
|
||||
pub mod image_info;
|
||||
pub mod memory_forget;
|
||||
|
|
@ -19,6 +20,7 @@ pub use composio::ComposioTool;
|
|||
pub use delegate::DelegateTool;
|
||||
pub use file_read::FileReadTool;
|
||||
pub use file_write::FileWriteTool;
|
||||
pub use git_operations::GitOperationsTool;
|
||||
pub use http_request::HttpRequestTool;
|
||||
pub use image_info::ImageInfoTool;
|
||||
pub use memory_forget::MemoryForgetTool;
|
||||
|
|
@ -62,6 +64,7 @@ pub fn all_tools(
|
|||
composio_key: Option<&str>,
|
||||
browser_config: &crate::config::BrowserConfig,
|
||||
http_config: &crate::config::HttpRequestConfig,
|
||||
workspace_dir: &std::path::Path,
|
||||
agents: &HashMap<String, DelegateAgentConfig>,
|
||||
fallback_api_key: Option<&str>,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
|
|
@ -72,6 +75,7 @@ pub fn all_tools(
|
|||
composio_key,
|
||||
browser_config,
|
||||
http_config,
|
||||
workspace_dir,
|
||||
agents,
|
||||
fallback_api_key,
|
||||
)
|
||||
|
|
@ -86,6 +90,7 @@ pub fn all_tools_with_runtime(
|
|||
composio_key: Option<&str>,
|
||||
browser_config: &crate::config::BrowserConfig,
|
||||
http_config: &crate::config::HttpRequestConfig,
|
||||
workspace_dir: &std::path::Path,
|
||||
agents: &HashMap<String, DelegateAgentConfig>,
|
||||
fallback_api_key: Option<&str>,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
|
|
@ -96,6 +101,7 @@ pub fn all_tools_with_runtime(
|
|||
Box::new(MemoryStoreTool::new(memory.clone())),
|
||||
Box::new(MemoryRecallTool::new(memory.clone())),
|
||||
Box::new(MemoryForgetTool::new(memory)),
|
||||
Box::new(GitOperationsTool::new(security.clone(), workspace_dir.to_path_buf())),
|
||||
];
|
||||
|
||||
if browser_config.enabled {
|
||||
|
|
@ -178,7 +184,7 @@ mod tests {
|
|||
};
|
||||
let http = crate::config::HttpRequestConfig::default();
|
||||
|
||||
let tools = all_tools(&security, mem, None, &browser, &http, &HashMap::new(), None);
|
||||
let tools = all_tools(&security, mem, None, &browser, &http, tmp.path(), &HashMap::new(), None);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(!names.contains(&"browser_open"));
|
||||
}
|
||||
|
|
@ -202,7 +208,7 @@ mod tests {
|
|||
};
|
||||
let http = crate::config::HttpRequestConfig::default();
|
||||
|
||||
let tools = all_tools(&security, mem, None, &browser, &http, &HashMap::new(), None);
|
||||
let tools = all_tools(&security, mem, None, &browser, &http, tmp.path(), &HashMap::new(), None);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(names.contains(&"browser_open"));
|
||||
}
|
||||
|
|
@ -328,15 +334,7 @@ mod tests {
|
|||
},
|
||||
);
|
||||
|
||||
let tools = all_tools(
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
&agents,
|
||||
Some("sk-test"),
|
||||
);
|
||||
let tools = all_tools(&security, mem, None, &browser, &http, tmp.path(), &agents, Some("sk-test"));
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(names.contains(&"delegate"));
|
||||
}
|
||||
|
|
@ -355,7 +353,7 @@ mod tests {
|
|||
let browser = BrowserConfig::default();
|
||||
let http = crate::config::HttpRequestConfig::default();
|
||||
|
||||
let tools = all_tools(&security, mem, None, &browser, &http, &HashMap::new(), None);
|
||||
let tools = all_tools(&security, mem, None, &browser, &http, tmp.path(), &HashMap::new(), None);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(!names.contains(&"delegate"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue