TradingAgents/.claude/lib/settings_merger.py

521 lines
19 KiB
Python

"""
Settings merger for merging settings.local.json with template configuration.
This module provides functionality to merge template settings (e.g., PreToolUse hooks)
with user's existing settings.local.json while preserving user customizations.
Security Features:
- Path validation (CWE-22: Path Traversal)
- Symlink rejection (CWE-59: Improper Link Resolution)
- Atomic writes with secure permissions (0o600)
- Audit logging for all operations
Design Pattern:
- Deep merge: Nested dictionaries are merged recursively
- Hooks merge by lifecycle event (PreToolUse, PostToolUse, etc.)
- User customizations preserved (permissions, custom config)
- Duplicate hooks avoided
Usage:
merger = SettingsMerger(project_root="/path/to/project")
result = merger.merge_settings(
template_path=Path("templates/settings.local.json"),
user_path=Path(".claude/settings.local.json"),
write_result=True
)
See Also:
- docs/LIBRARIES.md section 29 for API documentation
- tests/unit/lib/test_settings_merger.py for test cases
"""
import json
import os
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
# Import security utilities
try:
from autonomous_dev.lib.security_utils import validate_path, audit_log
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
# Issue #144: Migration mapping from unified hooks to replaced hooks
# When a unified hook is added, remove the old hooks it replaces
UNIFIED_HOOK_REPLACEMENTS = {
"unified_pre_tool.py": [
"pre_tool_use.py",
"enforce_implementation_workflow.py",
"batch_permission_approver.py",
],
"unified_prompt_validator.py": [
"detect_feature_request.py",
],
"unified_post_tool.py": [
"post_tool_use_error_capture.py",
],
"unified_session_tracker.py": [
"session_tracker.py",
"log_agent_completion.py",
"auto_update_project_progress.py",
],
"unified_git_automation.py": [
"auto_git_workflow.py",
],
}
@dataclass
class MergeResult:
"""Result of settings merge operation.
Attributes:
success: Whether merge succeeded
message: Human-readable result message
settings_path: Path to merged settings file (None if merge failed)
hooks_added: Number of hooks added from template
hooks_preserved: Number of existing hooks preserved
hooks_migrated: Number of old hooks removed during migration
details: Additional result details (errors, warnings, etc.)
"""
success: bool
message: str
settings_path: Optional[str] = None
hooks_added: int = 0
hooks_preserved: int = 0
hooks_migrated: int = 0
details: Dict[str, Any] = field(default_factory=dict)
class SettingsMerger:
"""Merge settings.local.json with template configuration.
This class handles merging template settings (e.g., PreToolUse hooks) with
user's existing settings while preserving user customizations.
Security:
- Validates all paths against project root
- Rejects symlinks and path traversal attempts
- Atomic writes with secure permissions (0o600)
- Audit logging for all operations
Attributes:
project_root: Project root directory for path validation
"""
def __init__(self, project_root: str):
"""Initialize settings merger.
Args:
project_root: Project root directory for path validation
"""
self.project_root = Path(project_root)
def merge_settings(
self, template_path: Path, user_path: Path, write_result: bool = True
) -> MergeResult:
"""Merge template settings with user settings.
This method performs a deep merge of template settings with existing
user settings, with special handling for hooks:
1. Read template and user settings (if exists)
2. Deep merge dictionaries (nested objects preserved)
3. Merge hooks by lifecycle event (avoid duplicates)
4. Atomic write to user path (if write_result=True)
Args:
template_path: Path to template settings.local.json
user_path: Path to user settings.local.json
write_result: Whether to write merged settings (False for dry-run)
Returns:
MergeResult with success status, counts, and details
Security:
- Validates both paths against project root
- Rejects symlinks and path traversal attempts
- Audit logs all operations
"""
try:
# Step 1: Validate paths (security)
# Validate template path
try:
validate_path(
template_path,
purpose="template settings",
allow_missing=False,
)
except ValueError as e:
audit_log(
"settings_merge",
"template_validation_failed",
{
"template_path": str(template_path),
"error": str(e),
},
)
return MergeResult(
success=False,
message=f"Template path validation failed: {e}",
details={"error": str(e)},
)
# Check if template exists
if not template_path.exists():
audit_log(
"settings_merge",
"template_not_found",
{
"template_path": str(template_path),
},
)
return MergeResult(
success=False,
message=f"Template settings not found: {template_path}",
details={"error": "Template file does not exist"},
)
# Validate user path (allow missing since we may create it)
try:
validate_path(
user_path,
purpose="user settings",
allow_missing=True,
)
except ValueError as e:
audit_log(
"settings_merge",
"user_path_validation_failed",
{
"user_path": str(user_path),
"error": str(e),
},
)
return MergeResult(
success=False,
message=f"User path validation failed: {e}",
details={"error": str(e)},
)
# Step 2: Read template settings
template_data = self._read_json(template_path)
if template_data is None:
audit_log(
"settings_merge",
"template_parse_failed",
{
"template_path": str(template_path),
},
)
return MergeResult(
success=False,
message=f"Failed to parse template JSON: {template_path}",
details={"error": "Invalid JSON in template file"},
)
# Step 3: Read existing user settings (if exists)
user_data = {}
if user_path.exists():
user_data = self._read_json(user_path)
if user_data is None:
audit_log(
"settings_merge",
"user_settings_parse_failed",
{
"user_path": str(user_path),
},
)
return MergeResult(
success=False,
message=f"Failed to parse user settings JSON: {user_path}",
details={"error": "Invalid JSON in user settings file"},
)
# Step 4: Merge dictionaries
merged_data = self._merge_dicts(user_data, template_data)
# Step 5: Merge hooks (track counts, migrate old hooks to unified)
merged_hooks, hooks_added, hooks_preserved, hooks_migrated = self._merge_hooks(
user_data.get("hooks", {}), template_data.get("hooks", {})
)
merged_data["hooks"] = merged_hooks
# Step 6: Write result (if not dry-run)
if write_result:
self._atomic_write(user_path, merged_data)
# Step 7: Audit log success
audit_log(
"settings_merge",
"merge_completed",
{
"user_path": str(user_path),
"template_path": str(template_path),
"hooks_added": hooks_added,
"hooks_preserved": hooks_preserved,
"hooks_migrated": hooks_migrated,
"write_result": write_result,
},
)
# Step 8: Return success
message = "Settings merged successfully"
if not user_path.exists() or not user_data:
message = "Settings created from template"
elif hooks_migrated > 0:
message = f"Settings merged successfully (migrated {hooks_migrated} hooks to unified)"
return MergeResult(
success=True,
message=message,
settings_path=str(user_path),
hooks_added=hooks_added,
hooks_preserved=hooks_preserved,
hooks_migrated=hooks_migrated,
details={
"template_path": str(template_path),
"write_result": write_result,
},
)
except Exception as e:
# Catch-all for unexpected errors
audit_log(
"settings_merge",
"unexpected_error",
{
"template_path": str(template_path),
"user_path": str(user_path),
"error": str(e),
},
)
return MergeResult(
success=False,
message=f"Settings merge failed: {e}",
details={"error": str(e)},
)
def _read_json(self, path: Path) -> Optional[Dict[str, Any]]:
"""Read and parse JSON file.
Args:
path: Path to JSON file
Returns:
Parsed JSON as dictionary, or None if parse fails
"""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
# Return None on parse error (caller handles)
return None
def _merge_dicts(self, base: Dict, updates: Dict) -> Dict:
"""Deep merge two dictionaries (updates override base).
This performs a recursive deep merge where:
- Nested dictionaries are merged recursively
- Lists are replaced (not merged)
- Scalar values from updates override base
Args:
base: Base dictionary (user settings)
updates: Updates dictionary (template settings)
Returns:
Merged dictionary
"""
merged = base.copy()
for key, value in updates.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
# Recursively merge nested dictionaries
# Special case: Don't deep merge "hooks" here (handled separately)
if key == "hooks":
# Skip hooks - they're merged separately with duplicate detection
continue
merged[key] = self._merge_dicts(merged[key], value)
else:
# Override with update value (lists, scalars, new keys)
# But don't override "hooks" key here (handled separately)
if key != "hooks":
merged[key] = value
return merged
def _merge_hooks(
self, existing: Dict, new: Dict
) -> Tuple[Dict, int, int, int]:
"""Merge hooks by lifecycle event, avoiding duplicates.
This merges hooks with special logic:
- Merge by lifecycle event (PreToolUse, PostToolUse, etc.)
- Avoid duplicate hooks (by exact dict comparison)
- Preserve existing hooks (user customizations)
- Issue #144: Migrate old hooks to unified hooks (remove replaced hooks)
Args:
existing: Existing hooks dictionary (user hooks)
new: New hooks dictionary (template hooks)
Returns:
Tuple of (merged_hooks, hooks_added, hooks_preserved, hooks_migrated)
"""
merged_hooks = {}
hooks_added = 0
hooks_preserved = 0
hooks_migrated = 0
# Issue #144: Build set of old hooks to remove (based on unified hooks in new)
hooks_to_remove = set()
for lifecycle, matcher_configs in new.items():
for config in matcher_configs:
if isinstance(config, dict):
# Handle nested structure: {"matcher": "*", "hooks": [...]}
inner_hooks = config.get("hooks", [config]) # Fallback to config itself if no nested hooks
for hook in inner_hooks:
if isinstance(hook, dict):
cmd = hook.get("command", "")
# Check if this is a unified hook
for unified_hook, replaced_hooks in UNIFIED_HOOK_REPLACEMENTS.items():
if unified_hook in cmd:
# Mark old hooks for removal
hooks_to_remove.update(replaced_hooks)
# Start with existing hooks (preserve user customizations, migrate old hooks)
for lifecycle, matcher_configs in existing.items():
filtered_configs = []
for config in matcher_configs:
if isinstance(config, dict):
# Handle nested structure: {"matcher": "*", "hooks": [...]}
if "hooks" in config:
# Nested format - filter inner hooks
filtered_inner = []
for hook in config.get("hooks", []):
if isinstance(hook, dict):
cmd = hook.get("command", "")
should_remove = False
for old_hook in hooks_to_remove:
if old_hook in cmd:
should_remove = True
hooks_migrated += 1
break
if not should_remove:
filtered_inner.append(hook)
hooks_preserved += 1
else:
filtered_inner.append(hook)
hooks_preserved += 1
# Only add config if it still has hooks
if filtered_inner:
filtered_configs.append({**config, "hooks": filtered_inner})
else:
# Flat format - check command directly
cmd = config.get("command", "")
should_remove = False
for old_hook in hooks_to_remove:
if old_hook in cmd:
should_remove = True
hooks_migrated += 1
break
if not should_remove:
filtered_configs.append(config)
hooks_preserved += 1
else:
filtered_configs.append(config)
hooks_preserved += 1
merged_hooks[lifecycle] = filtered_configs
# Add new hooks from template
for lifecycle, hooks in new.items():
if lifecycle not in merged_hooks:
# New lifecycle event - add all hooks
merged_hooks[lifecycle] = hooks.copy()
hooks_added += len(hooks)
else:
# Existing lifecycle event - merge without duplicates
existing_list = merged_hooks[lifecycle]
for hook in hooks:
if hook not in existing_list:
existing_list.append(hook)
hooks_added += 1
return merged_hooks, hooks_added, hooks_preserved, hooks_migrated
def _atomic_write(self, path: Path, content: Dict) -> None:
"""Write JSON file atomically with secure permissions.
This uses tempfile + rename for atomic writes:
1. Create temp file in same directory
2. Write JSON to temp file
3. Set secure permissions (0o600)
4. Atomic rename to target path
Args:
path: Target path for JSON file
content: Dictionary to write as JSON
Raises:
OSError: If write fails
"""
# Ensure parent directory exists
path.parent.mkdir(parents=True, exist_ok=True)
# Create temp file in same directory (for atomic rename)
fd = None
temp_path = None
try:
fd, temp_path = tempfile.mkstemp(
dir=str(path.parent),
prefix=".settings-",
suffix=".json.tmp",
)
# Write JSON to temp file
json_content = json.dumps(content, indent=2, sort_keys=True)
os.write(fd, json_content.encode("utf-8"))
os.close(fd)
fd = None
# Set secure permissions (user-only read/write)
os.chmod(temp_path, 0o600)
# Atomic rename
os.rename(temp_path, path)
except OSError as e:
# Clean up temp file on error
if fd is not None:
try:
os.close(fd)
except OSError:
pass
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
# Re-raise with context
raise OSError(f"Failed to write settings atomically: {e}") from e
def log_audit(event: str, context: Dict[str, Any]) -> None:
"""Alias for audit_log (backward compatibility with test mocks).
Args:
event: Event description
context: Event context
"""
audit_log("settings_merge", event, context)