TradingAgents/.claude/lib/uninstall_orchestrator.py

783 lines
27 KiB
Python

#!/usr/bin/env python3
"""
Uninstall Orchestrator - Complete uninstallation of autonomous-dev plugin
This module handles complete uninstallation of the autonomous-dev plugin with
backup and rollback capabilities. Implements three-phase execution:
Validate → Preview → Execute.
Security:
- Path traversal prevention (CWE-22)
- Symlink attack prevention (CWE-59)
- TOCTOU detection (CWE-367)
- Whitelist enforcement for allowed directories
- Audit logging for all operations
Features:
- Three-phase execution (validate, preview, execute)
- Automatic backup creation before deletion
- Rollback support to restore from backup
- Protected file preservation (PROJECT.md, .env, settings.local.json)
- Dry-run mode for safe preview
- Local-only mode to preserve global files
Usage:
from uninstall_orchestrator import uninstall_plugin
# Simple uninstall with preview
result = uninstall_plugin(project_root, dry_run=True)
print(f"Would remove {result.files_to_remove} files")
# Execute actual uninstall
result = uninstall_plugin(project_root, force=True)
if result.status == "success":
print(f"Removed {result.files_removed} files")
print(f"Backup: {result.backup_path}")
# Rollback if needed
orchestrator = UninstallOrchestrator(project_root)
rollback_result = orchestrator.rollback(result.backup_path)
Date: 2025-12-14
Issue: GitHub #131 - Add uninstall capability to install.sh and /sync command
Agent: implementer
Design Patterns:
See library-design-patterns skill for standardized design patterns.
"""
import json
import os
import tarfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List, Optional
# Import with fallback for both dev (plugins/) and installed (.claude/lib/) environments
try:
from plugins.autonomous_dev.lib.security_utils import validate_path, audit_log
from plugins.autonomous_dev.lib.protected_file_detector import ProtectedFileDetector
except ImportError:
# Fallback for installed environment (.claude/lib/)
from security_utils import audit_log
from protected_file_detector import ProtectedFileDetector
# Whitelist of allowed directories for uninstallation
ALLOWED_DIRECTORIES = [
".claude",
".autonomous-dev",
]
# Global directories (under home)
GLOBAL_DIRECTORIES = [
".claude",
".autonomous-dev",
]
@dataclass
class UninstallResult:
"""Result of uninstall operation.
Attributes:
status: Operation status ("success", "failure", "preview")
files_removed: Number of files actually removed
total_size_bytes: Total size of files removed/to be removed
backup_path: Path to backup tar.gz file
errors: List of error messages
dry_run: Whether this was a dry-run preview
files_to_remove: Number of files to be removed (preview mode)
file_list: List of file paths to be removed (preview mode)
manifest_found: Whether install manifest was found
files_restored: Number of files restored (rollback mode)
"""
status: str
files_removed: int = 0
total_size_bytes: int = 0
backup_path: Optional[Path] = None
errors: List[str] = field(default_factory=list)
dry_run: bool = False
files_to_remove: int = 0
file_list: List[Path] = field(default_factory=list)
manifest_found: bool = False
files_restored: int = 0
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary for JSON serialization.
Returns:
Dictionary representation of result
"""
return {
"status": self.status,
"files_removed": self.files_removed,
"total_size_bytes": self.total_size_bytes,
"backup_path": str(self.backup_path) if self.backup_path else None,
"errors": self.errors,
"dry_run": self.dry_run,
"files_to_remove": self.files_to_remove,
"file_list": [str(f) for f in self.file_list],
"manifest_found": self.manifest_found,
"files_restored": self.files_restored,
}
class UninstallError(Exception):
"""Exception raised for uninstall errors."""
pass
class UninstallOrchestrator:
"""Orchestrate complete uninstallation of autonomous-dev plugin.
This class handles three-phase uninstallation:
1. Validate: Check manifest exists and paths are valid
2. Preview: Show what will be deleted without deleting
3. Execute: Create backup and delete files
Examples:
>>> orchestrator = UninstallOrchestrator(project_root)
>>> # Phase 1: Validate
>>> result = orchestrator.validate()
>>> if result.status == "success":
... # Phase 2: Preview
... preview = orchestrator.preview()
... print(f"Would remove {preview.files_to_remove} files")
... # Phase 3: Execute
... result = orchestrator.execute(force=True)
... print(f"Backup: {result.backup_path}")
"""
def __init__(self, project_root: Path | str):
"""Initialize uninstall orchestrator.
Args:
project_root: Root directory of project to uninstall from
Raises:
ValueError: If path validation fails
"""
# Convert to Path if string
self.project_root = Path(project_root) if isinstance(project_root, str) else project_root
# Validate path (CWE-22: path traversal prevention)
self.project_root = self.project_root.resolve()
# Check for path traversal
try:
# Get the original path without resolving
original_path = Path(project_root) if isinstance(project_root, str) else project_root
original_str = str(original_path)
# Detect path traversal patterns
if ".." in original_str or "/./" in original_str:
raise ValueError(f"Path traversal detected: {original_str}")
except Exception as e:
audit_log("uninstall_orchestrator", "path_validation_error", {
"path": str(project_root),
"error": str(e)
})
raise ValueError(f"Path traversal detected: {project_root}")
self.claude_dir = self.project_root / ".claude"
self.manifest_path = self.claude_dir / "config" / "install_manifest.json"
self.protected_detector = ProtectedFileDetector()
# State tracking for TOCTOU detection
self._preview_state: Dict[str, Any] = {}
audit_log("uninstall_orchestrator", "initialized", {
"project_root": str(self.project_root),
"claude_dir": str(self.claude_dir),
})
def validate(self) -> UninstallResult:
"""Phase 1: Validate manifest exists and paths are valid.
Returns:
UninstallResult with validation status
"""
audit_log("uninstall_orchestrator", "validate_start", {
"manifest_path": str(self.manifest_path)
})
errors = []
# Check if manifest exists
if not self.manifest_path.exists():
errors.append(f"Install manifest not found: {self.manifest_path}")
audit_log("uninstall_orchestrator", "validate_failure", {
"error": "manifest_not_found"
})
return UninstallResult(
status="failure",
errors=errors,
manifest_found=False
)
# Check for multi-project installations
self._check_multi_project_installations()
audit_log("uninstall_orchestrator", "validate_success", {})
return UninstallResult(
status="success",
manifest_found=True
)
def preview(self) -> UninstallResult:
"""Phase 2: Preview files to be deleted without deleting.
Returns:
UninstallResult with preview information (files_to_remove, total_size_bytes, file_list)
Raises:
ValueError: If security validation fails
"""
audit_log("uninstall_orchestrator", "preview_start", {})
# Validate manifest exists
if not self.manifest_path.exists():
return UninstallResult(
status="failure",
errors=[f"Install manifest not found: {self.manifest_path}"],
manifest_found=False
)
# Load manifest
with open(self.manifest_path, "r") as f:
manifest = json.load(f)
# Get files to remove (this may raise ValueError for security violations)
files_to_remove, total_size, file_list = self._collect_files_to_remove(manifest)
# Store state for TOCTOU detection
self._preview_state = {
"files": {str(f): os.stat(f).st_mtime if f.exists() else None for f in file_list},
"timestamp": datetime.now().isoformat()
}
audit_log("uninstall_orchestrator", "preview_success", {
"files_to_remove": files_to_remove,
"total_size_bytes": total_size
})
return UninstallResult(
status="success",
files_to_remove=files_to_remove,
total_size_bytes=total_size,
file_list=file_list,
manifest_found=True
)
def execute(
self,
force: bool = False,
dry_run: bool = False,
local_only: bool = False
) -> UninstallResult:
"""Phase 3: Execute uninstallation with backup.
Args:
force: If True, execute deletion; if False, return error
dry_run: If True, only preview (same as preview() method)
local_only: If True, skip global ~/.claude/ and ~/.autonomous-dev/
Returns:
UninstallResult with execution status
Raises:
ValueError: If security validation fails
"""
audit_log("uninstall_orchestrator", "execute_start", {
"force": force,
"dry_run": dry_run,
"local_only": local_only
})
# Dry-run mode - just return preview
if dry_run:
result = self.preview()
result.dry_run = True
return result
# Force required for actual deletion
if not force:
audit_log("uninstall_orchestrator", "execute_force_required", {})
return UninstallResult(
status="failure",
errors=["Uninstall requires --force flag for confirmation"]
)
# Validate manifest exists
if not self.manifest_path.exists():
return UninstallResult(
status="failure",
errors=[f"Install manifest not found: {self.manifest_path}"],
manifest_found=False
)
try:
# Load manifest
with open(self.manifest_path, "r") as f:
manifest = json.load(f)
# Get files to remove
files_to_remove, total_size, file_list = self._collect_files_to_remove(
manifest,
local_only=local_only
)
# TOCTOU detection - check if files changed since preview
self._detect_toctou_changes(file_list)
# Create backup
backup_path = self._create_backup(file_list)
# Remove files
files_removed, errors = self._remove_files(file_list)
status = "success" if not errors or files_removed > 0 else "failure"
audit_log("uninstall_orchestrator", "execute_success", {
"files_removed": files_removed,
"backup_path": str(backup_path),
"errors": len(errors)
})
return UninstallResult(
status=status,
files_removed=files_removed,
total_size_bytes=total_size,
backup_path=backup_path,
errors=errors,
manifest_found=True
)
except Exception as e:
audit_log("uninstall_orchestrator", "execute_error", {
"error": str(e)
})
return UninstallResult(
status="failure",
errors=[f"Execution failed: {str(e)}"]
)
def rollback(self, backup_path: Path | str) -> UninstallResult:
"""Rollback uninstallation by restoring from backup.
Args:
backup_path: Path to backup tar.gz file
Returns:
UninstallResult with rollback status
"""
audit_log("uninstall_orchestrator", "rollback_start", {
"backup_path": str(backup_path)
})
backup = Path(backup_path) if isinstance(backup_path, str) else backup_path
# Validate backup exists
if not backup.exists():
return UninstallResult(
status="failure",
errors=[f"Backup file not found: {backup}"]
)
try:
# Extract backup with Zip Slip prevention (CVE-2007-4559)
with tarfile.open(backup, "r:gz") as tar:
# Validate all members before extraction (Zip Slip prevention)
project_root_resolved = self.project_root.resolve()
for member in tar.getmembers():
member_path = (self.project_root / member.name).resolve()
if not str(member_path).startswith(str(project_root_resolved)):
raise ValueError(f"Path traversal detected in archive: {member.name}")
# Safe to extract after validation
tar.extractall(path=self.project_root)
files_restored = len(tar.getmembers())
audit_log("uninstall_orchestrator", "rollback_success", {
"files_restored": files_restored
})
return UninstallResult(
status="success",
files_restored=files_restored
)
except Exception as e:
audit_log("uninstall_orchestrator", "rollback_error", {
"error": str(e)
})
return UninstallResult(
status="failure",
errors=[f"Rollback failed: {str(e)}"]
)
def _collect_files_to_remove(
self,
manifest: Dict[str, Any],
local_only: bool = False
) -> tuple[int, int, List[Path]]:
"""Collect files to remove from manifest.
Args:
manifest: Install manifest dictionary
local_only: If True, skip global directories
Returns:
Tuple of (count, total_size_bytes, file_list)
Raises:
ValueError: If security validation fails
"""
file_list = []
total_size = 0
# Get protected files
protected = self.protected_detector.detect_protected_files(self.project_root)
protected_paths = {Path(self.project_root) / p["path"] for p in protected}
# Process each component
components = manifest.get("components", {})
for component_name, component_data in components.items():
target = component_data.get("target", "")
files = component_data.get("files", [])
# Security: Check for path traversal in target (CWE-22)
if ".." in target or "/./" in target:
raise ValueError(f"Path traversal detected - target not in whitelist: {target}")
# Build target directory
target_dir = self.project_root / target
# Security: Validate target directory is within allowed paths
self._validate_file_path(target_dir)
# Skip global directories if local_only
if local_only and self._is_global_directory(target_dir):
audit_log("uninstall_orchestrator", "skip_global_directory", {
"target": str(target_dir),
"local_only": True
})
continue
for file_rel_path in files:
# Security: Check for path traversal in file path (CWE-22)
if ".." in file_rel_path or "/./" in file_rel_path:
raise ValueError(f"Path traversal detected in file path: {file_rel_path}")
# Extract relative structure from manifest path
# e.g., "plugins/autonomous-dev/commands/helpers/utility.md" -> "helpers/utility.md"
# This preserves nested directory structure
file_rel = Path(file_rel_path)
file_parts = file_rel.parts
# Manifest paths are like "plugins/autonomous-dev/component/..."
# We need everything after the component type (4th part onwards)
if len(file_parts) > 3:
# Has subdirectory structure (e.g., commands/helpers/utility.md)
relative_structure = Path(*file_parts[3:])
file_path = target_dir / relative_structure
else:
# Simple file (e.g., commands/auto-implement.md)
file_path = target_dir / file_rel.name
# Skip if file doesn't exist (partial install)
if not file_path.exists():
continue
# Security: Check for symlinks BEFORE resolving path (CWE-59)
if file_path.is_symlink():
real_path = file_path.resolve()
# Symlink detected - reject it
raise ValueError(f"Symlink detected: {file_path} -> {real_path}")
# Security: Validate path (CWE-22)
# Now safe to validate since we know it's not a symlink
self._validate_file_path(file_path)
# Skip protected files
if file_path in protected_paths:
audit_log("uninstall_orchestrator", "skip_protected_file", {
"file": str(file_path)
})
continue
# Add to list
file_list.append(file_path)
total_size += file_path.stat().st_size
return len(file_list), total_size, file_list
def _validate_file_path(self, file_path: Path) -> None:
"""Validate file path for security.
Args:
file_path: Path to validate
Raises:
ValueError: If path validation fails
"""
# Resolve to absolute path
resolved = file_path.resolve()
# Check path is within allowed directories
allowed = False
for allowed_dir in ALLOWED_DIRECTORIES:
# Check if path contains allowed directory (as a path component)
path_parts = resolved.parts
if allowed_dir in path_parts:
allowed = True
break
if not allowed:
raise ValueError(f"Path not in whitelist: {file_path}")
# Additional check: ensure path is within project_root or global home
project_root_resolved = self.project_root.resolve()
home_dir = Path.home()
within_project = str(resolved).startswith(str(project_root_resolved))
within_home = str(resolved).startswith(str(home_dir))
if not (within_project or within_home):
raise ValueError(f"Path not in whitelist: {file_path}")
def _is_global_directory(self, path: Path) -> bool:
"""Check if path is a global directory (~/.claude/ or ~/.autonomous-dev/).
Args:
path: Path to check
Returns:
True if path is under global directory
"""
resolved = path.resolve()
home_dir = Path.home()
for global_dir in GLOBAL_DIRECTORIES:
global_path = home_dir / global_dir
if str(resolved).startswith(str(global_path)):
return True
return False
def _detect_toctou_changes(self, file_list: List[Path]) -> None:
"""Detect TOCTOU race conditions (CWE-367).
Args:
file_list: List of files to check
"""
if not self._preview_state:
# No preview state to compare against
return
preview_files = self._preview_state.get("files", {})
for file_path in file_list:
file_key = str(file_path)
if file_key not in preview_files:
continue
preview_mtime = preview_files[file_key]
if preview_mtime is None:
continue
if not file_path.exists():
# File was deleted between preview and execute (TOCTOU race condition)
audit_log("uninstall_orchestrator", "TOCTOU_detected_file_deleted", {
"file": file_key
})
continue
current_mtime = os.stat(file_path).st_mtime
if current_mtime != preview_mtime:
# File was modified between preview and execute (TOCTOU race condition)
audit_log("uninstall_orchestrator", "TOCTOU_detected_file_changed", {
"file": file_key,
"preview_mtime": preview_mtime,
"current_mtime": current_mtime
})
def _create_backup(self, file_list: List[Path]) -> Path:
"""Create backup tar.gz of files before deletion.
Args:
file_list: List of files to backup
Returns:
Path to backup tar.gz file
"""
# Create backup directory
backup_dir = self.project_root / ".autonomous-dev"
backup_dir.mkdir(exist_ok=True)
# Generate timestamped backup filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = backup_dir / f"uninstall_backup_{timestamp}.tar.gz"
audit_log("uninstall_orchestrator", "backup_start", {
"backup_path": str(backup_path),
"files": len(file_list)
})
# Create tar.gz backup
with tarfile.open(backup_path, "w:gz") as tar:
for file_path in file_list:
if file_path.exists():
# Add file with relative path from project root
arcname = file_path.relative_to(self.project_root)
tar.add(file_path, arcname=arcname)
audit_log("uninstall_orchestrator", "backup_success", {
"backup_path": str(backup_path)
})
return backup_path
def _remove_files(self, file_list: List[Path]) -> tuple[int, List[str]]:
"""Remove files from filesystem.
Args:
file_list: List of files to remove
Returns:
Tuple of (files_removed_count, errors_list)
"""
files_removed = 0
errors = []
for file_path in file_list:
try:
if file_path.exists():
file_path.unlink()
files_removed += 1
audit_log("uninstall_orchestrator", "file_removed", {
"file": str(file_path)
})
except PermissionError as e:
error_msg = f"Permission denied: {file_path}"
errors.append(error_msg)
audit_log("uninstall_orchestrator", "permission_error", {
"file": str(file_path),
"error": str(e)
})
except Exception as e:
error_msg = f"Error removing {file_path}: {str(e)}"
errors.append(error_msg)
audit_log("uninstall_orchestrator", "removal_error", {
"file": str(file_path),
"error": str(e)
})
return files_removed, errors
def _check_multi_project_installations(self) -> None:
"""Check for multiple project installations and log warning."""
try:
home_dir = Path.home()
# Find .claude directories (limit search to avoid hanging)
claude_dirs = []
# Only check immediate subdirectories to avoid deep recursive search
for search_dir in [home_dir, home_dir / "Documents", home_dir / "projects"]:
if not search_dir.exists():
continue
# Only check one level deep to avoid performance issues
for item in search_dir.iterdir():
if not item.is_dir():
continue
claude_path = item / ".claude"
if claude_path.exists() and claude_path.is_dir():
claude_dirs.append(claude_path)
# Limit to first 10 directories to avoid hanging
if len(claude_dirs) >= 10:
break
if len(claude_dirs) > 1:
audit_log("uninstall_orchestrator", "multi_project_warning", {
"count": len(claude_dirs),
"directories": [str(d) for d in claude_dirs[:5]] # Log first 5
})
except Exception as e:
# Don't fail validation on multi-project detection
audit_log("uninstall_orchestrator", "multi_project_check_error", {
"error": str(e)
})
def uninstall_plugin(
project_root: Path | str,
force: bool = False,
dry_run: bool = False,
local_only: bool = False
) -> UninstallResult:
"""Standalone function to uninstall autonomous-dev plugin.
This is a convenience wrapper around UninstallOrchestrator for simple usage.
Args:
project_root: Root directory of project to uninstall from
force: If True, execute deletion; if False, show preview only
dry_run: If True, only preview (overrides force)
local_only: If True, skip global ~/.claude/ and ~/.autonomous-dev/
Returns:
UninstallResult with operation status
Examples:
>>> # Preview uninstall
>>> result = uninstall_plugin("/path/to/project", dry_run=True)
>>> print(f"Would remove {result.files_to_remove} files")
>>>
>>> # Execute uninstall
>>> result = uninstall_plugin("/path/to/project", force=True)
>>> if result.status == "success":
... print(f"Backup: {result.backup_path}")
"""
orchestrator = UninstallOrchestrator(project_root)
# Validate first
validation = orchestrator.validate()
if validation.status != "success":
return validation
# Execute with requested mode
return orchestrator.execute(force=force, dry_run=dry_run, local_only=local_only)
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python uninstall_orchestrator.py <project_root> [--force] [--dry-run] [--local-only]")
sys.exit(1)
project_root = sys.argv[1]
force = "--force" in sys.argv
dry_run = "--dry-run" in sys.argv
local_only = "--local-only" in sys.argv
result = uninstall_plugin(project_root, force=force, dry_run=dry_run, local_only=local_only)
print(json.dumps(result.to_dict(), indent=2))
sys.exit(0 if result.status == "success" else 1)