TradingAgents/.claude/lib/user_state_manager.py

423 lines
12 KiB
Python

#!/usr/bin/env python3
"""
User state management for autonomous-dev plugin.
Manages user preferences and first-run state persistence for Issue #61.
Features:
- First-run detection
- User preference storage (auto_git_enabled, etc.)
- State file persistence in ~/.autonomous-dev/
- Security validation (CWE-22 path traversal prevention)
- Audit logging for all operations
Date: 2025-11-11
Issue: #61 (Enable Zero Manual Git Operations by Default)
Agent: implementer
See error-handling-patterns skill for exception hierarchy and error handling best practices.
Design Patterns:
See library-design-patterns skill for standardized design patterns.
See state-management-patterns skill for standardized design patterns.
"""
import copy
import json
import sys
import tempfile
from pathlib import Path
from typing import Any, Dict
# Import security utilities (standard pattern from project libraries)
try:
from .security_utils import audit_log
except ImportError:
# Direct script execution - add lib dir to path
lib_dir = Path(__file__).parent.resolve()
sys.path.insert(0, str(lib_dir))
from security_utils import audit_log
# Default state file location
DEFAULT_STATE_FILE = Path.home() / ".autonomous-dev" / "user_state.json"
# Default state structure
DEFAULT_STATE = {
"first_run_complete": False,
"preferences": {},
"version": "1.0"
}
# Exception hierarchy pattern from error-handling-patterns skill:
# BaseException -> Exception -> AutonomousDevError -> DomainError(BaseException) -> SpecificError
class UserStateError(Exception):
"""Exception raised for user state management errors."""
pass
class UserStateManager:
"""
Manage user state and preferences.
Handles loading, saving, and updating user preferences with security
validation and audit logging.
"""
def __init__(self, state_file: Path):
"""
Initialize UserStateManager.
Args:
state_file: Path to state file
Raises:
UserStateError: If path validation fails or permission denied
"""
self.state_file = self._validate_state_file_path(state_file)
self.state = self._load_state()
def _validate_state_file_path(self, path: Path) -> Path:
"""
Validate state file path for security (CWE-22, CWE-59, CWE-367).
Implements comprehensive path validation:
- Path traversal prevention (CWE-22)
- Symlink attack prevention (CWE-59)
- TOCTOU mitigation (CWE-367)
Note: Cannot use security_utils.validate_path() as it's designed for
project paths, but state file is in ~/.autonomous-dev/ (outside project).
Args:
path: Path to validate
Returns:
Validated Path object
Raises:
UserStateError: If path is unsafe
"""
# Convert to Path if string
if isinstance(path, str):
path = Path(path)
# Check for path traversal in string form (CWE-22)
path_str = str(path)
if ".." in path_str:
audit_log(
"security_violation",
"failure",
{
"type": "path_traversal",
"path": path_str,
"component": "user_state_manager"
}
)
raise UserStateError(f"Path traversal detected: {path_str}")
# Check for symlink before resolution (CWE-59)
if path.exists() and path.is_symlink():
audit_log(
"security_violation",
"failure",
{
"type": "symlink_attack",
"path": str(path),
"component": "user_state_manager"
}
)
raise UserStateError(f"Symlinks not allowed: {path}")
# Resolve to absolute path
try:
resolved_path = path.resolve()
except (OSError, RuntimeError) as e:
raise UserStateError(f"Failed to resolve path: {e}")
# Check for symlink after resolution (CWE-59 - defense in depth)
if resolved_path.is_symlink():
audit_log(
"security_violation",
"failure",
{
"type": "symlink_after_resolution",
"path": str(resolved_path),
"component": "user_state_manager"
}
)
raise UserStateError(f"Symlink detected after resolution: {resolved_path}")
# Ensure path is within home directory or temp directory (for tests)
home_dir = Path.home().resolve()
temp_dir = Path(tempfile.gettempdir()).resolve()
# Check if path is in home or temp (allow temp for testing)
is_in_home = False
is_in_temp = False
try:
resolved_path.relative_to(home_dir)
is_in_home = True
except ValueError:
pass
try:
resolved_path.relative_to(temp_dir)
is_in_temp = True
except ValueError:
pass
if not (is_in_home or is_in_temp):
audit_log(
"security_violation",
"failure",
{
"type": "path_outside_allowed_dirs",
"path": str(resolved_path),
"home": str(home_dir),
"temp": str(temp_dir),
"component": "user_state_manager"
}
)
raise UserStateError(f"Path must be within home directory: {resolved_path}")
# Atomic check for file access (CWE-367 - TOCTOU mitigation)
# Use try/except instead of exists() check to avoid race condition
if resolved_path.exists():
try:
# Atomically test read access
resolved_path.read_text()
except PermissionError:
raise UserStateError(f"Permission denied: {resolved_path}")
return resolved_path
def _load_state(self) -> Dict[str, Any]:
"""
Load state from file or return default state.
Returns:
State dictionary
"""
if not self.state_file.exists():
audit_log(
"state_file_not_found",
"success",
{
"path": str(self.state_file),
"action": "creating_default"
}
)
return copy.deepcopy(DEFAULT_STATE)
try:
state_text = self.state_file.read_text()
state = json.loads(state_text)
audit_log(
"state_loaded",
"success",
{
"path": str(self.state_file),
"first_run_complete": state.get("first_run_complete", False)
}
)
return state
except (json.JSONDecodeError, ValueError) as e:
# Corrupted JSON - fall back to default state
audit_log(
"state_file_corrupted",
"warning",
{
"path": str(self.state_file),
"error": str(e),
"action": "using_default_state"
}
)
return copy.deepcopy(DEFAULT_STATE)
def save(self) -> None:
"""
Save state to file.
Raises:
UserStateError: If save fails
"""
try:
# Create parent directories if needed
self.state_file.parent.mkdir(parents=True, exist_ok=True)
# Write state to file
state_json = json.dumps(self.state, indent=2)
self.state_file.write_text(state_json)
audit_log(
"state_saved",
"success",
{
"path": str(self.state_file),
"first_run_complete": self.state.get("first_run_complete", False)
}
)
except OSError as e:
audit_log(
"state_save_failed",
"failure",
{
"path": str(self.state_file),
"error": str(e)
}
)
raise UserStateError(f"Failed to save state: {e}")
def is_first_run(self) -> bool:
"""
Check if this is the first run.
Returns:
True if first run, False otherwise
"""
return not self.state.get("first_run_complete", False)
def record_first_run_complete(self) -> None:
"""Mark first run as complete."""
self.state["first_run_complete"] = True
audit_log(
"first_run_marked_complete",
"success",
{"path": str(self.state_file)}
)
def get_preference(self, key: str, default: Any = None) -> Any:
"""
Get user preference value.
Args:
key: Preference key
default: Default value if key not found
Returns:
Preference value or default
"""
return self.state.get("preferences", {}).get(key, default)
def set_preference(self, key: str, value: Any) -> None:
"""
Set user preference value.
Args:
key: Preference key
value: Preference value
"""
if "preferences" not in self.state:
self.state["preferences"] = {}
self.state["preferences"][key] = value
audit_log(
"preference_updated",
"success",
{
"key": key,
"value": value,
"path": str(self.state_file)
}
)
# Module-level convenience functions
def load_user_state(state_file: Path = DEFAULT_STATE_FILE) -> Dict[str, Any]:
"""
Load user state from file.
Args:
state_file: Path to state file
Returns:
State dictionary
"""
manager = UserStateManager(state_file)
return manager.state
def save_user_state(state: Dict[str, Any], state_file: Path = DEFAULT_STATE_FILE) -> None:
"""
Save user state to file.
Args:
state: State dictionary to save
state_file: Path to state file
"""
manager = UserStateManager(state_file)
manager.state = state
manager.save()
def is_first_run(state_file: Path = DEFAULT_STATE_FILE) -> bool:
"""
Check if this is the first run.
Args:
state_file: Path to state file
Returns:
True if first run, False otherwise
"""
manager = UserStateManager(state_file)
return manager.is_first_run()
def record_first_run_complete(state_file: Path = DEFAULT_STATE_FILE) -> None:
"""
Mark first run as complete.
Args:
state_file: Path to state file
"""
manager = UserStateManager(state_file)
manager.record_first_run_complete()
manager.save()
def get_user_preference(
key: str,
state_file: Path = DEFAULT_STATE_FILE,
default: Any = None
) -> Any:
"""
Get user preference value.
Args:
key: Preference key
state_file: Path to state file
default: Default value if key not found
Returns:
Preference value or default
"""
manager = UserStateManager(state_file)
return manager.get_preference(key, default)
def set_user_preference(
key: str,
value: Any,
state_file: Path = DEFAULT_STATE_FILE
) -> None:
"""
Set user preference value.
Args:
key: Preference key
value: Preference value
state_file: Path to state file
"""
manager = UserStateManager(state_file)
manager.set_preference(key, value)
manager.save()