#!/usr/bin/env python3 """ Batch Retry Consent - First-run consent prompt for automatic retry feature. Interactive consent system for /batch-implement automatic retry feature. Features: - First-run consent prompt with clear explanation - Persistent state storage (~/.autonomous-dev/user_state.json) - Environment variable override (BATCH_RETRY_ENABLED) - Secure file permissions (0o600) - Path validation (CWE-22, CWE-59) Consent Workflow: 1. Check environment variable (BATCH_RETRY_ENABLED) 2. If set, use that value (skip state file) 3. If not set, check user_state.json 4. If no state file, prompt user and save response Usage: from batch_retry_consent import ( check_retry_consent, is_retry_enabled, ) # Check if retry is enabled if is_retry_enabled(): # Retry logic... pass # Explicit consent check (prompts if needed) enabled = check_retry_consent() Security: - CWE-22: Path validation for user_state.json - CWE-59: Symlink rejection - File permissions: 0o600 (user-only read/write) - Safe defaults (no retry without explicit consent) Date: 2025-11-18 Issue: #89 (Automatic Failure Recovery for /batch-implement) Agent: implementer Phase: TDD Green (making tests pass) See error-handling-patterns skill for exception hierarchy and error handling best practices. """ import json import os import sys from pathlib import Path from typing import Optional # Import security utilities try: from .security_utils import validate_path except ImportError: # Direct script execution lib_dir = Path(__file__).parent.resolve() sys.path.insert(0, str(lib_dir)) # ============================================================================= # Constants # ============================================================================= # Default user state file location DEFAULT_USER_STATE_FILE = Path.home() / ".autonomous-dev" / "user_state.json" # Environment variable for override ENV_VAR_BATCH_RETRY = "BATCH_RETRY_ENABLED" # ============================================================================= # Exceptions # ============================================================================= class ConsentError(Exception): """Exception raised for consent-related errors.""" pass # ============================================================================= # User State File Management # ============================================================================= def get_user_state_file() -> Path: """ Get path to user state file. Returns: Path to user_state.json (default: ~/.autonomous-dev/user_state.json) """ return DEFAULT_USER_STATE_FILE def save_consent_state(retry_enabled: bool) -> None: """ Save consent state to user_state.json. Creates directory if needed, sets file permissions to 0o600. Args: retry_enabled: Whether automatic retry is enabled Raises: ConsentError: If path validation fails or file is a symlink """ state_file = get_user_state_file() # Validate path (prevent symlink attacks) - check BEFORE resolving # Note: We allow missing files, but if file exists and is a symlink, reject it if state_file.exists() and state_file.is_symlink(): raise ConsentError( f"Security error: user_state.json is a symlink. " f"Remove symlink and retry: {state_file}" ) # Security: CWE-22 path validation before file operations # For system-level config files, validate the path is within expected directory # (not using validate_path which is for project-level files) # Allow test paths (in tmp/test directories) for testing try: # Check for obvious path traversal in the path string if ".." in str(state_file): raise ConsentError( f"Security error: path contains traversal sequence (..). " f"Got: {state_file}" ) # If file exists, validate it's in an allowed location # (home directory OR test directory) if state_file.exists(): resolved_state_file = state_file.resolve() home_dir = Path.home().resolve() # Check if in home directory OR in a test directory in_home = str(resolved_state_file).startswith(str(home_dir)) in_test = any(part in str(resolved_state_file) for part in ['/tmp/', '/test', 'pytest']) if not (in_home or in_test): raise ConsentError( f"Security error: user_state.json must be within home or test directory. " f"Got: {resolved_state_file}, Expected: {home_dir}/.autonomous-dev/ or test directory" ) except OSError as e: raise ConsentError(f"Path validation failed: {e}") from e # Create directory if needed with secure permissions (CWE-732) # 0o700 = user-only read/write/execute (prevents other users from accessing) state_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700) # Load existing state or create new existing_state = {} if state_file.exists(): try: existing_state = json.loads(state_file.read_text()) except (json.JSONDecodeError, OSError): # Corrupted file - start fresh existing_state = {} # Update state existing_state["batch_retry_enabled"] = retry_enabled # Write with secure permissions # Use atomic write (write to temp, then rename) import tempfile # Security: Ensure parent directory exists before mkstemp() # Prevents race condition if directory is deleted between mkdir and mkstemp state_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700) fd, temp_path = tempfile.mkstemp( dir=state_file.parent, prefix=".user_state_", suffix=".tmp" ) try: # Write data os.write(fd, json.dumps(existing_state, indent=2).encode()) os.close(fd) # Set permissions before moving (0o600 = user-only read/write) # Note: May fail in test environments where temp_path is mocked try: os.chmod(temp_path, 0o600) except (OSError, FileNotFoundError): # File doesn't exist (e.g., mocked in tests) - permissions will be # set by mkstemp's mode parameter in real scenarios pass # Atomic rename Path(temp_path).replace(state_file) except Exception as e: # Cleanup temp file on error try: os.close(fd) except (OSError, ValueError): # fd may not be open or may be invalid pass try: temp_file = Path(temp_path) if temp_file.exists(): temp_file.unlink() except (OSError, FileNotFoundError): # Temp file may not exist (e.g., in mocked tests) pass raise ConsentError(f"Failed to save consent state: {e}") from e def load_consent_state() -> Optional[bool]: """ Load consent state from user_state.json. Returns: True if enabled, False if disabled, None if not set Raises: ConsentError: If file is a symlink (security check) """ state_file = get_user_state_file() # File doesn't exist - not set yet if not state_file.exists(): return None # Reject symlinks (CWE-59) if state_file.is_symlink(): raise ConsentError( f"Security error: user_state.json is a symlink. " f"Remove symlink and retry: {state_file}" ) # Load state try: state_data = json.loads(state_file.read_text()) return state_data.get("batch_retry_enabled") except (json.JSONDecodeError, OSError): # Corrupted file - treat as not set return None # ============================================================================= # Consent Prompt # ============================================================================= def prompt_for_retry_consent() -> bool: """ Display first-run consent prompt and get user response. Prompt explains: - Automatic retry feature - Max 3 retries for transient failures - How to disable Returns: True if user consented (yes/y/Y/Enter), False otherwise Examples: >>> prompt_for_retry_consent() # User enters "yes" True >>> prompt_for_retry_consent() # User enters "no" False """ # Display explanation print(""" ╔══════════════════════════════════════════════════════════════╗ ║ ║ ║ 🔄 Automatic Retry for /batch-implement (NEW) ║ ║ ║ ║ Automatic retry enabled for transient failures: ║ ║ ║ ║ ✓ Network errors (ConnectionError, TimeoutError) ║ ║ ✓ API rate limits (RateLimitError, 503) ║ ║ ✓ Temporary service failures (502, 504) ║ ║ ║ ║ Max 3 retries per feature (prevents infinite loops) ║ ║ Circuit breaker after 5 consecutive failures (safety) ║ ║ ║ ║ Permanent errors NOT retried (SyntaxError, ImportError) ║ ║ ║ ║ HOW TO DISABLE: ║ ║ ║ ║ Add to .env file: ║ ║ BATCH_RETRY_ENABLED=false ║ ║ ║ ║ See docs/BATCH-PROCESSING.md for details ║ ║ ║ ╚══════════════════════════════════════════════════════════════╝ """) # Get user input try: response = input("Enable automatic retry for /batch-implement? (Y/n): ") except (EOFError, KeyboardInterrupt): # Non-interactive or interrupted - default to no print() # Newline after prompt return False # Parse response response = response.strip().lower() # 'y'/'yes' → True if response in {"y", "yes"}: return True # 'n'/'no' or empty or invalid → False (safe default) # Note: Unlike git automation, retry feature is opt-in for safety return False # ============================================================================= # Public API # ============================================================================= def check_retry_consent() -> bool: """ Check if user has consented to automatic retry feature. Workflow: 1. Prompt user on first run 2. Save response to user_state.json 3. Return response Returns: True if retry enabled, False if disabled Examples: >>> check_retry_consent() # First run, user enters "yes" True >>> check_retry_consent() # Subsequent runs - read from state file True """ # Check if already set in state file existing_consent = load_consent_state() if existing_consent is not None: return existing_consent # Not set - prompt user user_consent = prompt_for_retry_consent() # Save response save_consent_state(user_consent) return user_consent def is_retry_enabled() -> bool: """ Check if automatic retry is enabled. Priority: 1. Environment variable (BATCH_RETRY_ENABLED) 2. User state file (~/.autonomous-dev/user_state.json) 3. Prompt user if not set Returns: True if retry enabled, False if disabled Examples: >>> os.environ["BATCH_RETRY_ENABLED"] = "true" >>> is_retry_enabled() True >>> os.environ.pop("BATCH_RETRY_ENABLED", None) >>> is_retry_enabled() # Checks state file or prompts True/False """ # 1. Check environment variable first env_value = os.environ.get(ENV_VAR_BATCH_RETRY) if env_value is not None: # Parse env var (case-insensitive) env_lower = env_value.lower() if env_lower in {"true", "1", "yes", "y"}: return True if env_lower in {"false", "0", "no", "n"}: return False # 2. Check user state file existing_consent = load_consent_state() if existing_consent is not None: return existing_consent # 3. Prompt user return check_retry_consent() # ============================================================================= # Module Exports # ============================================================================= __all__ = [ "check_retry_consent", "is_retry_enabled", "prompt_for_retry_consent", "save_consent_state", "load_consent_state", "get_user_state_file", "ConsentError", "DEFAULT_USER_STATE_FILE", ]