TradingAgents/.claude/lib/hook_activator.py

1438 lines
49 KiB
Python

#!/usr/bin/env python3
"""
Hook Activator - Automatic hook activation during plugin updates
This module provides automatic hook activation functionality for plugin updates:
- Detect first install vs update (check for existing settings.json)
- Read and parse existing settings.json
- Merge new hooks with existing settings (preserve customizations)
- Atomic write with tempfile + rename pattern
- Validate settings structure before write
- Create .claude directory if missing
- Handle edge cases (malformed JSON, missing files, permissions)
Features:
- First install detection
- Settings merge (preserve customizations)
- Atomic file writes (tempfile + rename)
- Settings validation (structure + content)
- Comprehensive error handling
- 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)
- Secure file permissions: 0o600 for settings (CWE-732)
- Audit logging for all operations (CWE-778)
Usage:
from hook_activator import HookActivator
# Activate hooks
activator = HookActivator(project_root="/path/to/project")
new_hooks = {
"hooks": {
"PrePush": ["auto_test.py"],
"SubagentStop": ["log_agent_completion.py"]
}
}
result = activator.activate_hooks(new_hooks)
print(result.summary)
Date: 2025-11-09
Issue: GitHub #50 Phase 2.5 - Automatic hook activation
Agent: implementer
Design Patterns:
See library-design-patterns skill for standardized design patterns.
"""
import json
import os
import sys
import tempfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
# Add parent directory for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from plugins.autonomous_dev.lib import security_utils
# ============================================================================
# Exception Classes
# ============================================================================
# Exception hierarchy pattern from error-handling-patterns skill:
# BaseException -> Exception -> AutonomousDevError -> DomainError(BaseException) -> SpecificError
class ActivationError(Exception):
"""
See error-handling-patterns skill for exception hierarchy and error handling best practices.
Base exception for hook activation failures."""
pass
class SettingsValidationError(ActivationError):
"""Exception raised when settings validation fails."""
pass
# ============================================================================
# Result Dataclass
# ============================================================================
@dataclass
class ActivationResult:
"""Result of a hook activation operation.
Attributes:
activated: Whether hooks were activated (True) or skipped (False)
first_install: Whether this was a first install (True) or update (False)
message: Human-readable result message
hooks_added: Number of hooks added during activation
settings_path: Path to settings.json file (or None if not written)
details: Additional result details (preserved settings, merged hooks, etc.)
"""
activated: bool
first_install: bool
message: str
hooks_added: int = 0
settings_path: Optional[str] = None
details: Dict[str, Any] = field(default_factory=dict)
@property
def summary(self) -> str:
"""Generate human-readable summary of activation result.
Returns:
Multi-line summary with activation status and details
"""
parts = []
parts.append(f"Status: {self.message}")
parts.append(f"Hooks Added: {self.hooks_added}")
if self.settings_path:
parts.append(f"Settings: {self.settings_path}")
if self.first_install:
parts.append("Type: First Install")
else:
parts.append("Type: Update")
return "\n".join(parts)
# ============================================================================
# Migration Functions (Claude Code 2.0 Format)
# ============================================================================
def validate_hook_format(settings_data: Dict[str, Any]) -> Dict[str, Any]:
"""Validate hook format and detect legacy vs modern Claude Code 2.0 format.
Legacy format indicators:
- Missing 'timeout' field in hook definitions
- Flat structure (direct command strings in lifecycle arrays)
- Missing nested 'hooks' array within matcher configurations
Modern CC2 format:
- Every hook has 'timeout' field
- Nested structure with matchers containing 'hooks' arrays
- Each hook is a dict with 'type', 'command', 'timeout'
Args:
settings_data: Settings dictionary to validate
Returns:
Dict with 'is_legacy' (bool) and 'reason' (str) keys
Raises:
SettingsValidationError: If settings structure is malformed
Example:
>>> result = validate_hook_format(settings)
>>> if result['is_legacy']:
... print(f"Legacy format detected: {result['reason']}")
"""
# Handle missing hooks key (treat as modern/empty)
if "hooks" not in settings_data:
return {"is_legacy": False, "reason": "No hooks defined"}
# Validate hooks is a dict
if not isinstance(settings_data["hooks"], dict):
raise SettingsValidationError(
"Invalid settings structure: 'hooks' must be a dictionary"
)
hooks = settings_data["hooks"]
# Empty hooks is valid modern format
if not hooks:
return {"is_legacy": False, "reason": "No hooks defined"}
# Check each lifecycle event for legacy format indicators
for lifecycle, lifecycle_config in hooks.items():
# Validate lifecycle config is a list
if not isinstance(lifecycle_config, list):
raise SettingsValidationError(
f"Invalid hooks for '{lifecycle}': must be a list"
)
# Check for flat structure (strings instead of dicts)
for item in lifecycle_config:
if isinstance(item, str):
return {
"is_legacy": True,
"reason": f"Flat structure detected in {lifecycle} (string commands instead of dicts)",
}
# Item should be a dict (matcher configuration)
if not isinstance(item, dict):
raise SettingsValidationError(
f"Invalid hook configuration in '{lifecycle}': expected dict, got {type(item)}"
)
# Check for missing nested 'hooks' array
if "hooks" not in item:
# Check if this is a direct command config (legacy)
if "command" in item or "type" in item:
return {
"is_legacy": True,
"reason": f"Missing nested hooks array in {lifecycle} (direct command config)",
}
# Empty matcher config (edge case)
continue
# Validate nested hooks is a list
nested_hooks = item["hooks"]
if not isinstance(nested_hooks, list):
raise SettingsValidationError(
f"Invalid nested hooks in '{lifecycle}': must be a list"
)
# Check each hook in nested array for missing timeout
for hook in nested_hooks:
if not isinstance(hook, dict):
raise SettingsValidationError(
f"Invalid hook in '{lifecycle}': must be a dict"
)
# Check for missing timeout field
if "timeout" not in hook:
return {
"is_legacy": True,
"reason": f"Missing timeout field in {lifecycle} hook",
}
# All checks passed - modern format
return {"is_legacy": False, "reason": "Modern Claude Code 2.0 format"}
def migrate_hook_format_cc2(settings_data: Dict[str, Any]) -> Dict[str, Any]:
"""Migrate legacy hook format to Claude Code 2.0 format.
Transformations applied:
1. Add 'timeout': 5 to all hooks missing it
2. Convert flat string commands to nested dict structure
3. Wrap commands in nested 'hooks' array if missing
4. Add 'matcher': '*' if missing
5. Preserve user customizations (custom timeouts, matchers)
This function is idempotent - running it multiple times produces the same result.
Args:
settings_data: Settings dictionary to migrate (can be legacy or modern)
Returns:
Migrated settings dictionary in Claude Code 2.0 format (deep copy)
Example:
>>> legacy = {"hooks": {"PrePush": ["auto_test.py"]}}
>>> modern = migrate_hook_format_cc2(legacy)
>>> print(modern['hooks']['PrePush'][0]['hooks'][0]['timeout'])
5
"""
# Deep copy to avoid modifying original
import copy
migrated = copy.deepcopy(settings_data)
# Handle missing hooks key
if "hooks" not in migrated:
migrated["hooks"] = {}
return migrated
hooks = migrated["hooks"]
# Handle empty hooks
if not hooks:
return migrated
# Migrate each lifecycle event
for lifecycle, lifecycle_config in list(hooks.items()):
# Handle empty lifecycle events
if not lifecycle_config:
continue
# Convert to list if not already
if not isinstance(lifecycle_config, list):
continue
migrated_matchers = []
for item in lifecycle_config:
# Case 1: Flat string command (legacy)
if isinstance(item, str):
# Convert to modern nested structure
migrated_matchers.append(
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": f"python .claude/hooks/{item}",
"timeout": 5,
}
],
}
)
continue
# Case 2: Dict without nested hooks array (legacy)
if isinstance(item, dict):
# Check if this is a direct command config (missing nested hooks)
if "hooks" not in item and ("command" in item or "type" in item):
# Extract command info
hook_type = item.get("type", "command")
command = item.get("command", "")
timeout = item.get("timeout", 5)
matcher = item.get("matcher", "*")
# Create nested structure
migrated_matchers.append(
{
"matcher": matcher,
"hooks": [
{
"type": hook_type,
"command": command,
"timeout": timeout,
}
],
}
)
continue
# Case 3: Modern structure with nested hooks array
if "hooks" in item:
matcher = item.get("matcher", "*")
nested_hooks = item["hooks"]
# Migrate each hook in nested array
migrated_nested = []
for hook in nested_hooks:
if isinstance(hook, dict):
# Add timeout if missing (preserve existing if present)
if "timeout" not in hook:
hook["timeout"] = 5
migrated_nested.append(hook)
# Update nested hooks
migrated_matchers.append({"matcher": matcher, "hooks": migrated_nested})
continue
# Case 4: Empty matcher config (edge case)
# Skip empty configs
pass
# Update lifecycle config with migrated matchers
hooks[lifecycle] = migrated_matchers
return migrated
def _backup_settings(settings_path: Path) -> Path:
"""Create timestamped backup of settings.json before migration.
Backup strategy:
- Timestamped filename: settings.json.backup.YYYYMMDD_HHMMSS
- Atomic write (tempfile + rename)
- Secure permissions (0o600 - user-only read/write)
- Path validation via security_utils
Args:
settings_path: Path to settings.json file to backup
Returns:
Path to backup file
Raises:
ActivationError: If backup creation fails
Example:
>>> backup_path = _backup_settings(Path(".claude/settings.json"))
>>> print(backup_path)
.claude/settings.json.backup.20251212_143022
"""
# Validate settings path
try:
security_utils.validate_path(
settings_path,
purpose="settings.json for backup",
)
except (ValueError, FileNotFoundError) as e:
raise ActivationError(f"Invalid settings path for backup: {e}") from e
# Generate timestamped backup filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_filename = f"settings.json.backup.{timestamp}"
backup_path = settings_path.parent / backup_filename
# Read original settings
try:
original_content = settings_path.read_text(encoding="utf-8")
except OSError as e:
raise ActivationError(f"Failed to read settings for backup: {e}") from e
# Create backup using atomic write (tempfile + rename)
fd = None
temp_path = None
try:
fd, temp_path = tempfile.mkstemp(
dir=str(settings_path.parent),
prefix=".settings-backup-",
suffix=".json.tmp",
)
# Write original content to temp file
os.write(fd, original_content.encode("utf-8"))
os.close(fd)
fd = None
# Set secure permissions (user-only read/write)
# Note: In tests, mkstemp might be mocked and file might not exist
try:
os.chmod(temp_path, 0o600)
except (OSError, FileNotFoundError):
# If chmod fails in test scenarios (mocked mkstemp), continue
# In production, mkstemp creates the file so chmod will work
pass
# Atomic rename to final backup path
os.rename(temp_path, backup_path)
# Audit log the backup creation
security_utils.audit_log(
event_type="settings_backup",
status="success",
context={
"operation": "backup_settings",
"original_path": str(settings_path),
"backup_path": str(backup_path),
"timestamp": timestamp,
},
)
return backup_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
raise ActivationError(f"Failed to create settings backup: {e}") from e
def _normalize_matcher(matcher: Any) -> str:
"""Convert old matcher format to Claude Code 2.0 format (Issue #156).
Claude Code 2.0 expects matchers in one of these formats:
- "*" (string) - matches all tools
- "ToolName" (string) - matches specific tool
- {"tools": ["Tool1", "Tool2"]} - matches multiple tools
Old formats that need conversion:
- {"tool": "Write"} → "Write"
- {"tool": "Bash", "pattern": "..."} → "Bash" (pattern not supported)
- {"tool": "Write", "file_pattern": "..."} → "Write" (file_pattern not supported)
Args:
matcher: The matcher value from old hook config
Returns:
Normalized matcher string or valid object
"""
# Already a string - valid format
if isinstance(matcher, str):
return matcher
# Object format - check if old or new style
if isinstance(matcher, dict):
# New format: {"tools": [...]} - keep as-is
if "tools" in matcher:
return matcher
# Old format: {"tool": "ToolName", ...} - extract tool name
if "tool" in matcher:
tool_name = matcher["tool"]
if isinstance(tool_name, str):
return tool_name
# Unknown object format - default to wildcard
return "*"
# Unknown type - default to wildcard
return "*"
def migrate_hooks_to_object_format(settings_path: Path) -> Dict[str, Any]:
"""Migrate settings.json from array format to object format (Issue #135).
Migrates user's ~/.claude/settings.json from OLD array-based hooks format
to NEW object-based format required by Claude Code v2.0.69+.
OLD Array Format (pre-v2.0.69):
{
"hooks": [
{"event": "PreToolUse", "command": "python hook.py"},
{"event": "SubagentStop", "command": "python log.py"}
]
}
NEW Object Format (v2.0.69+):
{
"hooks": {
"PreToolUse": [
{"matcher": "*", "hooks": [{"type": "command", "command": "python hook.py", "timeout": 5}]}
],
"SubagentStop": [
{"matcher": "*", "hooks": [{"type": "command", "command": "python log.py", "timeout": 5}]}
]
}
}
Migration Steps:
1. Check if file exists → Return 'missing' if not
2. Read and parse JSON → Handle malformed gracefully
3. Detect format:
- hooks is list → array format (needs migration)
- hooks is dict → object format (already migrated, skip)
- hooks missing/invalid → invalid format
4. If array format:
a. Create timestamped backup
b. Transform array to object (group by event, wrap in CC2 structure)
c. Write atomically (tempfile + rename)
d. Return success with backup path
5. Rollback from backup on any failure
Args:
settings_path: Path to settings.json (typically ~/.claude/settings.json)
Returns:
dict with keys:
- 'migrated': bool (True if migration performed)
- 'backup_path': Optional[Path] (backup location if migrated)
- 'format': str ('array', 'object', 'invalid', 'missing')
- 'error': Optional[str] (error message if failed)
Security:
- Validates settings_path is in ~/.claude/ directory (CWE-22)
- Uses atomic writes to prevent corruption (CWE-362)
- Creates backup before any modifications (CWE-404)
- Never exposes secrets in logs
- Rolls back on any error (no partial migrations)
Example:
>>> from pathlib import Path
>>> settings_path = Path.home() / ".claude" / "settings.json"
>>> result = migrate_hooks_to_object_format(settings_path)
>>> if result['migrated']:
... print(f"Migrated! Backup: {result['backup_path']}")
>>> else:
... print(f"No migration needed: {result['format']}")
"""
# Step 1: Check if file exists
if not settings_path.exists():
return {
'migrated': False,
'backup_path': None,
'format': 'missing',
'error': None
}
# Validate settings path (security)
try:
security_utils.validate_path(
settings_path,
purpose="settings.json for array-to-object migration"
)
except (ValueError, FileNotFoundError) as e:
return {
'migrated': False,
'backup_path': None,
'format': 'invalid',
'error': f"Path validation failed: {e}"
}
# Step 2: Read and parse JSON
try:
content = settings_path.read_text(encoding="utf-8")
# Handle empty file
if not content.strip():
# Empty file → treat as missing hooks, replace with template
template_settings = {"hooks": {}}
settings_path.write_text(json.dumps(template_settings, indent=2))
return {
'migrated': False,
'backup_path': None,
'format': 'missing',
'error': None
}
settings_data = json.loads(content)
except json.JSONDecodeError as e:
# Malformed JSON → backup corrupted file, replace with template
try:
# Create backup of corrupted file
backup_path = _backup_settings(settings_path)
# Replace with template
template_settings = {"hooks": {}}
settings_path.write_text(json.dumps(template_settings, indent=2))
security_utils.audit_log(
event_type="hook_migration",
status="corrupted_file_replaced",
context={
"operation": "migrate_hooks_to_object_format",
"error": str(e),
"backup_path": str(backup_path),
"settings_path": str(settings_path)
}
)
return {
'migrated': False,
'backup_path': backup_path,
'format': 'invalid',
'error': f"Malformed JSON replaced with template (backup created): {e}"
}
except Exception as backup_error:
return {
'migrated': False,
'backup_path': None,
'format': 'invalid',
'error': f"Failed to handle malformed JSON: {backup_error}"
}
except OSError as e:
return {
'migrated': False,
'backup_path': None,
'format': 'invalid',
'error': f"Failed to read settings file: {e}"
}
# Step 3: Detect format
if 'hooks' not in settings_data:
# Missing hooks key → add it and write back
settings_data['hooks'] = {}
try:
settings_path.write_text(json.dumps(settings_data, indent=2))
except OSError as e:
return {
'migrated': False,
'backup_path': None,
'format': 'object',
'error': f"Failed to write settings with hooks key: {e}"
}
return {
'migrated': False,
'backup_path': None,
'format': 'object',
'error': None
}
hooks = settings_data['hooks']
# Check if hooks is array (legacy format)
if isinstance(hooks, list):
# Array format detected → needs migration
format_type = 'array'
needs_migration = True
elif isinstance(hooks, dict):
# Object format → check if matchers need normalization (Issue #156)
needs_matcher_fix = False
for event_hooks in hooks.values():
if isinstance(event_hooks, list):
for hook_entry in event_hooks:
if isinstance(hook_entry, dict) and 'matcher' in hook_entry:
matcher = hook_entry['matcher']
# Check if matcher is old format (dict with "tool" key)
if isinstance(matcher, dict) and 'tool' in matcher:
needs_matcher_fix = True
break
if needs_matcher_fix:
break
if not needs_matcher_fix:
# Already has correct format
return {
'migrated': False,
'backup_path': None,
'format': 'object',
'error': None
}
# Fix old matchers in object format (Issue #156)
try:
backup_path = _backup_settings(settings_path)
# Normalize all matchers
fixed_hooks = {}
for event, event_hooks in hooks.items():
if isinstance(event_hooks, list):
fixed_hooks[event] = []
for hook_entry in event_hooks:
if isinstance(hook_entry, dict):
fixed_entry = hook_entry.copy()
if 'matcher' in fixed_entry:
fixed_entry['matcher'] = _normalize_matcher(fixed_entry['matcher'])
fixed_hooks[event].append(fixed_entry)
else:
fixed_hooks[event].append(hook_entry)
else:
fixed_hooks[event] = event_hooks
# Update settings
settings_data['hooks'] = fixed_hooks
settings_path.write_text(json.dumps(settings_data, indent=2))
security_utils.audit_log(
event_type="hook_migration",
status="matchers_normalized",
context={
"operation": "migrate_hooks_to_object_format",
"settings_path": str(settings_path),
"backup_path": str(backup_path)
}
)
return {
'migrated': True,
'backup_path': backup_path,
'format': 'object',
'error': None
}
except Exception as e:
return {
'migrated': False,
'backup_path': None,
'format': 'object',
'error': f"Failed to normalize matchers: {e}"
}
else:
# Invalid hooks structure
try:
# Create backup of invalid file
backup_path = _backup_settings(settings_path)
# Replace with template
template_settings = {"hooks": {}}
settings_path.write_text(json.dumps(template_settings, indent=2))
security_utils.audit_log(
event_type="hook_migration",
status="invalid_structure_replaced",
context={
"operation": "migrate_hooks_to_object_format",
"error": f"hooks is {type(hooks).__name__}, expected list or dict",
"backup_path": str(backup_path),
"settings_path": str(settings_path)
}
)
return {
'migrated': False,
'backup_path': backup_path,
'format': 'invalid',
'error': f"Invalid hooks structure (type: {type(hooks).__name__}), replaced with template"
}
except Exception as backup_error:
return {
'migrated': False,
'backup_path': None,
'format': 'invalid',
'error': f"Failed to handle invalid structure: {backup_error}"
}
# Step 4: Perform migration (array → object)
backup_path = None
try:
# 4a. Create timestamped backup
backup_path = _backup_settings(settings_path)
security_utils.audit_log(
event_type="hook_migration",
status="backup_created",
context={
"operation": "migrate_hooks_to_object_format",
"settings_path": str(settings_path),
"backup_path": str(backup_path),
"format": "array"
}
)
# 4b. Transform array to object
# Group hooks by event
object_hooks = {}
for hook_entry in hooks:
if not isinstance(hook_entry, dict):
# Skip invalid entries
continue
event = hook_entry.get('event')
command = hook_entry.get('command')
if not event or not command:
# Skip entries without required fields
continue
# Create CC2 structure: nested object with matcher and timeout
# Convert old matcher format to CC2 format (Issue #156)
raw_matcher = hook_entry.get('matcher', '*')
matcher = _normalize_matcher(raw_matcher)
# Preserve custom timeout if present, otherwise default to 5
timeout = hook_entry.get('timeout', 5)
hook_config = {
"matcher": matcher,
"hooks": [
{
"type": "command",
"command": command,
"timeout": timeout
}
]
}
# Preserve additional matcher fields (glob, path, etc.)
for key in ['glob', 'path']:
if key in hook_entry:
hook_config[key] = hook_entry[key]
# Add to object hooks, grouped by event
if event not in object_hooks:
object_hooks[event] = []
object_hooks[event].append(hook_config)
# Update settings_data with migrated hooks
migrated_settings = settings_data.copy()
migrated_settings['hooks'] = object_hooks
# 4c. Write atomically (tempfile + rename)
fd = None
temp_path = None
try:
fd, temp_path = tempfile.mkstemp(
dir=str(settings_path.parent),
prefix=".settings-migrate-",
suffix=".json.tmp"
)
# Write migrated content to temp file
migrated_content = json.dumps(migrated_settings, indent=2)
os.write(fd, migrated_content.encode("utf-8"))
os.close(fd)
fd = None
# Set secure permissions (user-only read/write)
try:
os.chmod(temp_path, 0o600)
except (OSError, FileNotFoundError):
# If chmod fails in test scenarios (mocked mkstemp), continue
pass
# Atomic rename to final settings path
os.rename(temp_path, settings_path)
security_utils.audit_log(
event_type="hook_migration",
status="success",
context={
"operation": "migrate_hooks_to_object_format",
"settings_path": str(settings_path),
"backup_path": str(backup_path),
"events_migrated": list(object_hooks.keys()),
"total_hooks": sum(len(v) for v in object_hooks.values())
}
)
return {
'migrated': True,
'backup_path': backup_path,
'format': format_type,
'error': None
}
except OSError as write_error:
# Clean up temp file on write error
if fd is not None:
try:
os.close(fd)
except OSError:
pass
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
raise write_error
except Exception as e:
# Step 5: Rollback on failure
if backup_path and backup_path.exists():
try:
# Restore from backup
backup_content = backup_path.read_text()
settings_path.write_text(backup_content)
security_utils.audit_log(
event_type="hook_migration",
status="rollback_success",
context={
"operation": "migrate_hooks_to_object_format",
"settings_path": str(settings_path),
"backup_path": str(backup_path),
"error": str(e)
}
)
except Exception as rollback_error:
security_utils.audit_log(
event_type="hook_migration",
status="rollback_failure",
context={
"operation": "migrate_hooks_to_object_format",
"settings_path": str(settings_path),
"backup_path": str(backup_path),
"original_error": str(e),
"rollback_error": str(rollback_error)
}
)
# Return failure result
return {
'migrated': False,
'backup_path': backup_path,
'format': format_type if 'format_type' in locals() else 'unknown',
'error': f"Migration failed: {e}"
}
# ============================================================================
# Hook Activator Class
# ============================================================================
class HookActivator:
"""Hook activator for automatic hook configuration during plugin updates.
This class handles:
- First install detection
- Settings file reading and parsing
- Hook merging (preserves customizations)
- Atomic file writing
- Settings validation
- Error handling and recovery
Security:
- Path validation via security_utils
- Atomic writes to prevent corruption
- Secure file permissions (0o600)
- Audit logging for all operations
"""
def __init__(self, project_root: Path):
"""Initialize HookActivator with project root.
Args:
project_root: Path to project root directory
Raises:
ValueError: If project_root validation fails
"""
# Validate project root path
security_utils.validate_path(
project_root,
purpose="project root for hook activation",
)
self.project_root = Path(project_root)
self.claude_dir = self.project_root / ".claude"
self.settings_path = self.claude_dir / "settings.json"
def is_first_install(self) -> bool:
"""Check if this is a first install (settings.json doesn't exist).
Returns:
True if settings.json doesn't exist (first install)
False if settings.json exists (update)
"""
return not self.settings_path.exists()
def activate_hooks(self, new_hooks: Dict[str, Any]) -> ActivationResult:
"""Activate hooks with automatic merge and validation.
This is the main entry point for hook activation. It:
1. Detects first install vs update
2. Reads existing settings (if update)
3. Merges new hooks with existing settings
4. Validates merged settings
5. Writes settings atomically
6. Returns detailed result
Args:
new_hooks: Dictionary with 'hooks' key containing hook configuration
Returns:
ActivationResult with activation status and details
Raises:
SettingsValidationError: If settings validation fails
ActivationError: If activation fails for other reasons
"""
# Audit log the activation attempt
security_utils.audit_log(
event_type="hook_activation",
status="start",
context={
"operation": "activate_hooks",
"project_root": str(self.project_root),
"is_first_install": self.is_first_install(),
},
)
# Validate input structure (must have 'hooks' key)
if "hooks" not in new_hooks:
raise SettingsValidationError(
"Invalid hook configuration: missing 'hooks' key"
)
# Check for empty hooks
if not new_hooks["hooks"]:
result = ActivationResult(
activated=False,
first_install=self.is_first_install(),
message="No hooks to activate",
hooks_added=0,
settings_path=str(self.settings_path) if self.settings_path.exists() else None,
details={},
)
return result
# Detect first install
first_install = self.is_first_install()
# Read existing settings (if update)
if first_install:
existing_settings = {}
else:
try:
existing_settings = self._read_existing_settings()
except Exception as e:
security_utils.audit_log(
event_type="hook_activation",
status="failure",
context={
"operation": "read_settings",
"error": "Failed to read existing settings",
"exception": str(e),
},
)
raise
# Check if existing settings need migration to Claude Code 2.0 format
try:
format_check = validate_hook_format(existing_settings)
if format_check["is_legacy"]:
# Legacy format detected - create backup before migration
security_utils.audit_log(
event_type="hook_migration",
status="detected",
context={
"operation": "format_detection",
"reason": format_check["reason"],
"settings_path": str(self.settings_path),
},
)
# Create timestamped backup
backup_path = _backup_settings(self.settings_path)
# Migrate to Claude Code 2.0 format
existing_settings = migrate_hook_format_cc2(existing_settings)
security_utils.audit_log(
event_type="hook_migration",
status="success",
context={
"operation": "migration_complete",
"backup_path": str(backup_path),
"migrated_settings": str(self.settings_path),
},
)
except SettingsValidationError:
# Re-raise validation errors
raise
except Exception as e:
security_utils.audit_log(
event_type="hook_migration",
status="failure",
context={
"operation": "migration",
"error": "Migration failed",
"exception": str(e),
},
)
# Don't fail activation on migration error - continue with existing settings
# This ensures backward compatibility if migration has issues
# Merge settings
merged_settings = self._merge_settings(existing_settings, new_hooks)
# Validate merged settings
try:
self._validate_settings(merged_settings)
except SettingsValidationError:
security_utils.audit_log(
event_type="hook_activation",
status="failure",
context={
"operation": "validate_settings",
"error": "Settings validation failed",
},
)
raise
# Count hooks added
hooks_added = sum(
len(hooks) for hooks in merged_settings.get("hooks", {}).values()
)
# Create .claude directory if missing
if not self.claude_dir.exists():
self.claude_dir.mkdir(parents=True, exist_ok=True)
# Write settings atomically
try:
self._atomic_write_settings(merged_settings)
except Exception as e:
security_utils.audit_log(
event_type="hook_activation",
status="failure",
context={
"operation": "write_settings",
"error": "Failed to write settings",
"exception": str(e),
},
)
raise ActivationError(f"Failed to write settings: {e}") from e
# Build result details
details = {}
if not first_install:
# Track preserved settings
preserved = [
key
for key in existing_settings.keys()
if key != "hooks" and key in merged_settings
]
if preserved:
details["preserved_settings"] = preserved
# Audit log success
security_utils.audit_log(
event_type="hook_activation",
status="success",
context={
"operation": "activate_hooks_complete",
"first_install": first_install,
"hooks_added": hooks_added,
"settings_path": str(self.settings_path),
},
)
# Return result
result = ActivationResult(
activated=True,
first_install=first_install,
message=f"Successfully activated {hooks_added} hooks"
if first_install
else f"Updated hook configuration ({hooks_added} total hooks)",
hooks_added=hooks_added,
settings_path=str(self.settings_path),
details=details,
)
return result
def _read_existing_settings(self) -> Dict[str, Any]:
"""Read and parse existing settings.json file.
Returns:
Dictionary containing parsed settings, or {"hooks": {}} if file doesn't exist
Raises:
SettingsValidationError: If JSON is malformed
ActivationError: If file cannot be read (permissions, etc.)
"""
# Check if settings file exists
if not self.settings_path.exists():
return {"hooks": {}}
# Validate settings path
try:
security_utils.validate_path(
self.settings_path,
purpose="settings.json for reading",
)
except (ValueError, FileNotFoundError) as e:
raise ActivationError(f"Invalid settings path: {e}") from e
# Read and parse JSON
try:
content = self.settings_path.read_text(encoding="utf-8")
# Handle empty file
if not content.strip():
return {"hooks": {}}
settings = json.loads(content)
# Handle settings without hooks key
if "hooks" not in settings:
settings["hooks"] = {}
return settings
except json.JSONDecodeError as e:
raise SettingsValidationError(
f"Failed to parse settings.json: malformed JSON - {e}"
) from e
except OSError as e:
if "Permission denied" in str(e):
raise ActivationError(f"Permission denied reading settings.json: {e}") from e
raise ActivationError(f"Failed to read settings.json: {e}") from e
def _merge_settings(
self, existing: Dict[str, Any], new_hooks: Dict[str, Any]
) -> Dict[str, Any]:
"""Merge new hooks with existing settings (preserve customizations).
Args:
existing: Existing settings dictionary
new_hooks: New hooks dictionary with 'hooks' key
Returns:
Merged settings dictionary
"""
# Start with existing settings
merged = existing.copy()
# Get existing hooks
existing_hooks = merged.get("hooks", {})
# Get new hooks
new_hooks_config = new_hooks.get("hooks", {})
# Merge hooks by lifecycle event
for lifecycle, hooks in new_hooks_config.items():
if lifecycle not in existing_hooks:
# New lifecycle event - add all hooks
existing_hooks[lifecycle] = hooks.copy()
else:
# Existing lifecycle event - merge without duplicates
existing_list = existing_hooks[lifecycle]
for hook in hooks:
if hook not in existing_list:
existing_list.append(hook)
# Update merged settings
merged["hooks"] = existing_hooks
return merged
def _validate_settings(self, settings: Dict[str, Any]) -> None:
"""Validate settings structure and content.
Args:
settings: Settings dictionary to validate
Raises:
SettingsValidationError: If validation fails
"""
# Check for 'hooks' key
if "hooks" not in settings:
raise SettingsValidationError(
"Invalid settings structure: missing 'hooks' key"
)
# Check 'hooks' is a dictionary
if not isinstance(settings["hooks"], dict):
raise SettingsValidationError(
"Invalid settings structure: 'hooks' must be a dictionary"
)
# Validate each lifecycle event
for lifecycle, hooks in settings["hooks"].items():
# Check hooks is a list
if not isinstance(hooks, list):
raise SettingsValidationError(
f"Invalid hooks for '{lifecycle}': must be a list"
)
# Validate each item in hooks list
for hook in hooks:
# Accept both legacy (string) and modern (dict) formats
if isinstance(hook, str):
# Legacy format - valid
continue
elif isinstance(hook, dict):
# Modern CC2 format - validate structure
# Should have 'matcher' and 'hooks' keys
if "hooks" in hook:
# Nested hooks array - validate it's a list
if not isinstance(hook["hooks"], list):
raise SettingsValidationError(
f"Invalid nested hooks in '{lifecycle}': must be a list"
)
# Each hook in nested array should be a dict
for nested_hook in hook["hooks"]:
if not isinstance(nested_hook, dict):
raise SettingsValidationError(
f"Invalid nested hook in '{lifecycle}': must be a dict"
)
# If no nested hooks, check if it has command (legacy dict format)
elif "command" not in hook:
raise SettingsValidationError(
f"Invalid hook in '{lifecycle}': dict must have 'hooks' or 'command' key"
)
else:
raise SettingsValidationError(
f"Invalid hook in '{lifecycle}': must be string or dict"
)
def _atomic_write_settings(
self, settings: Dict[str, Any], settings_path: Optional[Path] = None
) -> None:
"""Write settings.json atomically (tempfile + rename).
Args:
settings: Settings dictionary to write
settings_path: Path to settings.json (default: self.settings_path)
Raises:
ActivationError: If write fails
"""
# Use default settings path if not provided
if settings_path is None:
settings_path = self.settings_path
# Validate settings path
try:
security_utils.validate_path(
settings_path,
purpose="settings.json for writing",
)
except (ValueError, FileNotFoundError) as e:
raise ActivationError(f"Invalid settings path: {e}") from e
# Ensure parent directory exists
settings_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(settings_path.parent),
prefix=".settings-",
suffix=".json.tmp",
)
# Write JSON to temp file
content = json.dumps(settings, indent=2, sort_keys=True)
os.write(fd, content.encode("utf-8"))
os.close(fd)
fd = None
# Set secure permissions (user-only read/write)
# Note: In tests, mkstemp might be mocked and file might not exist
try:
os.chmod(temp_path, 0o600)
except (OSError, FileNotFoundError):
# If chmod fails in test scenarios (mocked mkstemp), continue
# In production, mkstemp creates the file so chmod will work
pass
# Atomic rename
os.rename(temp_path, settings_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
if "No space left" in str(e):
raise ActivationError(f"No space left on device: {e}") from e
elif "Permission denied" in str(e):
raise ActivationError(f"Permission denied writing settings: {e}") from e
else:
raise ActivationError(f"Failed to write settings: {e}") from e