- Add core agent architecture with Command + Skill pattern - Implement Claude API integration for content analysis - Add Obsidian REST API integration for vault operations - Create conversational interface (v2.0) with natural language processing - Add comprehensive configuration management and validation - Include project documentation and developer guides - Set up testing framework with unit, integration, and property tests - Add Kiro specs for Claude API configuration and code quality improvements - Configure project steering files for development guidelines
1297 lines
44 KiB
Python
1297 lines
44 KiB
Python
"""
|
|
Comprehensive input validation and sanitization utilities
|
|
Provides validation for user input formats, constraints, and security
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import datetime, date
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Union, Tuple, Callable
|
|
from urllib.parse import urlparse
|
|
|
|
try:
|
|
from .error_handling import ValidationError
|
|
except ImportError:
|
|
from error_handling import ValidationError
|
|
|
|
|
|
class InputValidator:
|
|
"""Comprehensive input validation utility class"""
|
|
|
|
def __init__(self):
|
|
self.logger = logging.getLogger(__name__)
|
|
|
|
# Common regex patterns
|
|
self.patterns = {
|
|
'email': re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'),
|
|
'url': re.compile(r'^https?://[^\s/$.?#].[^\s]*$'),
|
|
'api_key_claude': re.compile(r'^sk-ant-[a-zA-Z0-9_-]{50,}$'),
|
|
'api_key_generic': re.compile(r'^[a-zA-Z0-9_-]{8,}$'),
|
|
'date_iso': re.compile(r'^\d{4}-\d{2}-\d{2}$'),
|
|
'datetime_iso': re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'),
|
|
'file_extension': re.compile(r'^\.[a-zA-Z0-9]+$'),
|
|
'folder_name': re.compile(r'^[a-zA-Z0-9_\-\s/]+$'),
|
|
'safe_filename': re.compile(r'^[a-zA-Z0-9_\-\.\s]+$'),
|
|
}
|
|
|
|
# Validation constraints
|
|
self.constraints = {
|
|
'max_string_length': 10000,
|
|
'max_content_length': 1000000, # 1MB
|
|
'min_password_length': 8,
|
|
'max_api_key_length': 200,
|
|
'max_path_length': 4096,
|
|
}
|
|
|
|
def validate_string(
|
|
self,
|
|
value: Any,
|
|
field_name: str,
|
|
min_length: int = 0,
|
|
max_length: Optional[int] = None,
|
|
pattern: Optional[str] = None,
|
|
allow_empty: bool = False,
|
|
strip_whitespace: bool = True
|
|
) -> str:
|
|
"""
|
|
Validate string input with comprehensive checks
|
|
|
|
Args:
|
|
value: Input value to validate
|
|
field_name: Name of the field for error messages
|
|
min_length: Minimum string length
|
|
max_length: Maximum string length
|
|
pattern: Regex pattern name or custom pattern
|
|
allow_empty: Whether to allow empty strings
|
|
strip_whitespace: Whether to strip whitespace
|
|
|
|
Returns:
|
|
Validated and sanitized string
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
# Type check
|
|
if not isinstance(value, str):
|
|
if value is None:
|
|
if allow_empty:
|
|
return ""
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be None",
|
|
field_name=field_name,
|
|
validation_rule="not_none"
|
|
)
|
|
# Try to convert to string
|
|
try:
|
|
value = str(value)
|
|
except Exception:
|
|
raise ValidationError(
|
|
message=f"{field_name} must be a string",
|
|
field_name=field_name,
|
|
validation_rule="string_type"
|
|
)
|
|
|
|
# Strip whitespace if requested
|
|
if strip_whitespace:
|
|
value = value.strip()
|
|
|
|
# Check empty string
|
|
if not value and not allow_empty:
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be empty",
|
|
field_name=field_name,
|
|
validation_rule="non_empty"
|
|
)
|
|
|
|
# Length validation
|
|
if len(value) < min_length:
|
|
raise ValidationError(
|
|
message=f"{field_name} must be at least {min_length} characters long",
|
|
field_name=field_name,
|
|
validation_rule="min_length"
|
|
)
|
|
|
|
max_len = max_length or self.constraints['max_string_length']
|
|
if len(value) > max_len:
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot exceed {max_len} characters",
|
|
field_name=field_name,
|
|
validation_rule="max_length"
|
|
)
|
|
|
|
# Pattern validation
|
|
if pattern and value:
|
|
if pattern in self.patterns:
|
|
regex = self.patterns[pattern]
|
|
else:
|
|
try:
|
|
regex = re.compile(pattern)
|
|
except re.error as e:
|
|
raise ValidationError(
|
|
message=f"Invalid regex pattern for {field_name}: {e}",
|
|
field_name=field_name,
|
|
validation_rule="pattern_error"
|
|
)
|
|
|
|
if not regex.match(value):
|
|
raise ValidationError(
|
|
message=f"{field_name} format is invalid",
|
|
field_name=field_name,
|
|
validation_rule="pattern_match"
|
|
)
|
|
|
|
return value
|
|
|
|
def validate_integer(
|
|
self,
|
|
value: Any,
|
|
field_name: str,
|
|
min_value: Optional[int] = None,
|
|
max_value: Optional[int] = None,
|
|
allow_none: bool = False
|
|
) -> Optional[int]:
|
|
"""
|
|
Validate integer input
|
|
|
|
Args:
|
|
value: Input value to validate
|
|
field_name: Name of the field for error messages
|
|
min_value: Minimum allowed value
|
|
max_value: Maximum allowed value
|
|
allow_none: Whether to allow None values
|
|
|
|
Returns:
|
|
Validated integer or None
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
if value is None:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be None",
|
|
field_name=field_name,
|
|
validation_rule="not_none"
|
|
)
|
|
|
|
# Try to convert to integer
|
|
if isinstance(value, str):
|
|
value = value.strip()
|
|
if not value:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be empty",
|
|
field_name=field_name,
|
|
validation_rule="non_empty"
|
|
)
|
|
|
|
try:
|
|
int_value = int(value)
|
|
except (ValueError, TypeError):
|
|
raise ValidationError(
|
|
message=f"{field_name} must be a valid integer",
|
|
field_name=field_name,
|
|
validation_rule="integer_type"
|
|
)
|
|
|
|
# Range validation
|
|
if min_value is not None and int_value < min_value:
|
|
raise ValidationError(
|
|
message=f"{field_name} must be at least {min_value}",
|
|
field_name=field_name,
|
|
validation_rule="min_value"
|
|
)
|
|
|
|
if max_value is not None and int_value > max_value:
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot exceed {max_value}",
|
|
field_name=field_name,
|
|
validation_rule="max_value"
|
|
)
|
|
|
|
return int_value
|
|
|
|
def validate_float(
|
|
self,
|
|
value: Any,
|
|
field_name: str,
|
|
min_value: Optional[float] = None,
|
|
max_value: Optional[float] = None,
|
|
allow_none: bool = False
|
|
) -> Optional[float]:
|
|
"""
|
|
Validate float input
|
|
|
|
Args:
|
|
value: Input value to validate
|
|
field_name: Name of the field for error messages
|
|
min_value: Minimum allowed value
|
|
max_value: Maximum allowed value
|
|
allow_none: Whether to allow None values
|
|
|
|
Returns:
|
|
Validated float or None
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
if value is None:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be None",
|
|
field_name=field_name,
|
|
validation_rule="not_none"
|
|
)
|
|
|
|
# Try to convert to float
|
|
if isinstance(value, str):
|
|
value = value.strip()
|
|
if not value:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be empty",
|
|
field_name=field_name,
|
|
validation_rule="non_empty"
|
|
)
|
|
|
|
try:
|
|
float_value = float(value)
|
|
except (ValueError, TypeError):
|
|
raise ValidationError(
|
|
message=f"{field_name} must be a valid number",
|
|
field_name=field_name,
|
|
validation_rule="float_type"
|
|
)
|
|
|
|
# Range validation
|
|
if min_value is not None and float_value < min_value:
|
|
raise ValidationError(
|
|
message=f"{field_name} must be at least {min_value}",
|
|
field_name=field_name,
|
|
validation_rule="min_value"
|
|
)
|
|
|
|
if max_value is not None and float_value > max_value:
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot exceed {max_value}",
|
|
field_name=field_name,
|
|
validation_rule="max_value"
|
|
)
|
|
|
|
return float_value
|
|
|
|
def validate_boolean(
|
|
self,
|
|
value: Any,
|
|
field_name: str,
|
|
allow_none: bool = False
|
|
) -> Optional[bool]:
|
|
"""
|
|
Validate boolean input with flexible conversion
|
|
|
|
Args:
|
|
value: Input value to validate
|
|
field_name: Name of the field for error messages
|
|
allow_none: Whether to allow None values
|
|
|
|
Returns:
|
|
Validated boolean or None
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
if value is None:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be None",
|
|
field_name=field_name,
|
|
validation_rule="not_none"
|
|
)
|
|
|
|
# Handle boolean type
|
|
if isinstance(value, bool):
|
|
return value
|
|
|
|
# Handle string conversion
|
|
if isinstance(value, str):
|
|
value = value.strip().lower()
|
|
if value in ('true', '1', 'yes', 'on', 'enabled'):
|
|
return True
|
|
elif value in ('false', '0', 'no', 'off', 'disabled'):
|
|
return False
|
|
else:
|
|
raise ValidationError(
|
|
message=f"{field_name} must be a valid boolean value (true/false, yes/no, 1/0)",
|
|
field_name=field_name,
|
|
validation_rule="boolean_format"
|
|
)
|
|
|
|
# Handle numeric conversion
|
|
if isinstance(value, (int, float)):
|
|
return bool(value)
|
|
|
|
raise ValidationError(
|
|
message=f"{field_name} must be a valid boolean value",
|
|
field_name=field_name,
|
|
validation_rule="boolean_type"
|
|
)
|
|
|
|
def validate_list(
|
|
self,
|
|
value: Any,
|
|
field_name: str,
|
|
min_length: int = 0,
|
|
max_length: Optional[int] = None,
|
|
item_validator: Optional[Callable] = None,
|
|
allow_none: bool = False
|
|
) -> Optional[List[Any]]:
|
|
"""
|
|
Validate list input with optional item validation
|
|
|
|
Args:
|
|
value: Input value to validate
|
|
field_name: Name of the field for error messages
|
|
min_length: Minimum list length
|
|
max_length: Maximum list length
|
|
item_validator: Function to validate each item
|
|
allow_none: Whether to allow None values
|
|
|
|
Returns:
|
|
Validated list or None
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
if value is None:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be None",
|
|
field_name=field_name,
|
|
validation_rule="not_none"
|
|
)
|
|
|
|
# Convert to list if needed
|
|
if not isinstance(value, list):
|
|
if isinstance(value, (tuple, set)):
|
|
value = list(value)
|
|
else:
|
|
raise ValidationError(
|
|
message=f"{field_name} must be a list",
|
|
field_name=field_name,
|
|
validation_rule="list_type"
|
|
)
|
|
|
|
# Length validation
|
|
if len(value) < min_length:
|
|
raise ValidationError(
|
|
message=f"{field_name} must contain at least {min_length} items",
|
|
field_name=field_name,
|
|
validation_rule="min_length"
|
|
)
|
|
|
|
if max_length is not None and len(value) > max_length:
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot contain more than {max_length} items",
|
|
field_name=field_name,
|
|
validation_rule="max_length"
|
|
)
|
|
|
|
# Validate each item if validator provided
|
|
if item_validator:
|
|
validated_items = []
|
|
for i, item in enumerate(value):
|
|
try:
|
|
validated_item = item_validator(item)
|
|
validated_items.append(validated_item)
|
|
except ValidationError as e:
|
|
raise ValidationError(
|
|
message=f"{field_name}[{i}]: {e.message}",
|
|
field_name=f"{field_name}[{i}]",
|
|
validation_rule=e.validation_rule
|
|
)
|
|
return validated_items
|
|
|
|
return value
|
|
|
|
def validate_dict(
|
|
self,
|
|
value: Any,
|
|
field_name: str,
|
|
required_keys: Optional[List[str]] = None,
|
|
optional_keys: Optional[List[str]] = None,
|
|
allow_extra_keys: bool = True,
|
|
allow_none: bool = False
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Validate dictionary input with key validation
|
|
|
|
Args:
|
|
value: Input value to validate
|
|
field_name: Name of the field for error messages
|
|
required_keys: List of required keys
|
|
optional_keys: List of optional keys
|
|
allow_extra_keys: Whether to allow keys not in required/optional
|
|
allow_none: Whether to allow None values
|
|
|
|
Returns:
|
|
Validated dictionary or None
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
if value is None:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be None",
|
|
field_name=field_name,
|
|
validation_rule="not_none"
|
|
)
|
|
|
|
if not isinstance(value, dict):
|
|
raise ValidationError(
|
|
message=f"{field_name} must be a dictionary",
|
|
field_name=field_name,
|
|
validation_rule="dict_type"
|
|
)
|
|
|
|
# Check required keys
|
|
if required_keys:
|
|
missing_keys = [key for key in required_keys if key not in value]
|
|
if missing_keys:
|
|
raise ValidationError(
|
|
message=f"{field_name} is missing required keys: {', '.join(missing_keys)}",
|
|
field_name=field_name,
|
|
validation_rule="missing_keys"
|
|
)
|
|
|
|
# Check for unexpected keys
|
|
if not allow_extra_keys and (required_keys or optional_keys):
|
|
allowed_keys = set(required_keys or []) | set(optional_keys or [])
|
|
extra_keys = [key for key in value.keys() if key not in allowed_keys]
|
|
if extra_keys:
|
|
raise ValidationError(
|
|
message=f"{field_name} contains unexpected keys: {', '.join(extra_keys)}",
|
|
field_name=field_name,
|
|
validation_rule="extra_keys"
|
|
)
|
|
|
|
return value
|
|
|
|
def validate_json(
|
|
self,
|
|
value: Any,
|
|
field_name: str,
|
|
allow_none: bool = False
|
|
) -> Optional[Union[Dict[str, Any], List[Any]]]:
|
|
"""
|
|
Validate JSON input (string or already parsed)
|
|
|
|
Args:
|
|
value: Input value to validate
|
|
field_name: Name of the field for error messages
|
|
allow_none: Whether to allow None values
|
|
|
|
Returns:
|
|
Parsed JSON data or None
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
if value is None:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be None",
|
|
field_name=field_name,
|
|
validation_rule="not_none"
|
|
)
|
|
|
|
# If already parsed, validate it's JSON-serializable
|
|
if isinstance(value, (dict, list)):
|
|
try:
|
|
json.dumps(value)
|
|
return value
|
|
except (TypeError, ValueError) as e:
|
|
raise ValidationError(
|
|
message=f"{field_name} contains non-JSON-serializable data: {e}",
|
|
field_name=field_name,
|
|
validation_rule="json_serializable"
|
|
)
|
|
|
|
# If string, try to parse
|
|
if isinstance(value, str):
|
|
value = value.strip()
|
|
if not value:
|
|
if allow_none:
|
|
return None
|
|
raise ValidationError(
|
|
message=f"{field_name} cannot be empty",
|
|
field_name=field_name,
|
|
validation_rule="non_empty"
|
|
)
|
|
|
|
try:
|
|
return json.loads(value)
|
|
except json.JSONDecodeError as e:
|
|
raise ValidationError(
|
|
message=f"{field_name} is not valid JSON: {e}",
|
|
field_name=field_name,
|
|
validation_rule="json_format"
|
|
)
|
|
|
|
raise ValidationError(
|
|
message=f"{field_name} must be a JSON string or parsed JSON data",
|
|
field_name=field_name,
|
|
validation_rule="json_type"
|
|
)
|
|
|
|
|
|
class ContentValidator:
|
|
"""Validator for content-specific inputs"""
|
|
|
|
def __init__(self):
|
|
self.logger = logging.getLogger(__name__)
|
|
self.base_validator = InputValidator()
|
|
|
|
def validate_journal_content(
|
|
self,
|
|
content: Any,
|
|
field_name: str = "journal_content",
|
|
max_length: Optional[int] = None
|
|
) -> str:
|
|
"""
|
|
Validate journal content with content-specific rules
|
|
|
|
Args:
|
|
content: Journal content to validate
|
|
field_name: Name of the field for error messages
|
|
max_length: Maximum content length
|
|
|
|
Returns:
|
|
Validated journal content
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
max_len = max_length or self.base_validator.constraints['max_content_length']
|
|
|
|
validated_content = self.base_validator.validate_string(
|
|
content,
|
|
field_name,
|
|
min_length=1,
|
|
max_length=max_len,
|
|
allow_empty=False
|
|
)
|
|
|
|
# Additional content validation
|
|
if len(validated_content.strip()) < 10:
|
|
raise ValidationError(
|
|
message=f"{field_name} appears to be too short for meaningful analysis",
|
|
field_name=field_name,
|
|
validation_rule="content_too_short"
|
|
)
|
|
|
|
# Check for suspicious content patterns
|
|
suspicious_patterns = [
|
|
r'<script[^>]*>.*?</script>', # Script tags
|
|
r'javascript:', # JavaScript URLs
|
|
r'data:text/html', # Data URLs
|
|
]
|
|
|
|
for pattern in suspicious_patterns:
|
|
if re.search(pattern, validated_content, re.IGNORECASE | re.DOTALL):
|
|
self.logger.warning(f"Suspicious content pattern detected in {field_name}")
|
|
# Don't reject, but log for security monitoring
|
|
|
|
return validated_content
|
|
|
|
def validate_api_key(
|
|
self,
|
|
api_key: Any,
|
|
api_type: str = "generic",
|
|
field_name: str = "api_key"
|
|
) -> str:
|
|
"""
|
|
Validate API key with type-specific rules
|
|
|
|
Args:
|
|
api_key: API key to validate
|
|
api_type: Type of API key (claude, generic, obsidian)
|
|
field_name: Name of the field for error messages
|
|
|
|
Returns:
|
|
Validated API key
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
validated_key = self.base_validator.validate_string(
|
|
api_key,
|
|
field_name,
|
|
min_length=8,
|
|
max_length=self.base_validator.constraints['max_api_key_length'],
|
|
allow_empty=False
|
|
)
|
|
|
|
# Type-specific validation
|
|
if api_type == "claude":
|
|
if not validated_key.startswith('sk-ant-'):
|
|
raise ValidationError(
|
|
message=f"{field_name} for Claude API should start with 'sk-ant-'",
|
|
field_name=field_name,
|
|
validation_rule="claude_api_key_format"
|
|
)
|
|
|
|
if len(validated_key) < 50:
|
|
raise ValidationError(
|
|
message=f"{field_name} for Claude API appears to be too short",
|
|
field_name=field_name,
|
|
validation_rule="claude_api_key_length"
|
|
)
|
|
|
|
elif api_type == "obsidian":
|
|
# Obsidian API keys are user-generated, so less strict
|
|
if len(validated_key) < 8:
|
|
raise ValidationError(
|
|
message=f"{field_name} for Obsidian API should be at least 8 characters",
|
|
field_name=field_name,
|
|
validation_rule="obsidian_api_key_length"
|
|
)
|
|
|
|
# Check for obviously insecure keys
|
|
insecure_keys = ['password', '123456', 'admin', 'test', 'key', 'secret']
|
|
if validated_key.lower() in insecure_keys:
|
|
raise ValidationError(
|
|
message=f"{field_name} appears to be insecure. Please use a stronger API key",
|
|
field_name=field_name,
|
|
validation_rule="insecure_api_key"
|
|
)
|
|
|
|
# Check for common issues
|
|
if ' ' in validated_key:
|
|
raise ValidationError(
|
|
message=f"{field_name} should not contain spaces",
|
|
field_name=field_name,
|
|
validation_rule="api_key_whitespace"
|
|
)
|
|
|
|
if validated_key.endswith('...') or '...' in validated_key:
|
|
raise ValidationError(
|
|
message=f"{field_name} appears to be truncated",
|
|
field_name=field_name,
|
|
validation_rule="api_key_truncated"
|
|
)
|
|
|
|
return validated_key
|
|
|
|
def validate_url(
|
|
self,
|
|
url: Any,
|
|
field_name: str = "url",
|
|
allowed_schemes: Optional[List[str]] = None,
|
|
require_https: bool = False
|
|
) -> str:
|
|
"""
|
|
Validate URL with security checks
|
|
|
|
Args:
|
|
url: URL to validate
|
|
field_name: Name of the field for error messages
|
|
allowed_schemes: List of allowed URL schemes
|
|
require_https: Whether to require HTTPS
|
|
|
|
Returns:
|
|
Validated URL
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
validated_url = self.base_validator.validate_string(
|
|
url,
|
|
field_name,
|
|
min_length=1,
|
|
allow_empty=False
|
|
)
|
|
|
|
# Parse URL
|
|
try:
|
|
parsed = urlparse(validated_url)
|
|
except Exception as e:
|
|
raise ValidationError(
|
|
message=f"{field_name} is not a valid URL: {e}",
|
|
field_name=field_name,
|
|
validation_rule="url_parse_error"
|
|
)
|
|
|
|
# Check scheme
|
|
if not parsed.scheme:
|
|
raise ValidationError(
|
|
message=f"{field_name} must include a scheme (http:// or https://)",
|
|
field_name=field_name,
|
|
validation_rule="url_missing_scheme"
|
|
)
|
|
|
|
allowed_schemes = allowed_schemes or ['http', 'https']
|
|
if parsed.scheme not in allowed_schemes:
|
|
raise ValidationError(
|
|
message=f"{field_name} scheme must be one of: {', '.join(allowed_schemes)}",
|
|
field_name=field_name,
|
|
validation_rule="url_invalid_scheme"
|
|
)
|
|
|
|
if require_https and parsed.scheme != 'https':
|
|
raise ValidationError(
|
|
message=f"{field_name} must use HTTPS",
|
|
field_name=field_name,
|
|
validation_rule="url_https_required"
|
|
)
|
|
|
|
# Check hostname
|
|
if not parsed.netloc:
|
|
raise ValidationError(
|
|
message=f"{field_name} must include a hostname",
|
|
field_name=field_name,
|
|
validation_rule="url_missing_hostname"
|
|
)
|
|
|
|
# Security checks for local/private URLs
|
|
hostname = parsed.hostname
|
|
if hostname:
|
|
# Check for localhost/private IPs (warn but don't reject for development)
|
|
if hostname in ['localhost', '127.0.0.1', '::1']:
|
|
self.logger.info(f"Local URL detected in {field_name}: {validated_url}")
|
|
elif hostname.startswith('192.168.') or hostname.startswith('10.') or hostname.startswith('172.'):
|
|
self.logger.info(f"Private network URL detected in {field_name}: {validated_url}")
|
|
|
|
return validated_url
|
|
|
|
|
|
# Global validator instances
|
|
input_validator = InputValidator()
|
|
content_validator = ContentValidator()
|
|
|
|
|
|
def validate_user_input(
|
|
data: Dict[str, Any],
|
|
validation_rules: Dict[str, Dict[str, Any]]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Validate user input data against validation rules
|
|
|
|
Args:
|
|
data: Input data to validate
|
|
validation_rules: Dictionary of field validation rules
|
|
|
|
Returns:
|
|
Dictionary of validated data
|
|
|
|
Raises:
|
|
ValidationError: If any validation fails
|
|
"""
|
|
validated_data = {}
|
|
|
|
for field_name, rules in validation_rules.items():
|
|
value = data.get(field_name)
|
|
|
|
# Get validation type and parameters
|
|
validation_type = rules.get('type', 'string')
|
|
required = rules.get('required', True)
|
|
|
|
# Handle missing values
|
|
if value is None or (isinstance(value, str) and not value.strip()):
|
|
if required:
|
|
raise ValidationError(
|
|
message=f"{field_name} is required",
|
|
field_name=field_name,
|
|
validation_rule="required"
|
|
)
|
|
else:
|
|
validated_data[field_name] = None
|
|
continue
|
|
|
|
# Apply validation based on type
|
|
try:
|
|
if validation_type == 'string':
|
|
validated_data[field_name] = input_validator.validate_string(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
elif validation_type == 'integer':
|
|
validated_data[field_name] = input_validator.validate_integer(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
elif validation_type == 'float':
|
|
validated_data[field_name] = input_validator.validate_float(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
elif validation_type == 'boolean':
|
|
validated_data[field_name] = input_validator.validate_boolean(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
elif validation_type == 'list':
|
|
validated_data[field_name] = input_validator.validate_list(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
elif validation_type == 'dict':
|
|
validated_data[field_name] = input_validator.validate_dict(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
elif validation_type == 'json':
|
|
validated_data[field_name] = input_validator.validate_json(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
elif validation_type == 'url':
|
|
validated_data[field_name] = content_validator.validate_url(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
elif validation_type == 'api_key':
|
|
api_type = rules.get('api_type', 'generic')
|
|
validated_data[field_name] = content_validator.validate_api_key(
|
|
value, api_type, field_name
|
|
)
|
|
elif validation_type == 'journal_content':
|
|
validated_data[field_name] = content_validator.validate_journal_content(
|
|
value, field_name, **{k: v for k, v in rules.items() if k not in ['type', 'required']}
|
|
)
|
|
else:
|
|
raise ValidationError(
|
|
message=f"Unknown validation type: {validation_type}",
|
|
field_name=field_name,
|
|
validation_rule="unknown_validation_type"
|
|
)
|
|
|
|
except ValidationError:
|
|
# Re-raise validation errors as-is
|
|
raise
|
|
except Exception as e:
|
|
# Wrap unexpected errors
|
|
raise ValidationError(
|
|
message=f"Validation error for {field_name}: {str(e)}",
|
|
field_name=field_name,
|
|
validation_rule="validation_error"
|
|
)
|
|
|
|
return validated_data
|
|
|
|
|
|
class CommandInputValidator:
|
|
"""Specialized validator for command inputs"""
|
|
|
|
def __init__(self):
|
|
self.logger = logging.getLogger(__name__)
|
|
self.base_validator = InputValidator()
|
|
self.content_validator = ContentValidator()
|
|
|
|
def validate_organize_command_input(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Validate input for the organize command
|
|
|
|
Args:
|
|
args: Command arguments to validate
|
|
|
|
Returns:
|
|
Validated arguments
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
validation_rules = {
|
|
'date': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'pattern': 'date_iso',
|
|
'allow_empty': True
|
|
},
|
|
'vault_path': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'min_length': 1,
|
|
'max_length': 4096,
|
|
'allow_empty': True
|
|
},
|
|
'daily_folder': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'pattern': 'folder_name',
|
|
'min_length': 1,
|
|
'max_length': 255,
|
|
'allow_empty': True
|
|
}
|
|
}
|
|
|
|
validated_args = validate_user_input(args, validation_rules)
|
|
|
|
# Additional date validation
|
|
if validated_args.get('date'):
|
|
try:
|
|
datetime.strptime(validated_args['date'], '%Y-%m-%d')
|
|
except ValueError:
|
|
raise ValidationError(
|
|
message="Date must be in YYYY-MM-DD format",
|
|
field_name="date",
|
|
validation_rule="date_format"
|
|
)
|
|
|
|
return validated_args
|
|
|
|
def validate_skill_input(self, skill_name: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Validate input for specific skills
|
|
|
|
Args:
|
|
skill_name: Name of the skill
|
|
kwargs: Skill arguments to validate
|
|
|
|
Returns:
|
|
Validated arguments
|
|
|
|
Raises:
|
|
ValidationError: If validation fails
|
|
"""
|
|
if skill_name == 'obsidian_read':
|
|
return self._validate_obsidian_read_input(kwargs)
|
|
elif skill_name == 'obsidian_write':
|
|
return self._validate_obsidian_write_input(kwargs)
|
|
elif skill_name == 'obsidian_append':
|
|
return self._validate_obsidian_append_input(kwargs)
|
|
elif skill_name == 'obsidian_list_files':
|
|
return self._validate_obsidian_list_input(kwargs)
|
|
elif skill_name == 'claude_analyze':
|
|
return self._validate_claude_analyze_input(kwargs)
|
|
elif skill_name == 'claude_transform':
|
|
return self._validate_claude_transform_input(kwargs)
|
|
else:
|
|
# Generic validation for unknown skills
|
|
return self._validate_generic_skill_input(kwargs)
|
|
|
|
def _validate_obsidian_read_input(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Validate Obsidian read skill input"""
|
|
validation_rules = {
|
|
'file_path': {
|
|
'type': 'string',
|
|
'required': True,
|
|
'min_length': 1,
|
|
'max_length': 4096
|
|
},
|
|
'api_url': {
|
|
'type': 'url',
|
|
'required': False,
|
|
'allowed_schemes': ['http', 'https']
|
|
},
|
|
'api_key': {
|
|
'type': 'api_key',
|
|
'required': True,
|
|
'api_type': 'obsidian'
|
|
}
|
|
}
|
|
|
|
validated = validate_user_input(kwargs, validation_rules)
|
|
|
|
# Additional file path validation
|
|
if validated.get('file_path'):
|
|
validated['file_path'] = self._sanitize_file_path(validated['file_path'])
|
|
|
|
return validated
|
|
|
|
def _validate_obsidian_write_input(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Validate Obsidian write skill input"""
|
|
validation_rules = {
|
|
'file_path': {
|
|
'type': 'string',
|
|
'required': True,
|
|
'min_length': 1,
|
|
'max_length': 4096
|
|
},
|
|
'content': {
|
|
'type': 'string',
|
|
'required': True,
|
|
'min_length': 0, # Allow empty content for creating empty files
|
|
'max_length': 1000000 # 1MB limit
|
|
},
|
|
'overwrite': {
|
|
'type': 'boolean',
|
|
'required': False
|
|
},
|
|
'api_url': {
|
|
'type': 'url',
|
|
'required': False,
|
|
'allowed_schemes': ['http', 'https']
|
|
},
|
|
'api_key': {
|
|
'type': 'api_key',
|
|
'required': True,
|
|
'api_type': 'obsidian'
|
|
}
|
|
}
|
|
|
|
validated = validate_user_input(kwargs, validation_rules)
|
|
|
|
# Additional file path validation
|
|
if validated.get('file_path'):
|
|
validated['file_path'] = self._sanitize_file_path(validated['file_path'])
|
|
|
|
return validated
|
|
|
|
def _validate_obsidian_append_input(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Validate Obsidian append skill input"""
|
|
validation_rules = {
|
|
'file_path': {
|
|
'type': 'string',
|
|
'required': True,
|
|
'min_length': 1,
|
|
'max_length': 4096
|
|
},
|
|
'content': {
|
|
'type': 'string',
|
|
'required': True,
|
|
'min_length': 1,
|
|
'max_length': 1000000 # 1MB limit
|
|
},
|
|
'api_url': {
|
|
'type': 'url',
|
|
'required': False,
|
|
'allowed_schemes': ['http', 'https']
|
|
},
|
|
'api_key': {
|
|
'type': 'api_key',
|
|
'required': True,
|
|
'api_type': 'obsidian'
|
|
}
|
|
}
|
|
|
|
validated = validate_user_input(kwargs, validation_rules)
|
|
|
|
# Additional file path validation
|
|
if validated.get('file_path'):
|
|
validated['file_path'] = self._sanitize_file_path(validated['file_path'])
|
|
|
|
return validated
|
|
|
|
def _validate_obsidian_list_input(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Validate Obsidian list files skill input"""
|
|
validation_rules = {
|
|
'folder_path': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'min_length': 0,
|
|
'max_length': 4096,
|
|
'allow_empty': True
|
|
},
|
|
'api_url': {
|
|
'type': 'url',
|
|
'required': False,
|
|
'allowed_schemes': ['http', 'https']
|
|
},
|
|
'api_key': {
|
|
'type': 'api_key',
|
|
'required': True,
|
|
'api_type': 'obsidian'
|
|
}
|
|
}
|
|
|
|
validated = validate_user_input(kwargs, validation_rules)
|
|
|
|
# Additional folder path validation
|
|
if validated.get('folder_path'):
|
|
validated['folder_path'] = self._sanitize_file_path(validated['folder_path'])
|
|
|
|
return validated
|
|
|
|
def _validate_claude_analyze_input(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Validate Claude analyze skill input"""
|
|
validation_rules = {
|
|
'journal_content': {
|
|
'type': 'journal_content',
|
|
'required': True
|
|
},
|
|
'api_key': {
|
|
'type': 'api_key',
|
|
'required': True,
|
|
'api_type': 'claude'
|
|
},
|
|
'model': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'min_length': 1,
|
|
'max_length': 100
|
|
},
|
|
'categories': {
|
|
'type': 'list',
|
|
'required': False,
|
|
'min_length': 0,
|
|
'max_length': 50
|
|
}
|
|
}
|
|
|
|
validated = validate_user_input(kwargs, validation_rules)
|
|
|
|
# Validate model name if provided
|
|
if validated.get('model'):
|
|
valid_models = [
|
|
'claude-3-5-sonnet-20241022',
|
|
'claude-3-5-haiku-20241022',
|
|
'claude-3-opus-20240229',
|
|
'claude-3-sonnet-20240229',
|
|
'claude-3-haiku-20240307'
|
|
]
|
|
if validated['model'] not in valid_models:
|
|
self.logger.warning(f"Unknown Claude model: {validated['model']}")
|
|
|
|
# Validate categories if provided
|
|
if validated.get('categories'):
|
|
category_validator = lambda cat: self.base_validator.validate_string(
|
|
cat, 'category', min_length=1, max_length=100
|
|
)
|
|
validated['categories'] = self.base_validator.validate_list(
|
|
validated['categories'],
|
|
'categories',
|
|
item_validator=category_validator
|
|
)
|
|
|
|
return validated
|
|
|
|
def _validate_claude_transform_input(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Validate Claude transform skill input"""
|
|
validation_rules = {
|
|
'content': {
|
|
'type': 'string',
|
|
'required': True,
|
|
'min_length': 1,
|
|
'max_length': 1000000 # 1MB limit
|
|
},
|
|
'transform_type': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'min_length': 1,
|
|
'max_length': 50
|
|
},
|
|
'api_key': {
|
|
'type': 'api_key',
|
|
'required': True,
|
|
'api_type': 'claude'
|
|
},
|
|
'model': {
|
|
'type': 'string',
|
|
'required': False,
|
|
'min_length': 1,
|
|
'max_length': 100
|
|
}
|
|
}
|
|
|
|
validated = validate_user_input(kwargs, validation_rules)
|
|
|
|
# Validate transform type
|
|
if validated.get('transform_type'):
|
|
valid_types = ['markdown', 'html', 'summary', 'outline', 'checklist']
|
|
if validated['transform_type'] not in valid_types:
|
|
self.logger.warning(f"Unknown transform type: {validated['transform_type']}")
|
|
|
|
return validated
|
|
|
|
def _validate_generic_skill_input(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Generic validation for unknown skills"""
|
|
validated = {}
|
|
|
|
for key, value in kwargs.items():
|
|
if isinstance(value, str):
|
|
# Basic string validation
|
|
validated[key] = self.base_validator.validate_string(
|
|
value, key, max_length=10000, allow_empty=True
|
|
)
|
|
elif isinstance(value, (int, float)):
|
|
# Keep numeric values as-is but validate range
|
|
if isinstance(value, int):
|
|
validated[key] = self.base_validator.validate_integer(
|
|
value, key, min_value=-2147483648, max_value=2147483647
|
|
)
|
|
else:
|
|
validated[key] = self.base_validator.validate_float(
|
|
value, key, min_value=-1e10, max_value=1e10
|
|
)
|
|
elif isinstance(value, bool):
|
|
validated[key] = value
|
|
elif isinstance(value, (list, dict)):
|
|
# Basic structure validation
|
|
try:
|
|
json.dumps(value) # Ensure JSON serializable
|
|
validated[key] = value
|
|
except (TypeError, ValueError):
|
|
raise ValidationError(
|
|
message=f"{key} contains non-serializable data",
|
|
field_name=key,
|
|
validation_rule="json_serializable"
|
|
)
|
|
else:
|
|
# Convert other types to string
|
|
validated[key] = str(value)
|
|
|
|
return validated
|
|
|
|
def _sanitize_file_path(self, file_path: str) -> str:
|
|
"""
|
|
Sanitize file path to prevent directory traversal
|
|
|
|
Args:
|
|
file_path: File path to sanitize
|
|
|
|
Returns:
|
|
Sanitized file path
|
|
|
|
Raises:
|
|
ValidationError: If path is unsafe
|
|
"""
|
|
# Remove any null bytes
|
|
file_path = file_path.replace('\x00', '')
|
|
|
|
# Normalize path separators
|
|
file_path = file_path.replace('\\', '/')
|
|
|
|
# Check for directory traversal attempts
|
|
if '..' in file_path:
|
|
raise ValidationError(
|
|
message="File path cannot contain '..' (directory traversal)",
|
|
field_name="file_path",
|
|
validation_rule="path_traversal"
|
|
)
|
|
|
|
# Check for absolute paths (should be relative to vault)
|
|
if file_path.startswith('/'):
|
|
raise ValidationError(
|
|
message="File path must be relative to vault root",
|
|
field_name="file_path",
|
|
validation_rule="absolute_path"
|
|
)
|
|
|
|
# Check for dangerous characters
|
|
dangerous_chars = ['<', '>', ':', '"', '|', '?', '*']
|
|
for char in dangerous_chars:
|
|
if char in file_path:
|
|
raise ValidationError(
|
|
message=f"File path cannot contain '{char}'",
|
|
field_name="file_path",
|
|
validation_rule="dangerous_character"
|
|
)
|
|
|
|
# Ensure path doesn't start with special directories
|
|
path_parts = file_path.split('/')
|
|
if path_parts and path_parts[0].startswith('.'):
|
|
if path_parts[0] not in ['.obsidian']: # Allow .obsidian folder
|
|
raise ValidationError(
|
|
message="File path cannot start with hidden directory",
|
|
field_name="file_path",
|
|
validation_rule="hidden_directory"
|
|
)
|
|
|
|
return file_path
|
|
|
|
|
|
# Global command input validator instance
|
|
command_input_validator = CommandInputValidator() |