""" Action Validator Pre-execution validation with rule engine for action requests. """ import asyncio import fnmatch import logging from collections import OrderedDict from ..config import get_safety_config from ..models import ( ActionRequest, ActionType, SafetyDecision, SafetyPolicy, ValidationResult, ValidationRule, ) logger = logging.getLogger(__name__) class ValidationCache: """LRU cache for validation results.""" def __init__(self, max_size: int = 1000, ttl_seconds: int = 60) -> None: self._cache: OrderedDict[str, tuple[ValidationResult, float]] = OrderedDict() self._max_size = max_size self._ttl = ttl_seconds self._lock = asyncio.Lock() async def get(self, key: str) -> ValidationResult | None: """Get cached validation result.""" import time async with self._lock: if key not in self._cache: return None result, timestamp = self._cache[key] if time.time() - timestamp > self._ttl: del self._cache[key] return None # Move to end (LRU) self._cache.move_to_end(key) return result async def set(self, key: str, result: ValidationResult) -> None: """Cache a validation result.""" import time async with self._lock: if key in self._cache: self._cache.move_to_end(key) else: if len(self._cache) >= self._max_size: self._cache.popitem(last=False) self._cache[key] = (result, time.time()) async def clear(self) -> None: """Clear the cache.""" async with self._lock: self._cache.clear() class ActionValidator: """ Validates actions against safety rules before execution. Features: - Rule-based validation engine - Allow/deny/require-approval rules - Pattern matching for tools and resources - Validation result caching - Bypass capability for emergencies """ def __init__( self, cache_enabled: bool = True, cache_size: int = 1000, cache_ttl: int = 60, ) -> None: """ Initialize the ActionValidator. Args: cache_enabled: Whether to cache validation results cache_size: Maximum cache entries cache_ttl: Cache TTL in seconds """ self._rules: list[ValidationRule] = [] self._cache_enabled = cache_enabled self._cache = ValidationCache(max_size=cache_size, ttl_seconds=cache_ttl) self._bypass_enabled = False self._bypass_reason: str | None = None config = get_safety_config() self._cache_enabled = cache_enabled self._cache_ttl = config.validation_cache_ttl self._cache_size = config.validation_cache_size def add_rule(self, rule: ValidationRule) -> None: """ Add a validation rule. Args: rule: The rule to add """ self._rules.append(rule) # Re-sort by priority (higher first) self._rules.sort(key=lambda r: r.priority, reverse=True) logger.debug( "Added validation rule: %s (priority %d)", rule.name, rule.priority ) def remove_rule(self, rule_id: str) -> bool: """ Remove a validation rule by ID. Args: rule_id: ID of the rule to remove Returns: True if rule was found and removed """ for i, rule in enumerate(self._rules): if rule.id == rule_id: del self._rules[i] logger.debug("Removed validation rule: %s", rule_id) return True return False def clear_rules(self) -> None: """Remove all validation rules.""" self._rules.clear() def load_rules_from_policy(self, policy: SafetyPolicy) -> None: """ Load validation rules from a safety policy. Args: policy: The policy to load rules from """ # Clear existing rules self.clear_rules() # Add rules from policy for rule in policy.validation_rules: self.add_rule(rule) # Create implicit rules from policy settings # Denied tools for i, pattern in enumerate(policy.denied_tools): self.add_rule( ValidationRule( name=f"deny_tool_{i}", description=f"Deny tool pattern: {pattern}", priority=100, # High priority for denials tool_patterns=[pattern], decision=SafetyDecision.DENY, reason=f"Tool matches denied pattern: {pattern}", ) ) # Require approval patterns for i, pattern in enumerate(policy.require_approval_for): if pattern == "*": # All actions require approval self.add_rule( ValidationRule( name="require_approval_all", description="All actions require approval", priority=50, action_types=list(ActionType), decision=SafetyDecision.REQUIRE_APPROVAL, reason="All actions require human approval", ) ) else: self.add_rule( ValidationRule( name=f"require_approval_{i}", description=f"Require approval for: {pattern}", priority=50, tool_patterns=[pattern], decision=SafetyDecision.REQUIRE_APPROVAL, reason=f"Action matches approval-required pattern: {pattern}", ) ) logger.info("Loaded %d rules from policy: %s", len(self._rules), policy.name) async def validate( self, action: ActionRequest, policy: SafetyPolicy | None = None, ) -> ValidationResult: """ Validate an action against all rules. Args: action: The action to validate policy: Optional policy override Returns: ValidationResult with decision and details """ # Check bypass if self._bypass_enabled: logger.warning( "Validation bypass active: %s - allowing action %s", self._bypass_reason, action.id, ) return ValidationResult( action_id=action.id, decision=SafetyDecision.ALLOW, applied_rules=[], reasons=[f"Validation bypassed: {self._bypass_reason}"], ) # Check cache if self._cache_enabled: cache_key = self._get_cache_key(action) cached = await self._cache.get(cache_key) if cached: logger.debug("Using cached validation for action %s", action.id) return cached # Load rules from policy if provided if policy and not self._rules: self.load_rules_from_policy(policy) # Validate against rules applied_rules: list[str] = [] reasons: list[str] = [] final_decision = SafetyDecision.ALLOW approval_id: str | None = None for rule in self._rules: if not rule.enabled: continue if self._rule_matches(rule, action): applied_rules.append(rule.id) if rule.reason: reasons.append(rule.reason) # Handle decision priority if rule.decision == SafetyDecision.DENY: # Deny takes precedence final_decision = SafetyDecision.DENY break elif rule.decision == SafetyDecision.REQUIRE_APPROVAL: # Upgrade to require approval if final_decision != SafetyDecision.DENY: final_decision = SafetyDecision.REQUIRE_APPROVAL # If no rules matched and no explicit allow, default to allow if not applied_rules: reasons.append("No matching rules - default allow") result = ValidationResult( action_id=action.id, decision=final_decision, applied_rules=applied_rules, reasons=reasons, approval_id=approval_id, ) # Cache result if self._cache_enabled: cache_key = self._get_cache_key(action) await self._cache.set(cache_key, result) return result async def validate_batch( self, actions: list[ActionRequest], policy: SafetyPolicy | None = None, ) -> list[ValidationResult]: """ Validate multiple actions. Args: actions: Actions to validate policy: Optional policy override Returns: List of validation results """ tasks = [self.validate(action, policy) for action in actions] return await asyncio.gather(*tasks) def enable_bypass(self, reason: str) -> None: """ Enable validation bypass (emergency use only). Args: reason: Reason for enabling bypass """ logger.critical("Validation bypass enabled: %s", reason) self._bypass_enabled = True self._bypass_reason = reason def disable_bypass(self) -> None: """Disable validation bypass.""" logger.info("Validation bypass disabled") self._bypass_enabled = False self._bypass_reason = None async def clear_cache(self) -> None: """Clear the validation cache.""" await self._cache.clear() def _rule_matches(self, rule: ValidationRule, action: ActionRequest) -> bool: """Check if a rule matches an action.""" # Check action types if rule.action_types: if action.action_type not in rule.action_types: return False # Check tool patterns if rule.tool_patterns: if not action.tool_name: return False matched = False for pattern in rule.tool_patterns: if self._matches_pattern(action.tool_name, pattern): matched = True break if not matched: return False # Check resource patterns if rule.resource_patterns: if not action.resource: return False matched = False for pattern in rule.resource_patterns: if self._matches_pattern(action.resource, pattern): matched = True break if not matched: return False # Check agent IDs if rule.agent_ids: if action.metadata.agent_id not in rule.agent_ids: return False return True def _matches_pattern(self, value: str, pattern: str) -> bool: """Check if value matches a pattern (supports wildcards).""" if pattern == "*": return True # Use fnmatch for glob-style matching return fnmatch.fnmatch(value, pattern) def _get_cache_key(self, action: ActionRequest) -> str: """Generate a cache key for an action.""" # Key based on action characteristics that affect validation key_parts = [ action.action_type.value, action.tool_name or "", action.resource or "", action.metadata.agent_id, action.metadata.autonomy_level.value, ] return ":".join(key_parts) # Module-level convenience functions def create_allow_rule( name: str, tool_patterns: list[str] | None = None, resource_patterns: list[str] | None = None, action_types: list[ActionType] | None = None, priority: int = 0, ) -> ValidationRule: """Create an allow rule.""" return ValidationRule( name=name, tool_patterns=tool_patterns, resource_patterns=resource_patterns, action_types=action_types, decision=SafetyDecision.ALLOW, priority=priority, ) def create_deny_rule( name: str, tool_patterns: list[str] | None = None, resource_patterns: list[str] | None = None, action_types: list[ActionType] | None = None, reason: str | None = None, priority: int = 100, ) -> ValidationRule: """Create a deny rule.""" return ValidationRule( name=name, tool_patterns=tool_patterns, resource_patterns=resource_patterns, action_types=action_types, decision=SafetyDecision.DENY, reason=reason, priority=priority, ) def create_approval_rule( name: str, tool_patterns: list[str] | None = None, resource_patterns: list[str] | None = None, action_types: list[ActionType] | None = None, reason: str | None = None, priority: int = 50, ) -> ValidationRule: """Create a require-approval rule.""" return ValidationRule( name=name, tool_patterns=tool_patterns, resource_patterns=resource_patterns, action_types=action_types, decision=SafetyDecision.REQUIRE_APPROVAL, reason=reason, priority=priority, )