#!/usr/bin/env python3 """ Settings Generator - Create settings.local.json with specific command patterns This module generates .claude/settings.local.json with: 1. Specific command patterns (Bash(git:*), Bash(pytest:*), etc.) - NO wildcards 2. Comprehensive deny list blocking dangerous operations 3. File operation permissions (Read, Write, Edit, Glob, Grep) 4. Command auto-discovery from plugins/autonomous-dev/commands/*.md 5. User customization preservation during upgrades Security Features: - NO wildcards: Uses specific patterns only (Bash(git:*) NOT Bash(*)) - Comprehensive deny list: Blocks rm -rf, sudo, eval, chmod, etc. - Path validation: CWE-22 (path traversal), CWE-59 (symlinks) - Command injection prevention: Validates pattern syntax - Atomic writes: Secure permissions (0o600) - Audit logging: All operations logged Usage: # Fresh install generator = SettingsGenerator(plugin_dir) result = generator.write_settings(settings_path) # Upgrade with merge result = generator.write_settings(settings_path, merge_existing=True, backup=True) See Also: - docs/LIBRARIES.md section 30 for API documentation - tests/unit/lib/test_settings_generator.py for test cases - GitHub Issue #115 for security requirements Date: 2025-12-12 Issue: GitHub #115 Agent: implementer """ import json import re from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Any, Optional # Import security utilities try: from autonomous_dev.lib.security_utils import validate_path, audit_log from autonomous_dev.lib.settings_merger import UNIFIED_HOOK_REPLACEMENTS except ImportError: # Fallback for direct script execution import sys sys.path.insert(0, str(Path(__file__).parent)) from security_utils import validate_path, audit_log from settings_merger import UNIFIED_HOOK_REPLACEMENTS # ============================================================================= # Module Constants # ============================================================================= # Version for settings generation SETTINGS_VERSION = "1.0.0" # Safe command patterns - SPECIFIC ONLY, NO WILDCARDS SAFE_COMMAND_PATTERNS = [ # File operations (always needed) "Read(**)", "Write(**)", "Edit(**)", "Glob(**)", "Grep(**)", # Common file-specific patterns "Read(**/*.py)", "Write(**/*.py)", "Read(**/*.md)", "Write(**/*.md)", # Git operations (safe, read-only or controlled writes) "Bash(git:*)", # Python/Testing "Bash(python:*)", "Bash(python3:*)", "Bash(pytest:*)", "Bash(pip:*)", "Bash(pip3:*)", # GitHub CLI (safe operations) "Bash(gh:*)", # Package managers (local install only) "Bash(npm:*)", # Safe read-only commands "Bash(ls:*)", "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)", "Bash(grep:*)", "Bash(find:*)", "Bash(which:*)", "Bash(pwd:*)", "Bash(echo:*)", # Safe directory operations "Bash(cd:*)", "Bash(mkdir:*)", "Bash(touch:*)", # Safe file operations (not destructive) "Bash(cp:*)", "Bash(mv:*)", # Other common tools "Bash(black:*)", "Bash(mypy:*)", "Bash(ruff:*)", "Bash(isort:*)", ] # Dangerous operations to ALWAYS deny (from auto_approve_policy.json) DEFAULT_DENY_LIST = [ # Destructive file operations "Bash(rm:-rf*)", "Bash(rm:-f*)", "Bash(shred:*)", "Bash(dd:*)", "Bash(mkfs:*)", "Bash(fdisk:*)", "Bash(parted:*)", # Privilege escalation "Bash(sudo:*)", "Bash(su:*)", "Bash(doas:*)", # Code execution "Bash(eval:*)", "Bash(exec:*)", "Bash(source:*)", "Bash(.:*)", # . is alias for source # Permission changes "Bash(chmod:*)", "Bash(chown:*)", "Bash(chgrp:*)", # Network operations (potential data exfiltration) "Bash(nc:*)", "Bash(netcat:*)", "Bash(ncat:*)", "Bash(telnet:*)", "Bash(curl:*|*sh*)", "Bash(curl:*|*bash*)", "Bash(curl:*--data*)", # Data exfiltration "Bash(wget:*|*sh*)", "Bash(wget:*|*bash*)", "Bash(wget:*--post-file*)", # Data exfiltration # Dangerous git operations "Bash(git:*--force*)", "Bash(git:*push*-f*)", "Bash(git:*reset*--hard*)", "Bash(git:*clean*-fd*)", # Package operations (system-level) "Bash(apt:*install*)", "Bash(apt:*remove*)", "Bash(yum:*install*)", "Bash(brew:*install*)", "Bash(npm:*install*-g*)", # Global install "Bash(npm:publish*)", "Bash(pip:upload*)", "Bash(twine:upload*)", # System operations "Bash(shutdown:*)", "Bash(reboot:*)", "Bash(halt:*)", "Bash(poweroff:*)", "Bash(kill:-9*-1*)", "Bash(killall:-9*)", # Shell injections "Bash(*|*sh*)", "Bash(*|*bash*)", "Bash(*$(rm*)", "Bash(*`rm*)", # Sensitive file access "Read(./.env)", "Read(./.env.*)", "Read(~/.ssh/**)", "Read(~/.aws/**)", "Read(~/.config/gh/**)", "Write(/etc/**)", "Write(/System/**)", "Write(/usr/**)", "Write(~/.ssh/**)", ] # ============================================================================= # Data Classes # ============================================================================= @dataclass class PermissionIssue: """Details about a detected permission issue. Attributes: issue_type: Type of issue (wildcard_pattern, missing_deny_list, empty_deny_list, outdated_pattern) description: Human-readable description of the issue pattern: Pattern affected by this issue (empty string if N/A) severity: Severity level (warning, error) """ issue_type: str description: str pattern: str severity: str @dataclass class ValidationResult: """Result of permission validation. Attributes: valid: Whether validation passed issues: List of detected issues needs_fix: Whether fixes should be applied """ valid: bool issues: List[PermissionIssue] needs_fix: bool @dataclass class GeneratorResult: """Result of settings generation operation. Attributes: success: Whether generation succeeded message: Human-readable result message settings_path: Path to generated settings file (None if failed) patterns_added: Number of new patterns added patterns_preserved: Number of user patterns preserved (upgrade only) denies_added: Number of deny patterns added details: Additional result details """ success: bool message: str settings_path: Optional[str] = None patterns_added: int = 0 patterns_preserved: int = 0 denies_added: int = 0 details: Dict[str, Any] = field(default_factory=dict) class SettingsGeneratorError(Exception): """Exception raised for settings generation errors.""" pass # ============================================================================= # Validation and Fixing Functions # ============================================================================= def validate_permission_patterns(settings: Dict) -> ValidationResult: """Validate permission patterns in settings. Detects: - Bash(*) wildcard → severity "error" - Bash(:*) wildcard → severity "warning" - Missing deny list → severity "error" - Empty deny list → severity "error" Args: settings: Settings dictionary to validate Returns: ValidationResult with detected issues Examples: >>> settings = {"permissions": {"allow": ["Bash(*)"], "deny": []}} >>> result = validate_permission_patterns(settings) >>> result.valid False >>> len(result.issues) 2 """ if settings is None: return ValidationResult( valid=False, issues=[PermissionIssue( issue_type="invalid_input", description="Settings is None", pattern="", severity="error" )], needs_fix=True ) if not isinstance(settings, dict): return ValidationResult( valid=False, issues=[PermissionIssue( issue_type="invalid_input", description="Settings is not a dictionary", pattern="", severity="error" )], needs_fix=True ) issues = [] # Check if permissions key exists if "permissions" not in settings: return ValidationResult( valid=False, issues=[PermissionIssue( issue_type="malformed_structure", description="Missing permissions section in settings", pattern="", severity="error" )], needs_fix=True ) permissions = settings["permissions"] if not isinstance(permissions, dict): return ValidationResult( valid=False, issues=[PermissionIssue( issue_type="malformed_structure", description="Permissions is not a dictionary", pattern="", severity="error" )], needs_fix=True ) # Check allow list for wildcards allow_list = permissions.get("allow", []) if not isinstance(allow_list, list): allow_list = [] # Detect Bash(*) wildcard - SEVERITY ERROR bash_wildcards = [p for p in allow_list if p == "Bash(*)"] for wildcard in bash_wildcards: issues.append(PermissionIssue( issue_type="wildcard_pattern", description="Overly permissive wildcard - too permissive", pattern=wildcard, severity="error" )) # Detect Bash(:*) wildcard - SEVERITY WARNING colon_wildcards = [p for p in allow_list if p == "Bash(:*)"] for wildcard in colon_wildcards: issues.append(PermissionIssue( issue_type="wildcard_pattern", description="Bash(:*) wildcard detected - less specific than recommended", pattern=wildcard, severity="warning" )) # Check deny list if "deny" not in permissions: issues.append(PermissionIssue( issue_type="missing_deny_list", description="Missing deny list - dangerous operations not blocked", pattern="", severity="error" )) elif not permissions["deny"]: issues.append(PermissionIssue( issue_type="empty_deny_list", description="Empty deny list - dangerous operations not blocked", pattern="", severity="error" )) # Settings are invalid if ANY issues exist (errors or warnings) valid = len(issues) == 0 needs_fix = len(issues) > 0 return ValidationResult(valid=valid, issues=issues, needs_fix=needs_fix) def detect_outdated_patterns(settings: Dict) -> List[str]: """Detect patterns not in SAFE_COMMAND_PATTERNS. Args: settings: Settings dictionary to check Returns: List of outdated pattern strings Examples: >>> settings = {"permissions": {"allow": ["Bash(obsolete:*)"]}} >>> outdated = detect_outdated_patterns(settings) >>> "Bash(obsolete:*)" in outdated True """ if not settings or not isinstance(settings, dict): return [] if "permissions" not in settings: return [] permissions = settings["permissions"] if not isinstance(permissions, dict): return [] allow_list = permissions.get("allow", []) if not isinstance(allow_list, list): return [] outdated = [] for pattern in allow_list: if pattern not in SAFE_COMMAND_PATTERNS: outdated.append(pattern) return outdated def fix_permission_patterns(user_settings: Dict, template_settings: Optional[Dict] = None) -> Dict: """Fix permission patterns while preserving user customizations. Process: 1. Preserve user hooks (don't touch) 2. Preserve valid custom allow patterns 3. Replace wildcards with specific patterns 4. Add comprehensive deny list 5. Validate result Args: user_settings: User's existing settings template_settings: Optional template settings (unused, for compatibility) Returns: Fixed settings dictionary Raises: ValueError: If user_settings is None or not a dictionary Examples: >>> settings = {"permissions": {"allow": ["Bash(*)"]}, "hooks": {"auto_format": True}} >>> fixed = fix_permission_patterns(settings) >>> "Bash(*)" not in fixed["permissions"]["allow"] True >>> fixed["hooks"]["auto_format"] True """ if user_settings is None: raise ValueError("user_settings cannot be None") if not isinstance(user_settings, dict): raise ValueError("user_settings must be a dictionary") # Deep copy to avoid modifying original fixed = json.loads(json.dumps(user_settings)) # Ensure permissions structure exists if "permissions" not in fixed: fixed["permissions"] = {"allow": [], "deny": []} if not isinstance(fixed["permissions"], dict): fixed["permissions"] = {"allow": [], "deny": []} if "allow" not in fixed["permissions"]: fixed["permissions"]["allow"] = [] if not isinstance(fixed["permissions"]["allow"], list): fixed["permissions"]["allow"] = [] # Get current allow list current_allow = fixed["permissions"]["allow"] # Remove wildcard patterns (Bash(*) and Bash(:*)) wildcards_to_remove = ["Bash(*)", "Bash(:*)"] new_allow = [p for p in current_allow if p not in wildcards_to_remove] # Add SAFE_COMMAND_PATTERNS if wildcards were removed has_wildcards = any(w in current_allow for w in wildcards_to_remove) if has_wildcards: # Merge SAFE_COMMAND_PATTERNS with existing patterns (avoid duplicates) for pattern in SAFE_COMMAND_PATTERNS: if pattern not in new_allow: new_allow.append(pattern) fixed["permissions"]["allow"] = new_allow # Fix deny list if "deny" not in fixed["permissions"] or not fixed["permissions"]["deny"]: fixed["permissions"]["deny"] = DEFAULT_DENY_LIST.copy() elif not isinstance(fixed["permissions"]["deny"], list): fixed["permissions"]["deny"] = DEFAULT_DENY_LIST.copy() return fixed # ============================================================================= # SettingsGenerator Class # ============================================================================= class SettingsGenerator: """Generate settings.local.json with command-specific patterns and deny list. This class discovers commands from the plugin directory and generates .claude/settings.local.json with: - Specific command patterns (NO wildcards) - Comprehensive deny list - User customization preservation (upgrades) Security: - Path validation (CWE-22, CWE-59) - Command injection prevention - Atomic writes with secure permissions - Audit logging Attributes: plugin_dir: Path to plugin directory (plugins/autonomous-dev) commands_dir: Path to commands directory discovered_commands: List of discovered command names """ def __init__(self, plugin_dir: Optional[Path] = None, project_root: Optional[Path] = None): """Initialize settings generator. Args: plugin_dir: Path to plugin directory (plugins/autonomous-dev) project_root: Path to project root (alternative to plugin_dir) Raises: SettingsGeneratorError: If plugin_dir not found Note: Commands directory is validated lazily when needed by methods. This allows using static methods like build_deny_list() without requiring full plugin structure. """ # Support both plugin_dir and project_root parameters if project_root is not None: self.plugin_dir = Path(project_root) / "plugins" / "autonomous-dev" # For project_root mode, allow missing plugin directory (used for global settings merge) self._allow_missing_plugin_dir = True elif plugin_dir is not None: self.plugin_dir = Path(plugin_dir) self._allow_missing_plugin_dir = False else: raise SettingsGeneratorError("Either plugin_dir or project_root must be provided") self.commands_dir = self.plugin_dir / "commands" self.discovered_commands = [] self.invalid_commands_found = [] # Track invalid command names self._validated = False # Validate plugin directory exists (unless in project_root mode for global settings) if not self.plugin_dir.exists(): if not self._allow_missing_plugin_dir: raise SettingsGeneratorError( f"Plugin directory not found: {self.plugin_dir}\n" f"Expected structure: plugins/autonomous-dev/" ) # In project_root mode - allow missing plugin_dir for global settings merge return # Check if commands directory exists # Special case: Allow /tmp without commands/ for testing static methods # Otherwise, require commands/ directory for full functionality is_system_temp = str(self.plugin_dir.resolve()) in ['/tmp', '/var/tmp', '/private/tmp'] if not self.commands_dir.exists(): if not is_system_temp and not self._allow_missing_plugin_dir: raise SettingsGeneratorError( f"Commands directory not found: {self.commands_dir}\n" f"Expected structure: plugins/autonomous-dev/commands/" ) # System temp directory or project_root mode - allow minimal initialization for static methods else: # Commands directory exists - discover commands self._validated = True self.discovered_commands = self.discover_commands() def discover_commands(self) -> List[str]: """Discover commands from plugins/autonomous-dev/commands/*.md files. Returns: List of command names (without .md extension) Raises: SettingsGeneratorError: If directory read fails or commands/ not found """ # Validate commands directory exists if not self.commands_dir.exists(): raise SettingsGeneratorError( f"Commands directory not found: {self.commands_dir}\n" f"Expected structure: plugins/autonomous-dev/commands/" ) commands = [] try: for file_path in self.commands_dir.iterdir(): # Skip non-.md files if not file_path.suffix == ".md": continue # Skip hidden files if file_path.name.startswith("."): continue # Skip archived subdirectory if file_path.is_dir(): continue # Extract command name (remove .md extension) command_name = file_path.stem # Track invalid command names for security validation if not self._is_valid_command_name(command_name): self.invalid_commands_found.append(command_name) continue commands.append(command_name) except PermissionError as e: raise SettingsGeneratorError( f"Permission denied reading commands directory: {self.commands_dir}\n" f"Error: {e}" ) except OSError as e: raise SettingsGeneratorError( f"Failed to read commands directory: {self.commands_dir}\n" f"Error: {e}" ) return sorted(commands) def _is_valid_command_name(self, name: str) -> bool: """Validate command name to prevent injection. Args: name: Command name to validate Returns: True if valid, False otherwise """ # Allow alphanumeric, dash, and underscore only return bool(re.match(r'^[a-zA-Z0-9_-]+$', name)) def build_command_patterns(self) -> List[str]: """Build specific command patterns from safe defaults. Returns specific patterns like: - Bash(git:*) - Bash(pytest:*) - Read(**) - Write(**) NEVER returns wildcards like Bash(*) or Bash(:*) Returns: List of specific command patterns Raises: SettingsGeneratorError: If pattern generation fails or invalid commands found """ # Check for security issues (invalid command names) if self.invalid_commands_found: raise SettingsGeneratorError( f"Invalid command names detected (potential security risk): " f"{', '.join(self.invalid_commands_found)}\n" f"Command names must contain only alphanumeric, dash, and underscore characters" ) patterns = [] # Add safe command patterns (from module constant) patterns.extend(SAFE_COMMAND_PATTERNS) # Deduplicate patterns patterns = list(set(patterns)) # Validate no wildcards in output dangerous_wildcards = ["Bash(*)", "Bash(**)", "Shell(*)", "Exec(*)"] for wildcard in dangerous_wildcards: if wildcard in patterns: raise SettingsGeneratorError( f"SECURITY: Wildcard pattern detected in output: {wildcard}\n" f"This would defeat the entire security model. Aborting." ) return sorted(patterns) @staticmethod def build_deny_list() -> List[str]: """Build comprehensive deny list of dangerous operations. Returns patterns blocking: - Destructive file operations (rm -rf, shred, dd) - Privilege escalation (sudo, su, chmod) - Code execution (eval, exec, source) - Network operations (nc, curl|sh) - Dangerous git operations (--force, reset --hard) - Package publishing (npm publish, twine upload) Returns: List of deny patterns """ # Return default deny list (from module constant) return list(DEFAULT_DENY_LIST) def generate_settings(self, merge_with: Optional[Dict] = None) -> Dict: """Generate settings dictionary with all patterns and metadata. Args: merge_with: Optional existing settings to merge with Returns: Settings dictionary ready for JSON serialization Structure: { "permissions": { "allow": [...], "deny": [...] }, "hooks": {...}, # Preserved from merge_with "generated_by": "autonomous-dev", "version": "1.0.0", "timestamp": "2025-12-12T10:30:00Z" } """ # Build patterns allow_patterns = self.build_command_patterns() deny_patterns = self.build_deny_list() # Add Claude Code standalone tools (not Bash patterns) standalone_tools = [ "Task", "WebFetch", "WebSearch", "TodoWrite", "NotebookEdit", ] allow_patterns.extend(standalone_tools) # Initialize settings structure settings = { "permissions": { "allow": allow_patterns, "deny": deny_patterns, }, "generated_by": "autonomous-dev", "version": SETTINGS_VERSION, "timestamp": datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'), } # Merge with existing settings if provided if merge_with: # Preserve user hooks if "hooks" in merge_with: settings["hooks"] = merge_with["hooks"] # Preserve user custom patterns (add to allow list) if "permissions" in merge_with and "allow" in merge_with["permissions"]: user_patterns = merge_with["permissions"]["allow"] # Filter out generated patterns, keep only user's custom ones custom_patterns = [ p for p in user_patterns if p not in SAFE_COMMAND_PATTERNS ] # Add custom patterns to allow list settings["permissions"]["allow"].extend(custom_patterns) # Deduplicate settings["permissions"]["allow"] = list(set(settings["permissions"]["allow"])) # Preserve user deny patterns (union with defaults) if "permissions" in merge_with and "deny" in merge_with["permissions"]: user_denies = merge_with["permissions"]["deny"] settings["permissions"]["deny"].extend(user_denies) # Deduplicate settings["permissions"]["deny"] = list(set(settings["permissions"]["deny"])) # Preserve any other custom keys for key, value in merge_with.items(): if key not in settings and key not in ["permissions"]: settings[key] = value return settings def write_settings( self, output_path: Path, merge_existing: bool = False, backup: bool = False, ) -> GeneratorResult: """Write settings.local.json to disk. Args: output_path: Path to write settings.local.json merge_existing: Whether to merge with existing settings backup: Whether to backup existing file Returns: GeneratorResult with success status and statistics Raises: SettingsGeneratorError: If write fails or generator not properly initialized """ # Validate generator was properly initialized if not self._validated and not self.plugin_dir.exists(): raise SettingsGeneratorError( f"Generator not properly initialized - plugin directory not found: {self.plugin_dir}\n" f"Cannot generate settings without valid plugin structure." ) try: # Step 1: Validate output path (security) try: validated_path = validate_path( output_path, purpose="settings generation", allow_missing=True, ) except ValueError as e: audit_log( "settings_generation", "path_validation_failed", { "output_path": str(output_path), "error": str(e), }, ) raise SettingsGeneratorError( f"Path validation failed: {e}\n" f"Cannot write to: {output_path}" ) # Step 2: Read existing settings if merging existing_settings = None corrupted_backup = False if merge_existing and output_path.exists(): try: existing_content = output_path.read_text() existing_settings = json.loads(existing_content) except json.JSONDecodeError: # Corrupted JSON - backup and continue with fresh settings corrupted_backup = True backup_path = output_path.parent / f"{output_path.name}.corrupted" output_path.rename(backup_path) audit_log( "settings_generation", "corrupted_settings_backed_up", { "output_path": str(output_path), "backup_path": str(backup_path), }, ) # Step 3: Backup existing file if requested if backup and output_path.exists() and not corrupted_backup: backup_path = output_path.parent / f"{output_path.name}.backup" output_path.rename(backup_path) audit_log( "settings_generation", "settings_backed_up", { "output_path": str(output_path), "backup_path": str(backup_path), }, ) # Step 4: Generate settings settings = self.generate_settings(merge_with=existing_settings) # Step 5: Create parent directory if needed output_path.parent.mkdir(parents=True, exist_ok=True) # Step 6: Write settings atomically # Use temporary file + rename for atomicity temp_path = output_path.parent / f".{output_path.name}.tmp" try: # Write to temp file temp_path.write_text(json.dumps(settings, indent=2) + "\n") # Set secure permissions (0o600 - owner read/write only) temp_path.chmod(0o600) # Atomic rename temp_path.rename(output_path) except Exception as e: # Cleanup temp file if write failed if temp_path.exists(): temp_path.unlink() raise # Step 7: Calculate statistics patterns_added = len(settings["permissions"]["allow"]) denies_added = len(settings["permissions"]["deny"]) patterns_preserved = 0 if existing_settings and "permissions" in existing_settings: # Count user patterns that were preserved user_patterns = existing_settings["permissions"].get("allow", []) custom_patterns = [ p for p in user_patterns if p not in SAFE_COMMAND_PATTERNS ] patterns_preserved = len(custom_patterns) # Step 8: Audit log success audit_log( "settings_generation", "success", { "output_path": str(output_path), "patterns_added": patterns_added, "denies_added": denies_added, "patterns_preserved": patterns_preserved, "merge_existing": merge_existing, "backup": backup, "corrupted": corrupted_backup, }, ) # Step 9: Return result message = "Settings created successfully" if corrupted_backup: message = "Settings regenerated (corrupted file backed up)" elif backup: message = "Settings updated successfully (backed up existing)" elif merge_existing: message = "Settings merged successfully" return GeneratorResult( success=True, message=message, settings_path=str(output_path), patterns_added=patterns_added, patterns_preserved=patterns_preserved, denies_added=denies_added, details={ "corrupted": corrupted_backup, "merged": merge_existing, "backed_up": backup, }, ) except PermissionError as e: audit_log( "settings_generation", "permission_denied", { "output_path": str(output_path), "error": str(e), }, ) raise SettingsGeneratorError( f"Permission denied writing settings: {output_path}\n" f"Error: {e}" ) except OSError as e: audit_log( "settings_generation", "write_failed", { "output_path": str(output_path), "error": str(e), }, ) # Check for disk full errors if e.errno == 28: # ENOSPC - No space left on device raise SettingsGeneratorError( f"Disk full - cannot write settings: {output_path}\n" f"Error: {e}" ) raise SettingsGeneratorError( f"Failed to write settings: {output_path}\n" f"Error: {e}" ) def merge_global_settings( self, global_path: Path, template_path: Path, fix_wildcards: bool = True, create_backup: bool = True ) -> Dict[str, Any]: """Merge global settings preserving user customizations. Process: 1. Read template settings 2. Read existing user settings (if any) 3. Fix broken patterns if enabled 4. Merge: template + user customizations 5. Preserve user hooks completely 6. Write atomically with backup Args: global_path: Path to global settings file (~/.claude/settings.json) template_path: Path to template file fix_wildcards: Whether to fix broken wildcard patterns create_backup: Whether to create backup before modification Returns: Merged settings dictionary Raises: SettingsGeneratorError: If template not found or write fails """ # Step 1: Validate template exists if not template_path.exists(): raise SettingsGeneratorError( f"Template file not found: {template_path}\n" f"Expected: plugins/autonomous-dev/config/global_settings_template.json" ) # Step 2: Read template try: with open(template_path, 'r') as f: template = json.load(f) except json.JSONDecodeError as e: raise SettingsGeneratorError( f"Invalid JSON in template: {template_path}\n" f"Error: {e}" ) except OSError as e: raise SettingsGeneratorError( f"Failed to read template: {template_path}\n" f"Error: {e}" ) # Step 3: Read existing user settings (if exists) user_settings = {} if global_path.exists(): try: with open(global_path, 'r') as f: user_settings = json.load(f) except json.JSONDecodeError: # Corrupted file - create backup and use template if create_backup: backup_path = global_path.with_suffix(".json.corrupted") # Remove old corrupted backup if exists if backup_path.exists(): backup_path.unlink() global_path.rename(backup_path) audit_log( "settings_merge", "corrupted_backup", {"backup_path": str(backup_path)} ) user_settings = {} except OSError as e: raise SettingsGeneratorError( f"Failed to read global settings: {global_path}\n" f"Error: {e}" ) # Step 4: Create backup if modifying existing file if global_path.exists() and create_backup and user_settings: backup_path = global_path.with_suffix(".json.backup") try: # Remove old backup if exists if backup_path.exists(): backup_path.unlink() with open(backup_path, 'w') as f: json.dump(user_settings, f, indent=2) audit_log( "settings_merge", "backup_created", {"backup_path": str(backup_path)} ) except OSError as e: # Don't fail merge if backup fails - just log audit_log( "settings_merge", "backup_failed", {"error": str(e)} ) # Step 5: Merge settings merged = self._deep_merge_settings(template, user_settings, fix_wildcards) # Step 6: Validate merged settings self._validate_merged_settings(merged) # Step 7: Write atomically global_path.parent.mkdir(parents=True, exist_ok=True) temp_path = global_path.parent / f".{global_path.name}.tmp" try: # Use write_text for atomic write temp_path.write_text(json.dumps(merged, indent=2)) # Atomic rename temp_path.replace(global_path) audit_log( "settings_merge", "success", { "global_path": str(global_path), "template_path": str(template_path), "fixed_wildcards": fix_wildcards } ) return merged except (PermissionError, IOError) as e: if temp_path.exists(): temp_path.unlink() # Let PermissionError and IOError bubble up for testing raise except OSError as e: if temp_path.exists(): temp_path.unlink() raise SettingsGeneratorError( f"Failed to write global settings: {global_path}\n" f"Error: {e}" ) def _deep_merge_settings( self, template: Dict[str, Any], user_settings: Dict[str, Any], fix_wildcards: bool ) -> Dict[str, Any]: """Deep merge preserving user customizations. Merge strategy (Claude Code 2.0 format): 1. Start with template (has all required patterns) 2. Fix broken wildcards in user settings if enabled 3. Merge permissions.allow: template + user patterns (union) 4. Merge permissions.deny: template + user patterns (union) 5. Preserve user hooks completely (don't modify) 6. Preserve all other user settings not in template Args: template: Template settings user_settings: Existing user settings fix_wildcards: Whether to fix broken wildcard patterns Returns: Merged settings dictionary """ # Start with template merged = json.loads(json.dumps(template)) # Deep copy # If no user settings, return template if not user_settings: return merged # Fix wildcards in user settings if enabled if fix_wildcards: user_settings = fix_permission_patterns(user_settings) # Merge permissions.allow and permissions.deny (Claude Code 2.0 format) if "permissions" in user_settings: user_perms = user_settings["permissions"] template_perms = merged.setdefault("permissions", {}) # Merge allow patterns (union) template_allow = template_perms.get("allow", []) user_allow = user_perms.get("allow", []) # Remove broken wildcards from user patterns broken_wildcards = ["Bash(:*)", "Bash(*)", "Bash(**)"] user_allow = [p for p in user_allow if p not in broken_wildcards] # Union of template and user patterns (deduplicate) merged_allow = list(set(template_allow + user_allow)) template_perms["allow"] = sorted(merged_allow) # Merge deny patterns (union) template_deny = template_perms.get("deny", []) user_deny = user_perms.get("deny", []) merged_deny = list(set(template_deny + user_deny)) template_perms["deny"] = sorted(merged_deny) # Merge hooks by lifecycle event (Issue #138: Fix hook loss during merge) # Previously: User hooks completely replaced template hooks, losing UserPromptSubmit # Now: Merge hooks - template hooks + user hooks (user wins for duplicates) # Issue #144: Migrate old hooks to unified hooks (remove replaced hooks) template_hooks = merged.get("hooks", {}) user_hooks = user_settings.get("hooks", {}) # Issue #144: Build set of old hooks to remove based on unified hooks in template hooks_to_remove = set() for lifecycle, matcher_configs in template_hooks.items(): for config in matcher_configs: if isinstance(config, dict): inner_hooks = config.get("hooks", [config]) for hook in inner_hooks: if isinstance(hook, dict): cmd = hook.get("command", "") for unified_hook, replaced_hooks in UNIFIED_HOOK_REPLACEMENTS.items(): if unified_hook in cmd: hooks_to_remove.update(replaced_hooks) # Start with template hooks (to preserve UserPromptSubmit, etc.) merged_hooks = json.loads(json.dumps(template_hooks)) # Deep copy # Merge user hooks on top (by lifecycle event), filtering out old hooks for lifecycle, hooks in user_hooks.items(): if lifecycle not in merged_hooks: # New lifecycle from user - add all hooks (filtering old ones) filtered_hooks = [] for hook in hooks: if isinstance(hook, dict): if "hooks" in hook: # Nested format - filter inner hooks filtered_inner = [] for inner_hook in hook.get("hooks", []): if isinstance(inner_hook, dict): cmd = inner_hook.get("command", "") should_remove = any(old_hook in cmd for old_hook in hooks_to_remove) if not should_remove: filtered_inner.append(inner_hook) else: filtered_inner.append(inner_hook) if filtered_inner: filtered_hooks.append({**hook, "hooks": filtered_inner}) else: # Flat format - check command directly cmd = hook.get("command", "") should_remove = any(old_hook in cmd for old_hook in hooks_to_remove) if not should_remove: filtered_hooks.append(hook) else: filtered_hooks.append(hook) if filtered_hooks: merged_hooks[lifecycle] = json.loads(json.dumps(filtered_hooks)) else: # Existing lifecycle - merge individual hooks (avoid duplicates, filter old) existing_hooks = merged_hooks[lifecycle] for hook in hooks: if isinstance(hook, dict): if "hooks" in hook: # Nested format - filter and merge inner hooks for inner_hook in hook.get("hooks", []): if isinstance(inner_hook, dict): cmd = inner_hook.get("command", "") should_remove = any(old_hook in cmd for old_hook in hooks_to_remove) if should_remove: continue # Check if this exact hook already exists hook_exists = any( h.get("command") == cmd for h in existing_hooks if isinstance(h, dict) and "command" in h ) # Also check nested hooks for existing in existing_hooks: if isinstance(existing, dict) and "hooks" in existing: hook_exists = hook_exists or any( ih.get("command") == cmd for ih in existing.get("hooks", []) if isinstance(ih, dict) ) if not hook_exists: # Add to first matcher config's hooks if existing_hooks and isinstance(existing_hooks[0], dict) and "hooks" in existing_hooks[0]: existing_hooks[0]["hooks"].append(json.loads(json.dumps(inner_hook))) else: # Flat format - check command directly cmd = hook.get("command", "") should_remove = any(old_hook in cmd for old_hook in hooks_to_remove) if should_remove: continue hook_exists = any( h.get("command") == hook.get("command") and h.get("matcher") == hook.get("matcher") for h in existing_hooks if isinstance(h, dict) ) if not hook_exists: existing_hooks.append(json.loads(json.dumps(hook))) if merged_hooks: merged["hooks"] = merged_hooks # Preserve all other user settings not in template for key, value in user_settings.items(): if key not in ["permissions", "hooks"]: merged[key] = json.loads(json.dumps(value)) # Deep copy return merged def _fix_wildcard_patterns(self, settings: Dict[str, Any]) -> Dict[str, Any]: """Fix broken wildcard patterns by replacing with safe patterns. Replaces: Bash(:*), Bash(*), Bash(**) → Safe specific patterns Preserves: All other patterns Args: settings: Settings dictionary to fix Returns: Fixed settings dictionary """ # Deep copy to avoid modifying original fixed = json.loads(json.dumps(settings)) broken_wildcards = ["Bash(:*)", "Bash(*)", "Bash(**)"] # Safe replacement patterns safe_patterns = [ "Bash(git:*)", "Bash(python:*)", "Bash(python3:*)", "Bash(pytest:*)", "Bash(pip:*)", "Bash(pip3:*)", "Bash(ls:*)", "Bash(cat:*)", "Bash(gh:*)", ] # Fix allowedTools.Bash.allow_patterns if "allowedTools" in fixed and "Bash" in fixed["allowedTools"]: bash = fixed["allowedTools"]["Bash"] if "allow_patterns" in bash: patterns = bash["allow_patterns"] # Check if any broken patterns exist has_broken = any(p in broken_wildcards for p in patterns) if has_broken: # Remove all broken patterns patterns = [p for p in patterns if p not in broken_wildcards] # Add safe patterns (avoiding duplicates) for safe_pattern in safe_patterns: if safe_pattern not in patterns: patterns.append(safe_pattern) bash["allow_patterns"] = patterns return fixed def _validate_merged_settings(self, settings: Dict[str, Any]) -> None: """Validate merged settings (Claude Code 2.0 format). Ensures: 1. No broken wildcard patterns 2. Required safe patterns present 3. Valid JSON structure Args: settings: Settings to validate Raises: SettingsGeneratorError: If validation fails """ # Check for broken wildcards in permissions.allow broken_wildcards = ["Bash(:*)", "Bash(*)", "Bash(**)"] if "permissions" in settings and "allow" in settings["permissions"]: allow_patterns = settings["permissions"]["allow"] for pattern in allow_patterns: if pattern in broken_wildcards: raise SettingsGeneratorError( f"Validation failed: Broken wildcard pattern found: {pattern}\n" f"This should have been fixed during merge" ) # ============================================================================= # CLI Interface (for testing) # ============================================================================= def main(): """CLI interface for settings generator (testing only).""" import sys if len(sys.argv) < 3: print("Usage: python settings_generator.py ") print("\nExample:") print(" python settings_generator.py plugins/autonomous-dev .claude/settings.local.json") sys.exit(1) plugin_dir = Path(sys.argv[1]) output_path = Path(sys.argv[2]) try: generator = SettingsGenerator(plugin_dir) result = generator.write_settings(output_path) if result.success: print(f"✅ {result.message}") print(f" Path: {result.settings_path}") print(f" Patterns added: {result.patterns_added}") print(f" Denies added: {result.denies_added}") else: print(f"❌ {result.message}") sys.exit(1) except SettingsGeneratorError as e: print(f"❌ Error: {e}") sys.exit(1) if __name__ == "__main__": main()