406 lines
13 KiB
Python
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",
|
|
]
|