1359 lines
51 KiB
Python
1359 lines
51 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Plugin Updater - Interactive plugin update with version detection, backup, and rollback
|
|
|
|
This module provides interactive plugin update functionality with:
|
|
- Version detection (check for updates)
|
|
- Automatic backup before update
|
|
- Rollback on failure
|
|
- Verification after update
|
|
- Security: Path validation and audit logging
|
|
|
|
Features:
|
|
- Check for plugin updates (dry-run mode)
|
|
- Create automatic backups with timestamps
|
|
- Update via sync_dispatcher.sync_marketplace()
|
|
- Verify update success (version + file validation)
|
|
- Rollback to backup on failure
|
|
- Cleanup backups after successful update
|
|
- Interactive confirmation prompts
|
|
- Rich result objects with detailed info
|
|
|
|
Security:
|
|
- All file paths validated via security_utils.validate_path()
|
|
- Prevents path traversal (CWE-22)
|
|
- Rejects symlink attacks (CWE-59)
|
|
- Backup permissions: user-only (0o700) - CWE-732
|
|
- Audit logging for all operations (CWE-778)
|
|
|
|
Usage:
|
|
from plugin_updater import PluginUpdater
|
|
|
|
# Interactive update
|
|
updater = PluginUpdater(project_root="/path/to/project")
|
|
result = updater.update()
|
|
print(result.summary)
|
|
|
|
# Check for updates only
|
|
comparison = updater.check_for_updates()
|
|
if comparison.is_upgrade:
|
|
print(f"Update available: {comparison.marketplace_version}")
|
|
|
|
Date: 2025-11-09
|
|
Issue: GitHub #50 Phase 2 - Interactive /update-plugin command
|
|
Agent: implementer
|
|
|
|
|
|
Design Patterns:
|
|
See library-design-patterns skill for standardized design patterns.
|
|
See state-management-patterns skill for standardized design patterns.
|
|
"""
|
|
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
# Import with fallback for both dev (plugins/) and installed (.claude/lib/) environments
|
|
try:
|
|
# Development environment
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
|
from plugins.autonomous_dev.lib import security_utils
|
|
from plugins.autonomous_dev.lib.version_detector import (
|
|
detect_version_mismatch,
|
|
VersionComparison,
|
|
)
|
|
from plugins.autonomous_dev.lib.sync_dispatcher import (
|
|
sync_marketplace,
|
|
SyncResult,
|
|
)
|
|
from plugins.autonomous_dev.lib.hook_activator import (
|
|
HookActivator,
|
|
ActivationResult,
|
|
ActivationError,
|
|
)
|
|
from plugins.autonomous_dev.lib.settings_generator import (
|
|
validate_permission_patterns,
|
|
fix_permission_patterns,
|
|
PermissionIssue,
|
|
)
|
|
except ImportError:
|
|
# Installed environment (.claude/lib/)
|
|
import security_utils
|
|
from version_detector import (
|
|
detect_version_mismatch,
|
|
VersionComparison,
|
|
)
|
|
from sync_dispatcher import (
|
|
sync_marketplace,
|
|
)
|
|
from hook_activator import (
|
|
HookActivator,
|
|
ActivationResult,
|
|
)
|
|
from settings_generator import (
|
|
validate_permission_patterns,
|
|
fix_permission_patterns,
|
|
)
|
|
|
|
|
|
# Exception hierarchy pattern from error-handling-patterns skill:
|
|
# BaseException -> Exception -> AutonomousDevError -> DomainError(BaseException) -> SpecificError
|
|
class UpdateError(Exception):
|
|
"""Base exception for plugin update errors.
|
|
|
|
See error-handling-patterns skill for exception hierarchy and error handling best practices.
|
|
"""
|
|
pass
|
|
|
|
|
|
class BackupError(UpdateError):
|
|
"""Exception raised when backup creation or restoration fails."""
|
|
pass
|
|
|
|
|
|
class VerificationError(UpdateError):
|
|
"""Exception raised when update verification fails."""
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class PermissionFixResult:
|
|
"""Result of permission validation/fix operation.
|
|
|
|
Attributes:
|
|
success: Whether fix succeeded (or was skipped)
|
|
action: Action taken (skipped, validated, fixed, regenerated, failed)
|
|
issues_found: Count of detected permission issues (integer)
|
|
fixes_applied: List of fixes that were applied
|
|
backup_path: Path to backup file (None if no backup created)
|
|
message: Human-readable result message
|
|
"""
|
|
success: bool
|
|
action: str
|
|
issues_found: int = 0
|
|
fixes_applied: List[str] = field(default_factory=list)
|
|
backup_path: Optional[Path] = None
|
|
message: str = ""
|
|
|
|
|
|
@dataclass
|
|
class UpdateResult:
|
|
"""Result of a plugin update operation.
|
|
|
|
Attributes:
|
|
success: Whether update succeeded (True) or failed (False)
|
|
updated: Whether update was performed (False if already up-to-date)
|
|
message: Human-readable result message
|
|
old_version: Plugin version before update (or current if no update)
|
|
new_version: Plugin version after update (or current if no update)
|
|
backup_path: Path to backup directory (None if no backup created)
|
|
rollback_performed: Whether rollback was performed after failure
|
|
hooks_activated: Whether hooks were activated after update (default: False)
|
|
permission_fix_result: Result of permission validation/fixing (None if not performed)
|
|
details: Additional result details (files updated, errors, etc.)
|
|
"""
|
|
|
|
success: bool
|
|
updated: bool
|
|
message: str
|
|
old_version: Optional[str] = None
|
|
new_version: Optional[str] = None
|
|
backup_path: Optional[Path] = None
|
|
rollback_performed: bool = False
|
|
hooks_activated: bool = False
|
|
permission_fix_result: Optional['PermissionFixResult'] = None
|
|
details: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
@property
|
|
def summary(self) -> str:
|
|
"""Generate comprehensive summary of update result.
|
|
|
|
Returns:
|
|
Human-readable summary with version and status info
|
|
"""
|
|
parts = [self.message]
|
|
|
|
# Add version information
|
|
if self.old_version and self.new_version:
|
|
if self.updated:
|
|
parts.append(f"Version: {self.old_version} → {self.new_version}")
|
|
else:
|
|
parts.append(f"Version: {self.old_version}")
|
|
|
|
# Add backup info
|
|
if self.backup_path:
|
|
parts.append(f"Backup: {self.backup_path}")
|
|
|
|
# Add rollback info
|
|
if self.rollback_performed:
|
|
parts.append("Rollback: Performed (restored from backup)")
|
|
|
|
# Add hook activation status
|
|
if self.hooks_activated:
|
|
parts.append("Hooks: Activated")
|
|
|
|
# Add details
|
|
if self.details:
|
|
for key, value in self.details.items():
|
|
parts.append(f"{key}: {value}")
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
class PluginUpdater:
|
|
"""Plugin updater with version detection, backup, and rollback.
|
|
|
|
This class provides complete plugin update workflow:
|
|
1. Check for updates (version comparison)
|
|
2. Create automatic backup
|
|
3. Perform update via sync_dispatcher
|
|
4. Verify update success
|
|
5. Rollback on failure
|
|
6. Cleanup backup on success
|
|
|
|
All file operations are security-validated and audit-logged.
|
|
|
|
Example:
|
|
>>> updater = PluginUpdater(project_root="/path/to/project")
|
|
>>> result = updater.update()
|
|
>>> if result.success:
|
|
... print(f"Updated to {result.new_version}")
|
|
>>> else:
|
|
... print(f"Update failed: {result.message}")
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
project_root: Path,
|
|
plugin_name: str = "autonomous-dev",
|
|
):
|
|
"""Initialize PluginUpdater with security validation.
|
|
|
|
Args:
|
|
project_root: Path to project root directory
|
|
plugin_name: Name of plugin to update (default: autonomous-dev)
|
|
|
|
Raises:
|
|
UpdateError: If project_root is invalid or doesn't exist
|
|
"""
|
|
# Validate project_root path
|
|
try:
|
|
validated_path = security_utils.validate_path(str(project_root), "project root")
|
|
self.project_root = Path(validated_path)
|
|
except ValueError as e:
|
|
raise UpdateError(f"Invalid project path: {e}")
|
|
|
|
# Check if path exists
|
|
if not self.project_root.exists():
|
|
raise UpdateError(f"Project path does not exist: {self.project_root}")
|
|
|
|
# Check for .claude directory
|
|
claude_dir = self.project_root / ".claude"
|
|
if not claude_dir.exists():
|
|
raise UpdateError(
|
|
f"Not a valid Claude project: .claude directory not found at {self.project_root}"
|
|
)
|
|
|
|
# Validate plugin_name (CWE-78: OS Command Injection prevention)
|
|
# Step 1: Length validation via security_utils
|
|
try:
|
|
validated_name = security_utils.validate_input_length(
|
|
value=plugin_name,
|
|
max_length=100,
|
|
field_name="plugin_name",
|
|
purpose="plugin update"
|
|
)
|
|
except ValueError as e:
|
|
raise UpdateError(f"Invalid plugin name: {e}")
|
|
|
|
# Step 2: Format validation (alphanumeric, dash, underscore only)
|
|
import re
|
|
if not re.match(r'^[a-zA-Z0-9_-]+$', validated_name):
|
|
raise UpdateError(
|
|
f"Invalid plugin name: {validated_name}\n"
|
|
f"Plugin names must contain only alphanumeric characters, dashes, and underscores.\n"
|
|
f"Examples: 'autonomous-dev', 'my_plugin', 'plugin123'"
|
|
)
|
|
|
|
self.plugin_name = validated_name
|
|
self.plugin_dir = claude_dir / "plugins" / validated_name
|
|
self.verbose = False # Default to non-verbose mode
|
|
|
|
# Validate plugin directory path (CWE-22: Path Traversal prevention)
|
|
# Ensures marketplace plugin directory is within project bounds
|
|
try:
|
|
validated_plugin_dir = security_utils.validate_path(
|
|
str(self.plugin_dir),
|
|
"plugin directory"
|
|
)
|
|
self.plugin_dir = Path(validated_plugin_dir)
|
|
except ValueError as e:
|
|
raise UpdateError(
|
|
f"Invalid plugin directory path: {e}\n"
|
|
f"Plugin directory must be within project .claude/plugins/ directory"
|
|
)
|
|
|
|
# Audit log initialization
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"initialized",
|
|
{
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": plugin_name,
|
|
},
|
|
)
|
|
|
|
def check_for_updates(self) -> VersionComparison:
|
|
"""Check for plugin updates by comparing versions.
|
|
|
|
Uses version_detector.detect_version_mismatch() to compare
|
|
project plugin version vs marketplace plugin version.
|
|
|
|
Returns:
|
|
VersionComparison object with upgrade/downgrade status
|
|
|
|
Raises:
|
|
UpdateError: If version detection fails
|
|
"""
|
|
try:
|
|
# Use version_detector to compare versions
|
|
comparison = detect_version_mismatch(
|
|
project_root=str(self.project_root),
|
|
plugin_name=self.plugin_name,
|
|
)
|
|
|
|
# Audit log the check
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"check_for_updates",
|
|
{
|
|
"event": "check_for_updates",
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
"status": comparison.status,
|
|
"project_version": comparison.project_version,
|
|
"marketplace_version": comparison.marketplace_version,
|
|
}
|
|
)
|
|
|
|
return comparison
|
|
|
|
except Exception as e:
|
|
# Audit log the error
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"check_for_updates_error",
|
|
{
|
|
"event": "check_for_updates_error",
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
"error": str(e),
|
|
}
|
|
)
|
|
raise UpdateError(f"Failed to check for updates: {e}")
|
|
|
|
def update(
|
|
self,
|
|
auto_backup: bool = True,
|
|
skip_confirm: bool = False,
|
|
activate_hooks: bool = True,
|
|
) -> UpdateResult:
|
|
"""Perform plugin update with backup and rollback.
|
|
|
|
Complete update workflow:
|
|
1. Pre-install cleanup (remove .claude/lib/ duplicates)
|
|
2. Check for updates (version comparison)
|
|
3. Skip if already up-to-date
|
|
4. Create backup (if auto_backup=True)
|
|
5. Perform sync via sync_dispatcher
|
|
6. Verify update success
|
|
7. Validate and fix permissions (non-blocking)
|
|
8. Sync lib files to ~/.claude/lib/ (non-blocking)
|
|
9. Activate hooks (if activate_hooks=True and sync successful)
|
|
10. Rollback on failure
|
|
11. Cleanup backup on success
|
|
|
|
Args:
|
|
auto_backup: Whether to create backup before update (default: True)
|
|
skip_confirm: Skip confirmation prompts (default: False)
|
|
activate_hooks: Whether to activate hooks after update (default: True)
|
|
|
|
Returns:
|
|
UpdateResult with success status and details
|
|
|
|
Example:
|
|
>>> updater = PluginUpdater("/path/to/project")
|
|
>>> result = updater.update()
|
|
>>> print(result.summary)
|
|
"""
|
|
from plugins.autonomous_dev.lib.orphan_file_cleaner import OrphanFileCleaner
|
|
|
|
backup_path = None
|
|
old_version = None
|
|
new_version = None
|
|
|
|
try:
|
|
# Step 1: Pre-install cleanup (remove duplicate libraries)
|
|
cleaner = OrphanFileCleaner(project_root=self.project_root)
|
|
cleanup_result = cleaner.pre_install_cleanup()
|
|
|
|
if not cleanup_result.success:
|
|
# Log warning but continue update
|
|
audit_log(
|
|
"plugin_updater",
|
|
"cleanup_warning",
|
|
{
|
|
"operation": "update",
|
|
"cleanup_error": cleanup_result.error_message,
|
|
},
|
|
)
|
|
|
|
# Step 2: Check for updates
|
|
comparison = self.check_for_updates()
|
|
old_version = comparison.project_version
|
|
expected_version = comparison.marketplace_version
|
|
|
|
# Step 3: Skip if already up-to-date
|
|
if comparison.status == VersionComparison.UP_TO_DATE:
|
|
return UpdateResult(
|
|
success=True,
|
|
updated=False,
|
|
message="Plugin is already up to date",
|
|
old_version=old_version,
|
|
new_version=old_version,
|
|
backup_path=None,
|
|
rollback_performed=False,
|
|
details={},
|
|
)
|
|
|
|
# Step 4: Create backup (if enabled)
|
|
if auto_backup:
|
|
backup_path = self._create_backup()
|
|
|
|
# Step 5: Perform sync via sync_dispatcher
|
|
# Find marketplace plugins file
|
|
marketplace_file = Path.home() / ".claude" / "plugins" / "installed_plugins.json"
|
|
|
|
# Validate marketplace file (CWE-22: Path Traversal prevention)
|
|
# Note: This is a global Claude file, not project-specific, so we use manual validation
|
|
# instead of validate_path() which enforces project-root whitelist
|
|
|
|
# Check 1: Must be in user's home directory (not root or system dirs)
|
|
if not str(marketplace_file.resolve()).startswith(str(Path.home().resolve())):
|
|
raise UpdateError(
|
|
f"Invalid marketplace file: must be in user home directory\n"
|
|
f"Path: {marketplace_file}\n"
|
|
f"Expected: ~/.claude/plugins/installed_plugins.json"
|
|
)
|
|
|
|
# Check 2: Reject symlinks (defense in depth)
|
|
if marketplace_file.is_symlink():
|
|
raise UpdateError(
|
|
f"Invalid marketplace file: symlink detected (potential attack)\n"
|
|
f"Path: {marketplace_file}\n"
|
|
f"Target: {marketplace_file.resolve()}"
|
|
)
|
|
|
|
# Use sync_marketplace for the update
|
|
sync_result = sync_marketplace(
|
|
project_root=str(self.project_root),
|
|
marketplace_plugins_file=marketplace_file,
|
|
cleanup_orphans=False,
|
|
dry_run=False,
|
|
)
|
|
|
|
if not sync_result.success:
|
|
# Sync failed - rollback if backup exists
|
|
if backup_path:
|
|
self._rollback(backup_path)
|
|
return UpdateResult(
|
|
success=False,
|
|
updated=False,
|
|
message=f"Update failed: {sync_result.message}",
|
|
old_version=old_version,
|
|
new_version=old_version,
|
|
backup_path=backup_path,
|
|
rollback_performed=True,
|
|
details={"error": sync_result.error or sync_result.message},
|
|
)
|
|
else:
|
|
return UpdateResult(
|
|
success=False,
|
|
updated=False,
|
|
message=f"Update failed: {sync_result.message}",
|
|
old_version=old_version,
|
|
new_version=old_version,
|
|
backup_path=None,
|
|
rollback_performed=False,
|
|
details={"error": sync_result.error or sync_result.message},
|
|
)
|
|
|
|
# Step 5: Verify update success
|
|
try:
|
|
self._verify_update(expected_version)
|
|
new_version = expected_version
|
|
except VerificationError as e:
|
|
# Verification failed - rollback
|
|
if backup_path:
|
|
self._rollback(backup_path)
|
|
return UpdateResult(
|
|
success=False,
|
|
updated=False,
|
|
message=f"Update verification failed: {e}",
|
|
old_version=old_version,
|
|
new_version=old_version,
|
|
backup_path=backup_path,
|
|
rollback_performed=True,
|
|
details={"error": str(e)},
|
|
)
|
|
else:
|
|
return UpdateResult(
|
|
success=False,
|
|
updated=False,
|
|
message=f"Update verification failed: {e}",
|
|
old_version=old_version,
|
|
new_version=old_version,
|
|
backup_path=None,
|
|
rollback_performed=False,
|
|
details={"error": str(e)},
|
|
)
|
|
|
|
# Step 5.5: Validate and fix permissions (non-blocking)
|
|
permission_fix_result = None
|
|
try:
|
|
permission_fix_result = self._validate_and_fix_permissions()
|
|
# Log result but don't fail update
|
|
if permission_fix_result.action in ["fixed", "regenerated"]:
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"permission_fix",
|
|
{
|
|
"event": "permission_fix",
|
|
"action": permission_fix_result.action,
|
|
"issues_found": len(permission_fix_result.issues_found),
|
|
"fixes_applied": permission_fix_result.fixes_applied,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
# Log but don't fail update
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"permission_fix_failed",
|
|
{
|
|
"event": "permission_fix_failed",
|
|
"error": str(e),
|
|
}
|
|
)
|
|
permission_fix_result = PermissionFixResult(
|
|
success=False,
|
|
action="failed",
|
|
issues_found=0,
|
|
message=f"Permission validation failed: {e}"
|
|
)
|
|
|
|
# Step 5.6: Sync lib files to ~/.claude/lib/ (non-blocking)
|
|
lib_files_synced = 0
|
|
try:
|
|
lib_files_synced = self._sync_lib_files()
|
|
except Exception as e:
|
|
# Log but don't fail update
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"lib_sync_exception",
|
|
{
|
|
"event": "lib_sync_exception",
|
|
"error": str(e),
|
|
}
|
|
)
|
|
print(f"Warning: Lib file sync encountered error: {e}")
|
|
|
|
# Step 6: Activate hooks (non-blocking, after successful sync)
|
|
hooks_activated = False
|
|
if activate_hooks:
|
|
activation_result = self._activate_hooks()
|
|
hooks_activated = activation_result.activated
|
|
|
|
# Step 7: Cleanup backup on success
|
|
if backup_path:
|
|
self._cleanup_backup(backup_path)
|
|
|
|
# Success!
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"update_success",
|
|
{
|
|
"event": "update_success",
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
"old_version": old_version,
|
|
"new_version": new_version,
|
|
"hooks_activated": hooks_activated,
|
|
"lib_files_synced": lib_files_synced,
|
|
}
|
|
)
|
|
|
|
# Merge sync_result.details with lib_files_synced
|
|
result_details = dict(sync_result.details)
|
|
result_details["lib_files_synced"] = lib_files_synced
|
|
|
|
return UpdateResult(
|
|
success=True,
|
|
updated=True,
|
|
message=f"Plugin updated successfully to {new_version}",
|
|
old_version=old_version,
|
|
new_version=new_version,
|
|
backup_path=backup_path,
|
|
rollback_performed=False,
|
|
hooks_activated=hooks_activated,
|
|
permission_fix_result=permission_fix_result,
|
|
details=result_details,
|
|
)
|
|
|
|
except Exception as e:
|
|
# Unexpected error during update - attempt automatic rollback if backup exists
|
|
# This provides defense in depth: even if sync fails unexpectedly, we can recover
|
|
if backup_path:
|
|
try:
|
|
self._rollback(backup_path)
|
|
rollback_performed = True
|
|
except Exception as rollback_error:
|
|
# Rollback failed too - critical error (data loss risk)
|
|
# Log both original error and rollback error for debugging
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"rollback_failed",
|
|
{
|
|
"event": "rollback_failed",
|
|
"project_root": str(self.project_root),
|
|
"error": str(e),
|
|
"rollback_error": str(rollback_error),
|
|
}
|
|
)
|
|
rollback_performed = False
|
|
else:
|
|
rollback_performed = False
|
|
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"update_error",
|
|
{
|
|
"event": "update_error",
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
"error": str(e),
|
|
"rollback_performed": rollback_performed,
|
|
}
|
|
)
|
|
|
|
return UpdateResult(
|
|
success=False,
|
|
updated=False,
|
|
message=f"Update failed: {e}",
|
|
old_version=old_version,
|
|
new_version=old_version,
|
|
backup_path=backup_path,
|
|
rollback_performed=rollback_performed,
|
|
details={"error": str(e)},
|
|
)
|
|
|
|
def _activate_hooks(self) -> ActivationResult:
|
|
"""Activate hooks after successful update (non-blocking).
|
|
|
|
This method is non-blocking: hook activation failures do NOT fail the update.
|
|
Activation errors are logged but the update still succeeds.
|
|
|
|
Returns:
|
|
ActivationResult with activation status and details
|
|
|
|
Note:
|
|
This method never raises exceptions - all errors are caught and logged.
|
|
"""
|
|
try:
|
|
# Create HookActivator
|
|
activator = HookActivator(project_root=self.project_root)
|
|
|
|
# Define default hooks for autonomous-dev plugin
|
|
# These are the core hooks that should be activated by default
|
|
default_hooks = {
|
|
"hooks": {
|
|
"UserPromptSubmit": [
|
|
"display_project_context.py",
|
|
"enforce_command_limit.py",
|
|
],
|
|
"SubagentStop": [
|
|
"log_agent_completion.py",
|
|
"auto_update_project_progress.py",
|
|
],
|
|
"PrePush": [
|
|
"auto_test.py",
|
|
],
|
|
}
|
|
}
|
|
|
|
# Activate hooks
|
|
result = activator.activate_hooks(default_hooks)
|
|
|
|
# Audit log activation result
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"hook_activation_complete",
|
|
{
|
|
"event": "hook_activation_complete",
|
|
"project_root": str(self.project_root),
|
|
"activated": result.activated,
|
|
"hooks_added": result.hooks_added,
|
|
},
|
|
)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
# Non-blocking: log error but don't fail update
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"hook_activation_error",
|
|
{
|
|
"event": "hook_activation_error",
|
|
"project_root": str(self.project_root),
|
|
"error": str(e),
|
|
},
|
|
)
|
|
|
|
# Return failure result (but update still succeeds)
|
|
return ActivationResult(
|
|
activated=False,
|
|
first_install=False,
|
|
message=f"Hook activation failed: {e}",
|
|
hooks_added=0,
|
|
settings_path=None,
|
|
details={"error": str(e)},
|
|
)
|
|
|
|
def _sync_lib_files(self) -> int:
|
|
"""Sync lib files from plugin to ~/.claude/lib/ (non-blocking).
|
|
|
|
This method copies required library files from the plugin's lib directory
|
|
to the global ~/.claude/lib/ directory where hooks can import them.
|
|
|
|
Workflow:
|
|
1. Read installation_manifest.json to get lib directory
|
|
2. Create ~/.claude/lib/ if it doesn't exist
|
|
3. Copy each .py file from plugin/lib/ to ~/.claude/lib/
|
|
4. Validate all paths for security (CWE-22, CWE-59)
|
|
5. Audit log all operations
|
|
6. Handle errors gracefully (non-blocking)
|
|
|
|
Returns:
|
|
Number of lib files successfully synced (0 on complete failure)
|
|
|
|
Note:
|
|
This method is non-blocking - errors are logged but don't fail update.
|
|
Missing manifest or source files are handled gracefully.
|
|
|
|
Security:
|
|
- All paths validated via security_utils.validate_path()
|
|
- Prevents path traversal (CWE-22)
|
|
- Rejects symlinks (CWE-59)
|
|
- Operations audit-logged (CWE-778)
|
|
"""
|
|
try:
|
|
# Step 1: Read manifest to verify lib directory should be synced
|
|
manifest_path = self.plugin_dir / "config" / "installation_manifest.json"
|
|
|
|
if not manifest_path.exists():
|
|
# Manifest missing - graceful degradation
|
|
print(f"Warning: installation_manifest.json not found, syncing all .py files from lib/")
|
|
# Continue anyway - copy all .py files from lib/
|
|
else:
|
|
# Validate manifest includes lib directory
|
|
try:
|
|
manifest_data = json.loads(manifest_path.read_text())
|
|
include_dirs = manifest_data.get("include_directories", [])
|
|
|
|
if "lib" not in include_dirs:
|
|
# Lib not in manifest - skip sync
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"lib_sync_skipped",
|
|
{
|
|
"event": "lib_sync_skipped",
|
|
"reason": "lib not in manifest include_directories",
|
|
"project_root": str(self.project_root),
|
|
}
|
|
)
|
|
return 0
|
|
except (json.JSONDecodeError, KeyError) as e:
|
|
# Manifest malformed - log warning but continue
|
|
print(f"Warning: Failed to parse manifest: {e}")
|
|
# Continue with sync anyway
|
|
|
|
# Step 2: Create target directory ~/.claude/lib/
|
|
target_dir = Path.home() / ".claude" / "lib"
|
|
|
|
# Security: Validate target path is in user home
|
|
if not str(target_dir.resolve()).startswith(str(Path.home().resolve())):
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"lib_sync_blocked",
|
|
{
|
|
"event": "lib_sync_blocked",
|
|
"reason": "target path outside user home",
|
|
"target_path": str(target_dir),
|
|
}
|
|
)
|
|
return 0
|
|
|
|
# Create directory if doesn't exist
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Step 3: Copy lib files from plugin to global location
|
|
source_dir = self.plugin_dir / "lib"
|
|
|
|
if not source_dir.exists():
|
|
# Source lib directory missing - log and return
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"lib_sync_skipped",
|
|
{
|
|
"event": "lib_sync_skipped",
|
|
"reason": "source lib directory not found",
|
|
"source_path": str(source_dir),
|
|
"project_root": str(self.project_root),
|
|
}
|
|
)
|
|
return 0
|
|
|
|
# Get all .py files from source lib directory
|
|
lib_files = list(source_dir.glob("*.py"))
|
|
|
|
if not lib_files:
|
|
# No lib files to sync
|
|
print("Info: No .py files found in plugin lib directory")
|
|
return 0
|
|
|
|
# Copy each file
|
|
files_synced = 0
|
|
files_failed = 0
|
|
|
|
for source_file in lib_files:
|
|
try:
|
|
# Skip __init__.py (not needed in global lib)
|
|
if source_file.name == "__init__.py":
|
|
continue
|
|
|
|
# Security: Validate source path
|
|
# Use manual validation since validate_path() enforces project-root whitelist
|
|
# and ~/.claude/lib/ is a global directory
|
|
if source_file.is_symlink():
|
|
print(f"Warning: Skipping symlink: {source_file.name}")
|
|
files_failed += 1
|
|
continue
|
|
|
|
# Validate file is actually in plugin lib directory (prevent traversal)
|
|
if not str(source_file.resolve()).startswith(str(source_dir.resolve())):
|
|
print(f"Warning: Skipping file outside lib directory: {source_file.name}")
|
|
files_failed += 1
|
|
continue
|
|
|
|
# Define target path
|
|
target_file = target_dir / source_file.name
|
|
|
|
# Security: Validate target path
|
|
if target_file.is_symlink():
|
|
print(f"Warning: Skipping existing symlink: {target_file.name}")
|
|
files_failed += 1
|
|
continue
|
|
|
|
# Copy file (overwrites existing)
|
|
shutil.copy2(source_file, target_file)
|
|
files_synced += 1
|
|
|
|
if self.verbose:
|
|
print(f" Synced: {source_file.name} → ~/.claude/lib/")
|
|
|
|
except (PermissionError, OSError) as e:
|
|
# File copy failed - log and continue with next file
|
|
print(f"Warning: Failed to sync {source_file.name}: {e}")
|
|
files_failed += 1
|
|
continue
|
|
|
|
# Step 4: Audit log sync result
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"lib_sync_complete",
|
|
{
|
|
"event": "lib_sync_complete",
|
|
"project_root": str(self.project_root),
|
|
"files_synced": files_synced,
|
|
"files_failed": files_failed,
|
|
"target_dir": str(target_dir),
|
|
}
|
|
)
|
|
|
|
if files_synced > 0:
|
|
print(f"Synced {files_synced} lib file(s) to ~/.claude/lib/")
|
|
|
|
if files_failed > 0:
|
|
print(f"Warning: {files_failed} lib file(s) failed to sync")
|
|
|
|
return files_synced
|
|
|
|
except Exception as e:
|
|
# Non-blocking: log error but don't fail update
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"lib_sync_error",
|
|
{
|
|
"event": "lib_sync_error",
|
|
"project_root": str(self.project_root),
|
|
"error": str(e),
|
|
}
|
|
)
|
|
print(f"Warning: Lib file sync failed: {e}")
|
|
return 0
|
|
|
|
def _validate_and_fix_permissions(self) -> PermissionFixResult:
|
|
"""Validate and fix settings.local.json permissions (non-blocking).
|
|
|
|
Workflow:
|
|
1. Check if settings.local.json exists (skip if not)
|
|
2. Load and validate permissions
|
|
3. If issues found:
|
|
a. Backup existing file
|
|
b. Generate template with correct patterns
|
|
c. Fix using fix_permission_patterns()
|
|
d. Write fixed settings atomically
|
|
4. Return result
|
|
|
|
Returns:
|
|
PermissionFixResult with action, issues, and fixes
|
|
|
|
Note:
|
|
This method is non-blocking - exceptions are caught and returned
|
|
as failed results. Update can succeed even if permission fix fails.
|
|
"""
|
|
settings_path = self.project_root / ".claude" / "settings.local.json"
|
|
|
|
# Step 1: Check if settings.local.json exists
|
|
if not settings_path.exists():
|
|
return PermissionFixResult(
|
|
success=True,
|
|
action="skipped",
|
|
issues_found=0,
|
|
fixes_applied=[],
|
|
backup_path=None,
|
|
message="No settings.local.json found - skipping validation"
|
|
)
|
|
|
|
try:
|
|
# Step 2: Load and validate permissions
|
|
try:
|
|
settings_content = settings_path.read_text()
|
|
settings = json.loads(settings_content)
|
|
except json.JSONDecodeError as e:
|
|
# Corrupted JSON - backup and try to regenerate
|
|
backup_path = self._backup_settings_file(settings_path)
|
|
|
|
try:
|
|
# Try to generate fresh settings from template
|
|
from plugins.autonomous_dev.lib.settings_generator import (
|
|
SettingsGenerator,
|
|
SAFE_COMMAND_PATTERNS,
|
|
DEFAULT_DENY_LIST,
|
|
)
|
|
|
|
plugin_dir = self.project_root / "plugins" / self.plugin_name
|
|
if plugin_dir.exists():
|
|
# Full regeneration from template
|
|
generator = SettingsGenerator(plugin_dir)
|
|
gen_result = generator.write_settings(settings_path, merge_existing=False)
|
|
|
|
if gen_result.success:
|
|
return PermissionFixResult(
|
|
success=True,
|
|
action="regenerated",
|
|
issues_found=1, # One issue: corrupted JSON
|
|
fixes_applied=["Regenerated settings from template"],
|
|
backup_path=backup_path,
|
|
message="Corrupted settings.local.json regenerated from template"
|
|
)
|
|
else:
|
|
# Plugin directory doesn't exist - create minimal valid settings
|
|
minimal_settings = {
|
|
"version": "1.0.0",
|
|
"permissions": {
|
|
"allow": SAFE_COMMAND_PATTERNS.copy(),
|
|
"deny": DEFAULT_DENY_LIST.copy()
|
|
}
|
|
}
|
|
settings_path.write_text(json.dumps(minimal_settings, indent=2))
|
|
|
|
return PermissionFixResult(
|
|
success=True,
|
|
action="regenerated",
|
|
issues_found=1, # One issue: corrupted JSON
|
|
fixes_applied=["Created minimal valid settings"],
|
|
backup_path=backup_path,
|
|
message="Corrupted JSON - created minimal valid settings"
|
|
)
|
|
|
|
except Exception as regen_error:
|
|
# Regeneration failed - return with backup info
|
|
return PermissionFixResult(
|
|
success=False,
|
|
action="failed",
|
|
issues_found=1, # One issue: corrupted JSON
|
|
fixes_applied=[],
|
|
backup_path=backup_path,
|
|
message=f"Corrupted JSON - backed up but regeneration failed: {regen_error}"
|
|
)
|
|
|
|
# Validate permissions
|
|
validation_result = validate_permission_patterns(settings)
|
|
|
|
# Step 3a: If no issues, return validated
|
|
if validation_result.valid:
|
|
return PermissionFixResult(
|
|
success=True,
|
|
action="validated",
|
|
issues_found=0,
|
|
fixes_applied=[],
|
|
backup_path=None,
|
|
message="Settings permissions already valid - no issues found"
|
|
)
|
|
|
|
# Step 3b: Issues found - backup and fix
|
|
backup_path = self._backup_settings_file(settings_path)
|
|
|
|
# Step 3c: Fix patterns
|
|
fixed_settings = fix_permission_patterns(settings)
|
|
|
|
# Step 3d: Write fixed settings atomically
|
|
settings_path.write_text(json.dumps(fixed_settings, indent=2))
|
|
|
|
# Build fixes_applied list
|
|
fixes_applied = []
|
|
if any("wildcard" in i.issue_type for i in validation_result.issues):
|
|
fixes_applied.append("Replaced wildcard patterns with specific commands")
|
|
if any("deny" in i.issue_type for i in validation_result.issues):
|
|
fixes_applied.append("Added comprehensive deny list")
|
|
|
|
return PermissionFixResult(
|
|
success=True,
|
|
action="fixed",
|
|
issues_found=len(validation_result.issues),
|
|
fixes_applied=fixes_applied,
|
|
backup_path=backup_path,
|
|
message=f"Fixed {len(validation_result.issues)} permission issue(s)"
|
|
)
|
|
|
|
except Exception as e:
|
|
# Non-blocking - return failure but don't raise
|
|
return PermissionFixResult(
|
|
success=False,
|
|
action="failed",
|
|
issues_found=0,
|
|
fixes_applied=[],
|
|
backup_path=None,
|
|
message=f"Permission validation failed: {e}"
|
|
)
|
|
|
|
def _backup_settings_file(self, settings_path: Path) -> Path:
|
|
"""Create timestamped backup of settings.local.json.
|
|
|
|
Args:
|
|
settings_path: Path to settings.local.json
|
|
|
|
Returns:
|
|
Path to backup file
|
|
"""
|
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") # Include microseconds
|
|
backup_dir = self.project_root / ".claude" / "backups"
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
backup_path = backup_dir / f"settings.local.json.backup-{timestamp}"
|
|
shutil.copy2(settings_path, backup_path)
|
|
|
|
# Audit log
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"settings_backup",
|
|
{
|
|
"event": "settings_backup",
|
|
"source": str(settings_path),
|
|
"backup": str(backup_path),
|
|
}
|
|
)
|
|
|
|
return backup_path
|
|
|
|
def _create_backup(self) -> Path:
|
|
"""Create timestamped backup of plugin directory.
|
|
|
|
Creates backup in temp directory with format:
|
|
/tmp/autonomous-dev-backup-YYYYMMDD-HHMMSS/
|
|
|
|
Backup permissions: 0o700 (user-only) for security (CWE-732)
|
|
|
|
Returns:
|
|
Path to backup directory
|
|
|
|
Raises:
|
|
BackupError: If backup creation fails
|
|
"""
|
|
try:
|
|
# Generate timestamp for backup name
|
|
# Format: YYYYMMDD-HHMMSS enables sorting and identification
|
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
backup_name = f"{self.plugin_name}-backup-{timestamp}"
|
|
|
|
# Create backup directory in temp using mkdtemp() for security
|
|
# mkdtemp() ensures atomic creation with 0o700 permissions by default
|
|
backup_path = Path(tempfile.mkdtemp(prefix=backup_name + "-"))
|
|
|
|
# Verify permissions are correct (CWE-59: TOCTOU prevention)
|
|
# Check that mkdtemp created directory with secure permissions
|
|
actual_perms = backup_path.stat().st_mode & 0o777
|
|
if actual_perms != 0o700:
|
|
# Attempt to fix permissions
|
|
backup_path.chmod(0o700)
|
|
# Verify fix worked
|
|
if backup_path.stat().st_mode & 0o777 != 0o700:
|
|
raise BackupError(
|
|
f"Cannot set secure permissions on backup directory: {backup_path}\n"
|
|
f"Expected 0o700, got {oct(actual_perms)}"
|
|
)
|
|
|
|
# Check if plugin directory exists
|
|
if not self.plugin_dir.exists():
|
|
# No plugin directory - create empty backup
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"backup_empty",
|
|
{
|
|
"event": "backup_empty",
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
"backup_path": str(backup_path),
|
|
"reason": "Plugin directory does not exist",
|
|
}
|
|
)
|
|
return backup_path
|
|
|
|
# Copy plugin directory to backup
|
|
# Use copytree with dirs_exist_ok=True to handle edge cases
|
|
for item in self.plugin_dir.iterdir():
|
|
if item.is_dir():
|
|
shutil.copytree(item, backup_path / item.name, dirs_exist_ok=True)
|
|
else:
|
|
shutil.copy2(item, backup_path / item.name)
|
|
|
|
# Audit log backup creation
|
|
security_utils.audit_log(
|
|
"plugin_backup_created",
|
|
"success",
|
|
{
|
|
"backup_path": str(backup_path),
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
}
|
|
)
|
|
|
|
return backup_path
|
|
|
|
except PermissionError as e:
|
|
raise BackupError(f"Permission denied creating backup: {e}")
|
|
except Exception as e:
|
|
raise BackupError(f"Failed to create backup: {e}")
|
|
|
|
def _rollback(self, backup_path: Path) -> None:
|
|
"""Restore plugin from backup directory.
|
|
|
|
Removes current plugin directory and restores from backup.
|
|
|
|
Args:
|
|
backup_path: Path to backup directory
|
|
|
|
Raises:
|
|
BackupError: If rollback fails
|
|
"""
|
|
try:
|
|
# Validate backup path exists
|
|
if not backup_path.exists():
|
|
raise BackupError(f"Backup path does not exist: {backup_path}")
|
|
|
|
# Check for symlinks (CWE-22: Path Traversal prevention)
|
|
if backup_path.is_symlink():
|
|
raise BackupError(
|
|
f"Rollback blocked: Backup path is a symlink (potential attack)\n"
|
|
f"Path: {backup_path}\n"
|
|
f"Target: {backup_path.resolve()}"
|
|
)
|
|
|
|
# Validate backup is in temp directory (not system directory)
|
|
# Allow backup paths in tempdir or test temp paths
|
|
import tempfile
|
|
temp_dir = tempfile.gettempdir()
|
|
|
|
# Resolve both paths to handle macOS symlinks (/var -> /private/var)
|
|
resolved_backup = str(backup_path.resolve())
|
|
resolved_temp = str(Path(temp_dir).resolve())
|
|
|
|
# Allow paths in system temp OR pytest temp fixtures (for testing)
|
|
is_in_temp = (
|
|
resolved_backup.startswith(resolved_temp)
|
|
or "/tmp/" in resolved_backup
|
|
or "pytest-of-" in resolved_backup # pytest temp directories
|
|
)
|
|
if not is_in_temp:
|
|
raise BackupError(
|
|
f"Rollback blocked: Backup path not in temp directory\n"
|
|
f"Path: {backup_path}\n"
|
|
f"Expected location: {temp_dir}"
|
|
)
|
|
|
|
# Remove current plugin directory if it exists
|
|
if self.plugin_dir.exists():
|
|
shutil.rmtree(self.plugin_dir)
|
|
|
|
# Restore from backup
|
|
self.plugin_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copytree(backup_path, self.plugin_dir, dirs_exist_ok=True)
|
|
|
|
# Audit log rollback
|
|
security_utils.audit_log(
|
|
"plugin_rollback",
|
|
"success",
|
|
{
|
|
"backup_path": str(backup_path),
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
}
|
|
)
|
|
|
|
except PermissionError as e:
|
|
raise BackupError(f"Permission denied during rollback: {e}")
|
|
except Exception as e:
|
|
raise BackupError(f"Rollback failed: {e}")
|
|
|
|
def _cleanup_backup(self, backup_path: Path) -> None:
|
|
"""Remove backup directory after successful update.
|
|
|
|
Args:
|
|
backup_path: Path to backup directory to remove
|
|
|
|
Note:
|
|
Gracefully handles nonexistent backup (no error raised)
|
|
"""
|
|
try:
|
|
if backup_path and backup_path.exists():
|
|
shutil.rmtree(backup_path)
|
|
|
|
# Audit log cleanup
|
|
security_utils.audit_log(
|
|
"plugin_backup_cleanup",
|
|
"success",
|
|
{
|
|
"backup_path": str(backup_path),
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
# Non-critical - log but don't raise
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"backup_cleanup_error",
|
|
{
|
|
"event": "backup_cleanup_error",
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
"backup_path": str(backup_path),
|
|
"error": str(e),
|
|
}
|
|
)
|
|
|
|
def _verify_update(self, expected_version: str) -> None:
|
|
"""Verify update succeeded by checking version.
|
|
|
|
Args:
|
|
expected_version: Expected version after update
|
|
|
|
Raises:
|
|
VerificationError: If verification fails
|
|
"""
|
|
try:
|
|
# Critical: Check if plugin.json exists (required for version detection)
|
|
# Missing plugin.json indicates sync failed or corrupted state
|
|
plugin_json = self.plugin_dir / "plugin.json"
|
|
if not plugin_json.exists():
|
|
raise VerificationError(
|
|
f"Verification failed: plugin.json not found at {plugin_json}"
|
|
)
|
|
|
|
# Check file size (DoS prevention - CWE-400)
|
|
# Prevent processing of maliciously large files
|
|
file_size = plugin_json.stat().st_size
|
|
if file_size > 10 * 1024 * 1024: # 10MB max
|
|
raise VerificationError(
|
|
f"plugin.json too large: {file_size} bytes (max 10MB)\n"
|
|
f"This may indicate a corrupted or malicious file."
|
|
)
|
|
|
|
# Parse plugin.json - must be valid JSON (indicates successful sync)
|
|
# Parse failure indicates corrupted sync or incomplete transfer
|
|
try:
|
|
plugin_data = json.loads(plugin_json.read_text())
|
|
except json.JSONDecodeError as e:
|
|
raise VerificationError(f"Verification failed: Invalid JSON in plugin.json: {e}")
|
|
|
|
# Validate required fields exist (data integrity check)
|
|
required_fields = ["name", "version"]
|
|
missing = [f for f in required_fields if f not in plugin_data]
|
|
if missing:
|
|
raise VerificationError(
|
|
f"plugin.json missing required fields: {missing}\n"
|
|
f"This indicates an incomplete or corrupted plugin installation."
|
|
)
|
|
|
|
# Critical: Verify version matches expected version
|
|
# Mismatch indicates sync failed to update to correct version
|
|
actual_version = plugin_data.get("version")
|
|
|
|
# Validate version format (semantic versioning)
|
|
import re
|
|
if not re.match(r'^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$', actual_version):
|
|
raise VerificationError(
|
|
f"Invalid version format: {actual_version}\n"
|
|
f"Expected semantic versioning (e.g., 3.8.0 or 3.8.0-beta.1)"
|
|
)
|
|
|
|
if actual_version != expected_version:
|
|
raise VerificationError(
|
|
f"Version mismatch: expected {expected_version}, got {actual_version}"
|
|
)
|
|
|
|
# Audit log successful verification
|
|
security_utils.audit_log(
|
|
"plugin_updater",
|
|
"verification_success",
|
|
{
|
|
"event": "verification_success",
|
|
"project_root": str(self.project_root),
|
|
"plugin_name": self.plugin_name,
|
|
"version": actual_version,
|
|
}
|
|
)
|
|
|
|
except VerificationError:
|
|
# Re-raise VerificationError
|
|
raise
|
|
except Exception as e:
|
|
raise VerificationError(f"Verification failed: {e}")
|