Files

1297 lines
44 KiB
Python
Raw Permalink Normal View History

"""
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()