TradingAgents/.claude/lib/batch_retry_consent.py

406 lines
13 KiB
Python

#!/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",
]