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,
|
composio_key,
|
||||||
&config.browser,
|
&config.browser,
|
||||||
&config.http_request,
|
&config.http_request,
|
||||||
|
&config.workspace_dir,
|
||||||
&config.agents,
|
&config.agents,
|
||||||
config.api_key.as_deref(),
|
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)
|
let conn = Connection::open(&db_path)
|
||||||
.with_context(|| format!("Failed to open cron DB: {}", db_path.display()))?;
|
.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(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS cron_jobs (
|
"CREATE TABLE IF NOT EXISTS cron_jobs (
|
||||||
id TEXT PRIMARY KEY,
|
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)?;
|
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 cutoff = (Local::now() - Duration::days(i64::from(retention_days))).to_rfc3339();
|
||||||
|
|
||||||
let affected = conn.execute(
|
let affected = conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,21 @@ impl SqliteMemory {
|
||||||
}
|
}
|
||||||
|
|
||||||
let conn = Connection::open(&db_path)?;
|
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)?;
|
Self::init_schema(&conn)?;
|
||||||
|
|
||||||
Ok(Self {
|
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 delegate;
|
||||||
pub mod file_read;
|
pub mod file_read;
|
||||||
pub mod file_write;
|
pub mod file_write;
|
||||||
|
pub mod git_operations;
|
||||||
pub mod http_request;
|
pub mod http_request;
|
||||||
pub mod image_info;
|
pub mod image_info;
|
||||||
pub mod memory_forget;
|
pub mod memory_forget;
|
||||||
|
|
@ -19,6 +20,7 @@ pub use composio::ComposioTool;
|
||||||
pub use delegate::DelegateTool;
|
pub use delegate::DelegateTool;
|
||||||
pub use file_read::FileReadTool;
|
pub use file_read::FileReadTool;
|
||||||
pub use file_write::FileWriteTool;
|
pub use file_write::FileWriteTool;
|
||||||
|
pub use git_operations::GitOperationsTool;
|
||||||
pub use http_request::HttpRequestTool;
|
pub use http_request::HttpRequestTool;
|
||||||
pub use image_info::ImageInfoTool;
|
pub use image_info::ImageInfoTool;
|
||||||
pub use memory_forget::MemoryForgetTool;
|
pub use memory_forget::MemoryForgetTool;
|
||||||
|
|
@ -62,6 +64,7 @@ pub fn all_tools(
|
||||||
composio_key: Option<&str>,
|
composio_key: Option<&str>,
|
||||||
browser_config: &crate::config::BrowserConfig,
|
browser_config: &crate::config::BrowserConfig,
|
||||||
http_config: &crate::config::HttpRequestConfig,
|
http_config: &crate::config::HttpRequestConfig,
|
||||||
|
workspace_dir: &std::path::Path,
|
||||||
agents: &HashMap<String, DelegateAgentConfig>,
|
agents: &HashMap<String, DelegateAgentConfig>,
|
||||||
fallback_api_key: Option<&str>,
|
fallback_api_key: Option<&str>,
|
||||||
) -> Vec<Box<dyn Tool>> {
|
) -> Vec<Box<dyn Tool>> {
|
||||||
|
|
@ -72,6 +75,7 @@ pub fn all_tools(
|
||||||
composio_key,
|
composio_key,
|
||||||
browser_config,
|
browser_config,
|
||||||
http_config,
|
http_config,
|
||||||
|
workspace_dir,
|
||||||
agents,
|
agents,
|
||||||
fallback_api_key,
|
fallback_api_key,
|
||||||
)
|
)
|
||||||
|
|
@ -86,6 +90,7 @@ pub fn all_tools_with_runtime(
|
||||||
composio_key: Option<&str>,
|
composio_key: Option<&str>,
|
||||||
browser_config: &crate::config::BrowserConfig,
|
browser_config: &crate::config::BrowserConfig,
|
||||||
http_config: &crate::config::HttpRequestConfig,
|
http_config: &crate::config::HttpRequestConfig,
|
||||||
|
workspace_dir: &std::path::Path,
|
||||||
agents: &HashMap<String, DelegateAgentConfig>,
|
agents: &HashMap<String, DelegateAgentConfig>,
|
||||||
fallback_api_key: Option<&str>,
|
fallback_api_key: Option<&str>,
|
||||||
) -> Vec<Box<dyn Tool>> {
|
) -> Vec<Box<dyn Tool>> {
|
||||||
|
|
@ -96,6 +101,7 @@ pub fn all_tools_with_runtime(
|
||||||
Box::new(MemoryStoreTool::new(memory.clone())),
|
Box::new(MemoryStoreTool::new(memory.clone())),
|
||||||
Box::new(MemoryRecallTool::new(memory.clone())),
|
Box::new(MemoryRecallTool::new(memory.clone())),
|
||||||
Box::new(MemoryForgetTool::new(memory)),
|
Box::new(MemoryForgetTool::new(memory)),
|
||||||
|
Box::new(GitOperationsTool::new(security.clone(), workspace_dir.to_path_buf())),
|
||||||
];
|
];
|
||||||
|
|
||||||
if browser_config.enabled {
|
if browser_config.enabled {
|
||||||
|
|
@ -178,7 +184,7 @@ mod tests {
|
||||||
};
|
};
|
||||||
let http = crate::config::HttpRequestConfig::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();
|
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||||
assert!(!names.contains(&"browser_open"));
|
assert!(!names.contains(&"browser_open"));
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +208,7 @@ mod tests {
|
||||||
};
|
};
|
||||||
let http = crate::config::HttpRequestConfig::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();
|
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||||
assert!(names.contains(&"browser_open"));
|
assert!(names.contains(&"browser_open"));
|
||||||
}
|
}
|
||||||
|
|
@ -328,15 +334,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let tools = all_tools(
|
let tools = all_tools(&security, mem, None, &browser, &http, tmp.path(), &agents, Some("sk-test"));
|
||||||
&security,
|
|
||||||
mem,
|
|
||||||
None,
|
|
||||||
&browser,
|
|
||||||
&http,
|
|
||||||
&agents,
|
|
||||||
Some("sk-test"),
|
|
||||||
);
|
|
||||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||||
assert!(names.contains(&"delegate"));
|
assert!(names.contains(&"delegate"));
|
||||||
}
|
}
|
||||||
|
|
@ -355,7 +353,7 @@ mod tests {
|
||||||
let browser = BrowserConfig::default();
|
let browser = BrowserConfig::default();
|
||||||
let http = crate::config::HttpRequestConfig::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();
|
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||||
assert!(!names.contains(&"delegate"));
|
assert!(!names.contains(&"delegate"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue