"""Small typed policy evaluator shared by management, router, and connectors.""" from __future__ import annotations import fnmatch import re from collections.abc import Iterable, Mapping from enum import StrEnum from typing import Any from uuid import UUID, uuid4 from pydantic import BaseModel, ConfigDict, Field, field_validator class PolicyValidationError(ValueError): """A policy contains an unsupported or unsafe selector.""" class Decision(StrEnum): ALLOW = "allow" ALLOW_WITH_PREVIEW = "allow_with_preview" ALLOW_WITH_APPROVAL = "allow_with_approval" ALLOW_IF_CONDITIONS = "allow_if_conditions" ENFORCE = "enforce" DENY = "deny" DENY_AND_ALERT = "deny_and_alert" _IDENTIFIER = re.compile(r"^[A-Za-z0-9._:/-]{1,256}$") _MATCH_FIELDS = { "workspace", "workspaceId", "project", "projectId", "projects", "projectSlugs", "environment", "environments", "environmentClassifications", "client", "clientId", "clientIds", "principal", "principalType", "virtualServer", "virtualServers", "capability", "capabilities", "tool", "tools", "toolPatterns", "resourceType", "resourceTypes", "connector", "connectors", "riskClass", "riskClasses", "origin", "origins", } _CONDITION_FIELDS = { "requireEnabledClient", "requireBinding", "requireScope", "requireCapabilityPack", "requireConnectorBinding", "requireInputValidation", "requireOutputValidation", "requireRedaction", "maxArgumentBytes", "allEqual", "requiredClaims", "maxDefaultBytes", "maxDefaultItems", "redactionProfile", } class InvocationContext(BaseModel): """Validated identity and target context used at every sensitive boundary.""" model_config = ConfigDict(extra="forbid", frozen=True) workspace: str = Field(min_length=1, max_length=128) project: str = Field(min_length=1, max_length=128) environment: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,31}$") client_id: UUID | None = None principal: str = Field(min_length=1, max_length=256) principal_type: str = Field(default="machine", max_length=64) virtual_server: str | None = Field(default=None, max_length=128) capability: str | None = Field(default=None, max_length=256) tool: str = Field(min_length=1, max_length=256) connector: str = Field(min_length=1, max_length=64) risk_class: str = Field(default="R1", pattern=r"^(?:R[0-9]+|RX)$") origin: str = Field(default="mcp", max_length=64) resource_type: str | None = Field(default=None, max_length=64) environment_classification: str | None = Field(default=None, max_length=64) binding_present: bool = False client_enabled: bool = False arguments: Mapping[str, Any] = Field(default_factory=dict) trace_id: str = Field(default_factory=lambda: uuid4().hex, pattern=r"^[A-Za-z0-9._:-]{1,128}$") class PolicyRule(BaseModel): """A catalog or persisted rule with only typed selector primitives.""" model_config = ConfigDict(extra="forbid", frozen=True) slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$") workspace: str | None = None project: str | None = None priority: int = Field(ge=0, le=100000) description: str = Field(min_length=1, max_length=1000) match: dict[str, Any] = Field(default_factory=dict) conditions: dict[str, Any] = Field(default_factory=dict) decision: Decision terminal: bool = False source: str = Field(default="catalog", max_length=64) enabled: bool = True @field_validator("match", "conditions") @classmethod def safe_maps(cls, value: dict[str, Any]) -> dict[str, Any]: if len(value) > 32: raise PolicyValidationError("policy selector map is too large") return value @classmethod def from_document(cls, document: Mapping[str, Any]) -> PolicyRule: metadata = document.get("metadata") or {} spec = document.get("spec") or {} if not isinstance(metadata, Mapping) or not isinstance(spec, Mapping): raise PolicyValidationError("policy document metadata and spec must be objects") match = dict(spec.get("match") or {}) conditions = dict(spec.get("conditions") or {}) unknown_match = set(match) - _MATCH_FIELDS unknown_conditions = set(conditions) - _CONDITION_FIELDS if unknown_match or unknown_conditions: raise PolicyValidationError( f"unsupported policy fields: {sorted(unknown_match | unknown_conditions)}" ) project = spec.get("project") workspace = spec.get("workspace") return cls( slug=str(metadata.get("slug", "")), workspace=str(workspace) if workspace is not None else None, project=str(project) if project is not None else None, priority=int(spec.get("priority", 0)), description=str(spec.get("description", "")), match=match, conditions=conditions, decision=Decision(str(spec.get("decision", "deny"))), terminal=bool(spec.get("terminal", False)), source=str(spec.get("source", "catalog")), enabled=bool(spec.get("enabled", True)), ) class TraceStep(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) phase: str rule: str | None = None matched: bool reason: str class DecisionTrace(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) trace_id: str decision: Decision reason_code: str matched_rule_ids: tuple[str, ...] = () steps: tuple[TraceStep, ...] = () def _values(value: Any) -> tuple[str, ...]: if isinstance(value, list | tuple | set): return tuple(str(item) for item in value) return (str(value),) def _context_value(context: InvocationContext, key: str) -> tuple[str, ...]: mapping: dict[str, Any] = { "workspace": context.workspace, "workspaceId": context.workspace, "project": context.project, "projectId": context.project, "projectSlugs": context.project, "projects": context.project, "environment": context.environment, "environments": context.environment, "environmentClassifications": context.environment_classification or context.environment, "resourceType": context.resource_type or "", "resourceTypes": context.resource_type or "", "client": str(context.client_id) if context.client_id else "", "clientId": str(context.client_id) if context.client_id else "", "clientIds": str(context.client_id) if context.client_id else "", "principal": context.principal, "principalType": context.principal_type, "virtualServer": context.virtual_server or "", "capability": context.capability or "", "capabilities": context.capability or "", "tool": context.tool, "tools": context.tool, "toolPatterns": context.tool, "connector": context.connector, "connectors": context.connector, "riskClass": context.risk_class, "riskClasses": context.risk_class, "origin": context.origin, "origins": context.origin, } return _values(mapping.get(key, "")) def _rule_scope_matches(rule: PolicyRule, context: InvocationContext) -> bool: return (rule.workspace is None or rule.workspace == context.workspace) and ( rule.project is None or rule.project == context.project ) def _matches(rule: PolicyRule, context: InvocationContext) -> bool: if not _rule_scope_matches(rule, context): return False for key, expected in rule.match.items(): if key not in _MATCH_FIELDS: raise PolicyValidationError(f"unsupported policy match field: {key}") actual = _context_value(context, key) expected_values = _values(expected) if key in {"toolPatterns"}: if not any( fnmatch.fnmatchcase(value, pattern) for value in actual for pattern in expected_values ): return False elif not any(value in expected_values for value in actual): return False return True def _conditions_match(rule: PolicyRule, context: InvocationContext) -> bool: for key, expected in rule.conditions.items(): if key not in _CONDITION_FIELDS: raise PolicyValidationError(f"unsupported policy condition: {key}") if key == "requireEnabledClient" and bool(expected) and not context.client_enabled: return False if key == "requireBinding" and bool(expected) and not context.binding_present: return False if key == "maxArgumentBytes" and len(str(dict(context.arguments)).encode()) > int(expected): return False if key in { "allEqual", "requiredClaims", "maxDefaultBytes", "maxDefaultItems", "redactionProfile", }: return False return True class PolicyEngine: """Deterministic precedence: validation, global deny, explicit deny, allow, default deny.""" def __init__(self, policies: Iterable[PolicyRule]) -> None: self.policies = tuple( sorted( (item for item in policies if item.enabled), key=lambda item: (-item.priority, item.slug), ) ) for policy in self.policies: unknown = set(policy.match) - _MATCH_FIELDS if unknown: raise PolicyValidationError(f"unsupported policy match fields: {sorted(unknown)}") if ( policy.decision in { Decision.ALLOW, Decision.ALLOW_IF_CONDITIONS, Decision.ALLOW_WITH_APPROVAL, Decision.ALLOW_WITH_PREVIEW, } and policy.project is None and policy.slug != "global-deny" ): # Workspace-wide allows must not expand a project scope implicitly. continue def evaluate(self, context: InvocationContext) -> DecisionTrace: steps: list[TraceStep] = [] if not context.binding_present: return DecisionTrace( trace_id=context.trace_id, decision=Decision.DENY, reason_code="MISSING_BINDING", steps=( TraceStep( phase="identity", matched=False, reason="client/project binding is required" ), ), ) for rule in self.policies: matched = _matches(rule, context) steps.append( TraceStep( phase="global-deny" if rule.project is None else "match", rule=rule.slug, matched=matched, reason="selector matched" if matched else "selector did not match", ) ) if ( matched and rule.project is None and rule.decision in {Decision.DENY, Decision.DENY_AND_ALERT} and rule.terminal ): return self._result(context, rule.decision, "GLOBAL_DENY", [rule.slug], steps) for rule in self.policies: if ( rule.slug == "global-deny" or rule.decision not in {Decision.DENY, Decision.DENY_AND_ALERT} or not _matches(rule, context) ): continue return self._result(context, rule.decision, "EXPLICIT_DENY", [rule.slug], steps) for rule in self.policies: if rule.decision not in { Decision.ALLOW, Decision.ALLOW_IF_CONDITIONS, Decision.ALLOW_WITH_APPROVAL, Decision.ALLOW_WITH_PREVIEW, } or not _matches(rule, context): continue conditions = _conditions_match(rule, context) steps.append( TraceStep( phase="conditions", rule=rule.slug, matched=conditions, reason="conditions satisfied" if conditions else "conditions not satisfied", ) ) if conditions: return self._result(context, rule.decision, "ALLOW_RULE", [rule.slug], steps) return self._result(context, Decision.DENY, "DEFAULT_DENY", [], steps) @staticmethod def _result( context: InvocationContext, decision: Decision, reason: str, rules: list[str], steps: list[TraceStep], ) -> DecisionTrace: return DecisionTrace( trace_id=context.trace_id, decision=decision, reason_code=reason, matched_rule_ids=tuple(rules), steps=tuple(steps), ) class PolicySimulator: """Runs the exact engine against a known-entity inventory without side effects.""" def __init__( self, engine: PolicyEngine, *, known_projects: Iterable[str] = (), known_clients: Iterable[UUID] = (), known_tools: Iterable[str] = (), ) -> None: self.engine = engine self.known_projects = frozenset(known_projects) self.known_clients = frozenset(known_clients) self.known_tools = frozenset(known_tools) def simulate(self, context: InvocationContext) -> DecisionTrace: if self.known_projects and context.project not in self.known_projects: return DecisionTrace( trace_id=context.trace_id, decision=Decision.DENY, reason_code="UNKNOWN_PROJECT", steps=( TraceStep( phase="simulation", matched=False, reason="project is not a known entity" ), ), ) if self.known_tools and context.tool not in self.known_tools: return DecisionTrace( trace_id=context.trace_id, decision=Decision.DENY, reason_code="UNKNOWN_TOOL", steps=( TraceStep( phase="simulation", matched=False, reason="tool is not a known entity" ), ), ) if context.client_id and self.known_clients and context.client_id not in self.known_clients: return DecisionTrace( trace_id=context.trace_id, decision=Decision.DENY, reason_code="UNKNOWN_CLIENT", steps=( TraceStep( phase="simulation", matched=False, reason="client is not a known entity" ), ), ) return self.engine.evaluate(context)