From 3c512c8a12ea2dfefddaecb4299bf7d28715819f Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Thu, 4 Jun 2026 14:43:55 +0530 Subject: [PATCH] feat: changes for governance --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/adapters/__init__.py | 35 ++ .../src/uipath/core/adapters/base.py | 107 +++++ .../src/uipath/core/adapters/evaluator.py | 100 +++++ .../src/uipath/core/adapters/registry.py | 153 +++++++ .../src/uipath/core/governance/__init__.py | 37 ++ .../src/uipath/core/governance/config.py | 33 ++ .../src/uipath/core/governance/exceptions.py | 114 +++++ .../src/uipath/core/governance/models.py | 69 ++++ .../uipath-core/tests/adapters/__init__.py | 0 .../uipath-core/tests/adapters/test_base.py | 163 ++++++++ .../tests/adapters/test_evaluator.py | 104 +++++ .../tests/adapters/test_registry.py | 388 ++++++++++++++++++ .../uipath-core/tests/governance/__init__.py | 0 .../tests/governance/test_config.py | 47 +++ .../tests/governance/test_exceptions.py | 205 +++++++++ packages/uipath-core/uv.lock | 2 +- 17 files changed, 1557 insertions(+), 2 deletions(-) create mode 100644 packages/uipath-core/src/uipath/core/adapters/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/adapters/base.py create mode 100644 packages/uipath-core/src/uipath/core/adapters/evaluator.py create mode 100644 packages/uipath-core/src/uipath/core/adapters/registry.py create mode 100644 packages/uipath-core/src/uipath/core/governance/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/governance/config.py create mode 100644 packages/uipath-core/src/uipath/core/governance/exceptions.py create mode 100644 packages/uipath-core/src/uipath/core/governance/models.py create mode 100644 packages/uipath-core/tests/adapters/__init__.py create mode 100644 packages/uipath-core/tests/adapters/test_base.py create mode 100644 packages/uipath-core/tests/adapters/test_evaluator.py create mode 100644 packages/uipath-core/tests/adapters/test_registry.py create mode 100644 packages/uipath-core/tests/governance/__init__.py create mode 100644 packages/uipath-core/tests/governance/test_config.py create mode 100644 packages/uipath-core/tests/governance/test_exceptions.py diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index c07e4c3b7..58655ae63 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.17" +version = "0.6.0" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/adapters/__init__.py b/packages/uipath-core/src/uipath/core/adapters/__init__.py new file mode 100644 index 000000000..5906b1b39 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/__init__.py @@ -0,0 +1,35 @@ +"""Generic adapter contracts for framework integrations. + +This package holds only the abstract contracts — concrete adapter +implementations live in framework-specific plugin packages (e.g. +``uipath-langchain``, ``uipath-openai``) that target the framework they +integrate with. Plugin packages register their concrete adapters with +the global :class:`AdapterRegistry` via the +``uipath.governance.adapters`` entry-point group. + +Public surface: + +- :class:`BaseAdapter` – abstract base every adapter inherits from. +- :class:`GovernedAgentBase` – proxy base for governed agent wrappers. +- :class:`EvaluatorProtocol` – structural protocol the adapter expects + from any policy evaluator. +- :class:`AdapterRegistry` – ordered list of adapters that resolves + the first match for a given agent. +""" + +from .base import BaseAdapter, GovernedAgentBase +from .evaluator import EvaluatorProtocol +from .registry import ( + AdapterRegistry, + get_adapter_registry, + reset_adapter_registry, +) + +__all__ = [ + "BaseAdapter", + "GovernedAgentBase", + "EvaluatorProtocol", + "AdapterRegistry", + "get_adapter_registry", + "reset_adapter_registry", +] diff --git a/packages/uipath-core/src/uipath/core/adapters/base.py b/packages/uipath-core/src/uipath/core/adapters/base.py new file mode 100644 index 000000000..975d35ed4 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/base.py @@ -0,0 +1,107 @@ +"""Base adapter contracts for framework-specific integrations. + +An adapter's job: + +1. Detect whether it can handle a given agent object. +2. Attach hooks to that agent (framework-specific). +3. Publish events to a policy evaluator when those hooks fire. + +The evaluator subscribes to events and runs policy checks; it never +knows or cares which adapter fired the event. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any +from uuid import uuid4 + +from .evaluator import EvaluatorProtocol + + +class BaseAdapter(ABC): + """Base class for framework-specific governance adapters.""" + + #: Set to True on a catch-all adapter that should always sort last in + #: the registry. The registry uses this flag (not the class name) to + #: keep the fallback in last position when new adapters register. + is_fallback: bool = False + + @property + def name(self) -> str: + """Return adapter name for logging.""" + return self.__class__.__name__ + + @abstractmethod + def can_handle(self, agent: Any) -> bool: + """Return True if this adapter knows how to hook into this agent type.""" + + @abstractmethod + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + """Attach governance hooks to the agent. + + Args: + agent: The agent to govern. + agent_id: Unique identifier for the agent. + session_id: Session identifier for tracing. + evaluator: Policy evaluator implementing + :class:`EvaluatorProtocol`. + + Returns: + A governed proxy (or the original agent with hooks installed). + """ + + def detach(self, governed: Any) -> Any: + """Detach governance and return the original agent. + + Default implementation uses the public :attr:`GovernedAgentBase.unwrapped` + contract; non-proxy adapters that return the original agent from + :meth:`attach` get back ``governed`` unchanged. + """ + return getattr(governed, "unwrapped", governed) + + def _generate_trace_id(self) -> str: + """Generate a trace ID for governance events.""" + return str(uuid4()) + + +class GovernedAgentBase: + """Base class for governed agent proxies. + + Provides common functionality for all governed agents: + + - Stores reference to original agent + - Forwards unknown attributes to original agent + - Tracks governance metadata + """ + + def __init__( + self, + agent: Any, + adapter: BaseAdapter, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> None: + """Initialize with the wrapped agent and governance metadata.""" + self._agent = agent + self._adapter = adapter + self._agent_id = agent_id + self._session_id = session_id + self._evaluator = evaluator + self._trace_id = adapter._generate_trace_id() + + @property + def unwrapped(self) -> Any: + """Get the original unwrapped agent.""" + return self._agent + + def __getattr__(self, name: str) -> Any: + """Forward attribute access to the original agent.""" + return getattr(self._agent, name) diff --git a/packages/uipath-core/src/uipath/core/adapters/evaluator.py b/packages/uipath-core/src/uipath/core/adapters/evaluator.py new file mode 100644 index 000000000..2a1723c44 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/evaluator.py @@ -0,0 +1,100 @@ +"""Structural contract for the policy evaluator an adapter talks to. + +Framework adapters call into a policy evaluator at each lifecycle hook. +The concrete evaluator (e.g. +:class:`uipath.core.governance.native.evaluator.GovernanceEvaluator`) +lives elsewhere; adapters depend only on this structural protocol so +they can be swapped against alternative evaluators (Microsoft AGT, +composite, …) without code change. + +``EvaluatorProtocol`` is a :class:`typing.Protocol` so any class whose +methods match the signatures below satisfies the contract without +inheritance. +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class EvaluatorProtocol(Protocol): + """Structural protocol an adapter expects from a policy evaluator. + + Return types are intentionally :class:`typing.Any`: the concrete + audit record shape lives in the plugin package that owns the + evaluator and the policy model. Adapters in that package cast the + return value back to the concrete type they know. + """ + + def evaluate_before_agent( + self, + agent_input: str, + agent_name: str, + runtime_id: str, + trace_id: str, + model_name: str = "", + **kwargs: Any, + ) -> Any: + """Evaluate BEFORE_AGENT rules.""" + ... + + def evaluate_after_agent( + self, + agent_output: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_AGENT rules.""" + ... + + def evaluate_before_model( + self, + model_input: str, + agent_name: str, + runtime_id: str, + trace_id: str, + messages: list[dict[str, Any]] | None = None, + model_name: str = "", + **kwargs: Any, + ) -> Any: + """Evaluate BEFORE_MODEL rules.""" + ... + + def evaluate_after_model( + self, + model_output: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_MODEL rules.""" + ... + + def evaluate_tool_call( + self, + tool_name: str, + tool_args: dict[str, Any], + agent_name: str, + runtime_id: str, + trace_id: str, + session_state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Any: + """Evaluate TOOL_CALL rules.""" + ... + + def evaluate_after_tool( + self, + tool_name: str, + tool_result: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> Any: + """Evaluate AFTER_TOOL rules.""" + ... diff --git a/packages/uipath-core/src/uipath/core/adapters/registry.py b/packages/uipath-core/src/uipath/core/adapters/registry.py new file mode 100644 index 000000000..ccdf6a8e7 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/adapters/registry.py @@ -0,0 +1,153 @@ +"""Ordered registry of framework adapters. + +The registry is a pure, implementation-agnostic container — it does +**not** know about any concrete adapter. Plugin packages (e.g. +``uipath-langchain``) populate it by either: + +1. Declaring a ``uipath.governance.adapters`` entry point whose value + is a zero-arg callable that calls :meth:`AdapterRegistry.register`. + These are auto-discovered on first call to + :func:`get_adapter_registry`. +2. Calling :meth:`AdapterRegistry.register` directly at import time + (e.g. side-effect on importing the plugin's governance submodule). + +Adapters are checked in registration order: register more specific +adapters before generic ones — first ``can_handle`` match wins. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from .base import BaseAdapter + +logger = logging.getLogger(__name__) + +ENTRY_POINT_GROUP = "uipath.governance.adapters" + + +class AdapterRegistry: + """Ordered list of adapters; resolves the first match for an agent.""" + + def __init__(self) -> None: + """Initialize an empty registry.""" + self._adapters: list[BaseAdapter] = [] + + def register(self, adapter: BaseAdapter, position: int | None = None) -> None: + """Register an adapter. + + Args: + adapter: The adapter to register. + position: Insertion index (``0`` = highest priority). When + ``None`` the adapter is appended; if the last entry has + :attr:`BaseAdapter.is_fallback` set, the new adapter is + inserted just before it so the fallback stays last. + """ + if position is not None: + self._adapters.insert(position, adapter) + elif self._adapters and self._adapters[-1].is_fallback: + self._adapters.insert(-1, adapter) + else: + self._adapters.append(adapter) + logger.debug("Registered adapter: %s", adapter.name) + + def resolve(self, agent: Any) -> BaseAdapter | None: + """Return the first adapter that can handle ``agent`` (or ``None``).""" + for adapter in self._adapters: + try: + if adapter.can_handle(agent): + logger.debug( + "AdapterRegistry: %s -> %s", + type(agent).__name__, + adapter.name, + ) + return adapter + except Exception as exc: + logger.warning( + "Adapter %s.can_handle() failed: %s", + adapter.name, + exc, + ) + continue + return None + + def get_all(self) -> list[BaseAdapter]: + """Return a copy of the registered adapters in priority order.""" + return self._adapters.copy() + + def clear(self) -> None: + """Remove all registered adapters.""" + self._adapters.clear() + + +_registry: AdapterRegistry | None = None + + +def _discover_entry_point_adapters() -> None: + """Load every adapter advertised under the ``uipath.governance.adapters`` group. + + Each entry-point value must be a zero-arg callable (typically a + ``register_*`` function in the plugin package) that calls + :meth:`AdapterRegistry.register`. A failure to load or invoke any + one entry point is logged and skipped — a single broken plugin + must never block governance startup. + """ + try: + from importlib.metadata import entry_points + except ImportError: # pragma: no cover - importlib.metadata is stdlib in py3.11+ + return + + try: + eps = entry_points(group=ENTRY_POINT_GROUP) + except Exception as exc: # noqa: BLE001 - discovery failures must never raise + logger.warning("Adapter entry-point discovery failed: %s", exc) + return + + for ep in eps: + try: + registrar = ep.load() + except Exception as exc: # noqa: BLE001 - one broken plugin must not block others + logger.warning( + "Failed to load governance adapter entry point '%s' (%s): %s", + ep.name, + ep.value, + exc, + ) + continue + if not callable(registrar): + logger.warning( + "Governance adapter entry point '%s' is not callable: %r", + ep.name, + registrar, + ) + continue + try: + registrar() + except Exception as exc: # noqa: BLE001 - one broken plugin must not block others + logger.warning( + "Governance adapter '%s' register call failed: %s", ep.name, exc + ) + + +def get_adapter_registry() -> AdapterRegistry: + """Return the process-wide adapter registry singleton. + + On first call, discovers and registers every adapter declared under + the ``uipath.governance.adapters`` entry-point group, so framework + SDKs (``uipath-langchain``, ``uipath-openai``, …) just need to be + installed — no explicit import is required. + """ + global _registry + if _registry is None: + _registry = AdapterRegistry() + _discover_entry_point_adapters() + return _registry + + +def reset_adapter_registry() -> None: + """Drop the singleton registry (intended for tests).""" + global _registry + if _registry is not None: + _registry.clear() + _registry = None diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py new file mode 100644 index 000000000..dd32228ed --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -0,0 +1,37 @@ +"""UiPath governance shared contracts. + +Evaluator-agnostic types every governance consumer references — +adapter packages (``uipath-langchain``, ``uipath-openai``, …), the +runtime layer (``uipath.runtime.governance``), and customer code that +catches :class:`GovernanceBlockException`. The full runtime / audit / +native-evaluator implementation lives in ``uipath.runtime.governance``; +this core surface is just the contracts. +""" + +from .config import ( + GOVERNANCE_FEATURE_FLAG, + is_governance_enabled, +) +from .exceptions import ( + GovernanceBlockException, + GovernanceConfigError, + GovernanceViolation, + Severity, +) +from .models import Action, AuditRecord, LifecycleHook, RuleEvaluation + +__all__ = [ + # Output models (cross adapter boundary) + "Action", + "AuditRecord", + "LifecycleHook", + "RuleEvaluation", + # Config + "GOVERNANCE_FEATURE_FLAG", + "is_governance_enabled", + # Exceptions + "GovernanceBlockException", + "GovernanceConfigError", + "GovernanceViolation", + "Severity", +] diff --git a/packages/uipath-core/src/uipath/core/governance/config.py b/packages/uipath-core/src/uipath/core/governance/config.py new file mode 100644 index 000000000..da99d3e13 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/config.py @@ -0,0 +1,33 @@ +"""Governance configuration. + +Process-level feature-flag gate that decides whether the Python +governance checker runs at all. Enforcement mode is per-policy and +lives in the runtime package alongside the ``/runtime/policy`` client. +""" + +from __future__ import annotations + +import logging + +from uipath.core.feature_flags import FeatureFlags + +logger = logging.getLogger(__name__) + +# Feature flag name controlling whether governance runs. +# Mirrors the gate in ``uipath-runtime`` so the platform-injection path +# and direct callers (agents constructing an evaluator themselves) +# honour the same toggle. +GOVERNANCE_FEATURE_FLAG = "EnablePythonGovernanceChecker" + + +def is_governance_enabled() -> bool: + """Return True iff the ``EnablePythonGovernanceChecker`` flag is set. + + Resolution order: + + 1. :meth:`uipath.core.feature_flags.FeatureFlagsManager.is_flag_enabled` - + the in-process programmatic registry (typically populated from + gitops) and its own ``UIPATH_FEATURE_`` env-var fallback. + 2. Default ``True``. + """ + return FeatureFlags.is_flag_enabled(GOVERNANCE_FEATURE_FLAG, default=False) diff --git a/packages/uipath-core/src/uipath/core/governance/exceptions.py b/packages/uipath-core/src/uipath/core/governance/exceptions.py new file mode 100644 index 000000000..48f4b178a --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/exceptions.py @@ -0,0 +1,114 @@ +"""Governance exception types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from uipath.core.governance.models import AuditRecord + +_DEFAULT_RULE_ID = "POLICY" +_DEFAULT_RULE_NAME = "Governance Policy" +_MSG_PREFIX = "[Governance Policy Violation]" + + +class Severity(str, Enum): + """Severity classification for a governance violation.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +@dataclass +class GovernanceViolation: + """Details of a governance rule violation.""" + + rule_id: str + rule_name: str + detail: str + severity: Severity = Severity.HIGH + + +def _format_violation_message(rule_id: str, rule_name: str, detail: str) -> str: + return f"{_MSG_PREFIX} {rule_name} ({rule_id}): {detail}" + + +class GovernanceBlockException(Exception): + """Raised when a governance policy blocks an operation. + + This exception indicates that the AI agent's operation was blocked by + a configured governance policy, not an unexpected system error. + + Prefer the classmethod constructors (:meth:`from_violation`, + :meth:`from_audit_record`) when you have structured context — the + default constructor is for raw-message use only. + """ + + # Error code for Orchestrator categorization + error_code: str = "GOVERNANCE_POLICY_VIOLATION" + + def __init__( + self, + message: str | None = None, + *, + violation: GovernanceViolation | None = None, + audit_record: AuditRecord | None = None, + rule_id: str = _DEFAULT_RULE_ID, + rule_name: str = _DEFAULT_RULE_NAME, + ) -> None: + """Construct from a pre-formatted message and optional structured context. + + Most callers should use :meth:`from_violation` or + :meth:`from_audit_record` instead of passing structured context + directly. + """ + self.violation = violation + self.audit_record = audit_record + self.rule_id = rule_id + self.rule_name = rule_name + super().__init__( + message or f"{_MSG_PREFIX} Operation blocked by governance policy." + ) + + @classmethod + def from_violation( + cls, violation: GovernanceViolation + ) -> "GovernanceBlockException": + """Build from a structured :class:`GovernanceViolation`.""" + return cls( + message=_format_violation_message( + violation.rule_id, violation.rule_name, violation.detail + ), + violation=violation, + rule_id=violation.rule_id, + rule_name=violation.rule_name, + ) + + @classmethod + def from_audit_record(cls, audit_record: AuditRecord) -> "GovernanceBlockException": + """Build from an :class:`AuditRecord` — first matched rule wins.""" + matched_rules = [e for e in audit_record.evaluations if e.matched] + if matched_rules: + rule = matched_rules[0] + message = _format_violation_message( + rule.rule_id, rule.rule_name, rule.detail or "Policy violation detected" + ) + return cls( + message=message, + audit_record=audit_record, + rule_id=rule.rule_id, + rule_name=rule.rule_name, + ) + return cls( + message=( + f"{_MSG_PREFIX} Operation blocked. " + f"Rules evaluated: {len(audit_record.evaluations)}" + ), + audit_record=audit_record, + ) + + +class GovernanceConfigError(RuntimeError): + """Raised when governance is misconfigured.""" diff --git a/packages/uipath-core/src/uipath/core/governance/models.py b/packages/uipath-core/src/uipath/core/governance/models.py new file mode 100644 index 000000000..1446429d3 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/governance/models.py @@ -0,0 +1,69 @@ +"""Shared governance output types. + +These dataclasses cross the adapter boundary — every evaluator +implementation (native, AGT, composite, …) produces them, and every +adapter consumes them. They are kept free of policy-input concepts +(``Rule``/``Check``/``Condition``) so the adapter packages don't +inherit the native policy model. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any + + +class Action(str, Enum): + """Actions that can be taken when a rule matches.""" + + ALLOW = "allow" + DENY = "deny" + AUDIT = "audit" + ESCALATE = "escalate" + + +class LifecycleHook(str, Enum): + """Agent lifecycle hooks where rules can be evaluated.""" + + BEFORE_AGENT = "before_agent" + AFTER_AGENT = "after_agent" + BEFORE_MODEL = "before_model" + AFTER_MODEL = "after_model" + TOOL_CALL = "tool_call" + AFTER_TOOL = "after_tool" + MEMORY_WRITE = "memory_write" + + +@dataclass +class RuleEvaluation: + """Result of evaluating a single rule.""" + + rule_id: str + rule_name: str + matched: bool + detail: str = "" + pack_name: str = "" + action: Action = Action.ALLOW + description: str = "" + check_results: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class AuditRecord: + """Complete audit record for a governance evaluation.""" + + timestamp: datetime + agent_name: str + runtime_id: str + trace_id: str + hook: LifecycleHook + evaluations: list[RuleEvaluation] + final_action: Action + metadata: dict[str, Any] = field(default_factory=dict) + rules_matched: int = field(init=False) + + def __post_init__(self) -> None: + """Derive rules_matched from the evaluations list.""" + self.rules_matched = sum(1 for e in self.evaluations if e.matched) diff --git a/packages/uipath-core/tests/adapters/__init__.py b/packages/uipath-core/tests/adapters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/adapters/test_base.py b/packages/uipath-core/tests/adapters/test_base.py new file mode 100644 index 000000000..9be6346ed --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_base.py @@ -0,0 +1,163 @@ +"""Tests for BaseAdapter defaults and GovernedAgentBase proxy behavior.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from uipath.core.adapters import BaseAdapter, EvaluatorProtocol +from uipath.core.adapters.base import GovernedAgentBase + + +class _StubEvaluator: + """No-op evaluator that structurally matches EvaluatorProtocol.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return None + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return None + + +class _MinimalAdapter(BaseAdapter): + """Concrete adapter that does NOT override ``name`` — exercises the default.""" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _Agent: + """Simple stand-in for a framework agent with one attribute and one method.""" + + foo = "bar" + + def greet(self) -> str: + return "hello" + + +# --------------------------------------------------------------------------- +# BaseAdapter defaults +# --------------------------------------------------------------------------- + + +def test_default_name_is_class_name(): + """The default ``name`` property returns the class name.""" + assert _MinimalAdapter().name == "_MinimalAdapter" + + +def test_detach_returns_unwrapped_when_present(): + """``detach`` honours the ``unwrapped`` contract on a governed proxy.""" + adapter = _MinimalAdapter() + original = object() + + class _Proxy: + unwrapped = original + + assert adapter.detach(_Proxy()) is original + + +def test_detach_returns_input_when_no_unwrapped_attribute(): + """For non-proxy adapters, ``detach`` returns the input unchanged.""" + adapter = _MinimalAdapter() + raw = object() + assert adapter.detach(raw) is raw + + +def test_generate_trace_id_returns_unique_uuid_string(): + """``_generate_trace_id`` returns a string UUID; consecutive calls differ.""" + adapter = _MinimalAdapter() + a = adapter._generate_trace_id() + b = adapter._generate_trace_id() + assert isinstance(a, str) + assert a != b + assert len(a) == 36 # canonical UUID4 form: 32 hex + 4 dashes + + +# --------------------------------------------------------------------------- +# GovernedAgentBase proxy +# --------------------------------------------------------------------------- + + +def test_governed_agent_base_stores_metadata_and_generates_trace_id(): + """Constructor wires every governance field and pulls a trace id from the adapter.""" + agent = _Agent() + adapter = _MinimalAdapter() + evaluator = _StubEvaluator() + + governed = GovernedAgentBase( + agent=agent, + adapter=adapter, + agent_id="agent-123", + session_id="session-abc", + evaluator=evaluator, + ) + + assert governed._agent is agent + assert governed._adapter is adapter + assert governed._agent_id == "agent-123" + assert governed._session_id == "session-abc" + assert governed._evaluator is evaluator + assert isinstance(governed._trace_id, str) + assert len(governed._trace_id) == 36 + + +def test_governed_agent_base_unwrapped_returns_original_agent(): + agent = _Agent() + governed = GovernedAgentBase( + agent=agent, + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + assert governed.unwrapped is agent + + +def test_governed_agent_base_forwards_attribute_access_to_agent(): + """Unknown attributes fall through to the wrapped agent via __getattr__.""" + governed = GovernedAgentBase( + agent=_Agent(), + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + + assert governed.foo == "bar" + assert governed.greet() == "hello" + + +def test_governed_agent_base_attribute_miss_raises_attribute_error(): + """If the wrapped agent also lacks the attribute, AttributeError surfaces.""" + governed = GovernedAgentBase( + agent=_Agent(), + adapter=_MinimalAdapter(), + agent_id="a", + session_id="s", + evaluator=_StubEvaluator(), + ) + + with pytest.raises(AttributeError): + _ = governed.does_not_exist diff --git a/packages/uipath-core/tests/adapters/test_evaluator.py b/packages/uipath-core/tests/adapters/test_evaluator.py new file mode 100644 index 000000000..5c9e5c9e5 --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_evaluator.py @@ -0,0 +1,104 @@ +"""Tests for EvaluatorProtocol. + +The protocol is a structural type. These tests verify two things: + +1. A class whose method shapes match the protocol passes ``isinstance`` + against the ``runtime_checkable`` Protocol. +2. Subclassing the Protocol and calling ``super().`` actually + executes the stub bodies — this both documents that the stubs are + safely callable (they return ``None``) and brings the contract module + to full line coverage. +""" + +from __future__ import annotations + +from typing import Any + +from uipath.core.adapters import EvaluatorProtocol + + +class _MissingMethodEvaluator: + """Only implements one method — fails the structural check.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return None + + +class _CompleteEvaluator: + """All six methods present with the expected names — passes ``isinstance``.""" + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return "before-agent" + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return "after-agent" + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return "before-model" + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return "after-model" + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return "tool-call" + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return "after-tool" + + +class _ProtocolSubclass(EvaluatorProtocol): + """Subclass that delegates to ``super()`` — exercises the stub bodies. + + Each override calls ``super().(...)`` so the ``...`` body of + the Protocol method actually executes (returns ``None``). + """ + + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_before_agent(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_agent(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_before_model(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_model(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_tool_call(*args, **kwargs) # type: ignore[safe-super] + + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: + return super().evaluate_after_tool(*args, **kwargs) # type: ignore[safe-super] + + +# --------------------------------------------------------------------------- +# Structural conformance +# --------------------------------------------------------------------------- + + +def test_complete_evaluator_is_recognized_by_runtime_check(): + """A class with all six methods passes ``isinstance`` against the protocol.""" + assert isinstance(_CompleteEvaluator(), EvaluatorProtocol) + + +def test_partial_evaluator_is_rejected_by_runtime_check(): + """A class missing methods does NOT pass the structural check.""" + assert not isinstance(_MissingMethodEvaluator(), EvaluatorProtocol) + + +# --------------------------------------------------------------------------- +# Stub-body execution (line coverage for the ``...`` placeholders) +# --------------------------------------------------------------------------- + + +def test_protocol_subclass_methods_execute_stub_bodies(): + """Calling each method via ``super()`` executes the stub body and returns None.""" + e = _ProtocolSubclass() + + assert e.evaluate_before_agent("input", "agent", "rt", "trace") is None + assert e.evaluate_after_agent("output", "agent", "rt", "trace") is None + assert e.evaluate_before_model("input", "agent", "rt", "trace") is None + assert e.evaluate_after_model("output", "agent", "rt", "trace") is None + assert e.evaluate_tool_call("tool", {"arg": 1}, "agent", "rt", "trace") is None + assert e.evaluate_after_tool("tool", "result", "agent", "rt", "trace") is None diff --git a/packages/uipath-core/tests/adapters/test_registry.py b/packages/uipath-core/tests/adapters/test_registry.py new file mode 100644 index 000000000..d4ba8daf6 --- /dev/null +++ b/packages/uipath-core/tests/adapters/test_registry.py @@ -0,0 +1,388 @@ +"""Tests for AdapterRegistry — ordering, resolution, entry-point discovery.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from uipath.core.adapters import BaseAdapter, EvaluatorProtocol +from uipath.core.adapters.registry import ( + AdapterRegistry, + _discover_entry_point_adapters, + get_adapter_registry, + reset_adapter_registry, +) + +# --------------------------------------------------------------------------- +# Test adapters +# --------------------------------------------------------------------------- + + +class _SpecificAdapter(BaseAdapter): + """Matches only objects with a ``__specific__`` marker.""" + + @property + def name(self) -> str: + return "specific" + + def can_handle(self, agent: Any) -> bool: + return hasattr(agent, "__specific__") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _FallbackAdapter(BaseAdapter): + """Matches anything — must always sort last.""" + + is_fallback = True + + @property + def name(self) -> str: + return "fallback" + + def can_handle(self, agent: Any) -> bool: + return True + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _SecondaryAdapter(BaseAdapter): + """Another specific adapter, used to test ordering between two specifics.""" + + @property + def name(self) -> str: + return "secondary" + + def can_handle(self, agent: Any) -> bool: + return hasattr(agent, "__secondary__") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + return agent + + +class _BrokenAdapter(BaseAdapter): + """``can_handle`` raises — must be skipped, not crash resolution.""" + + @property + def name(self) -> str: + return "broken" + + def can_handle(self, agent: Any) -> bool: + raise RuntimeError("can_handle exploded") + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + raise RuntimeError("attach exploded") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_global_registry(): + """Each test starts with no singleton registry.""" + reset_adapter_registry() + yield + reset_adapter_registry() + + +# --------------------------------------------------------------------------- +# register / resolve / get_all / clear +# --------------------------------------------------------------------------- + + +def test_empty_registry_resolves_to_none(): + reg = AdapterRegistry() + assert reg.resolve(object()) is None + assert reg.get_all() == [] + + +def test_register_appends_in_order(): + reg = AdapterRegistry() + a, b = _SpecificAdapter(), _SecondaryAdapter() + reg.register(a) + reg.register(b) + assert reg.get_all() == [a, b] + + +def test_resolve_returns_first_matching_adapter(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_SecondaryAdapter()) + + agent = MagicMock() + agent.__secondary__ = True # only secondary should match + resolved = reg.resolve(agent) + assert resolved is not None + assert resolved.name == "secondary" + + +def test_resolve_skips_broken_can_handle_and_continues(): + """A can_handle() that raises must not break the whole resolve loop.""" + reg = AdapterRegistry() + reg.register(_BrokenAdapter()) + reg.register(_SpecificAdapter()) + + agent = MagicMock() + agent.__specific__ = True + resolved = reg.resolve(agent) + assert resolved is not None + assert resolved.name == "specific" + + +def test_register_position_inserts_at_index(): + reg = AdapterRegistry() + a, b, c = _SpecificAdapter(), _SecondaryAdapter(), _SpecificAdapter() + reg.register(a) + reg.register(b) + reg.register(c, position=0) # c jumps to head + assert reg.get_all()[0] is c + assert reg.get_all()[1:] == [a, b] + + +def test_fallback_stays_last_when_new_adapter_registered(): + """When the last entry has ``is_fallback`` set, new adapters insert before it.""" + reg = AdapterRegistry() + fallback = _FallbackAdapter() + reg.register(fallback) + reg.register(_SpecificAdapter()) # this should insert BEFORE fallback + + adapters = reg.get_all() + assert adapters[-1] is fallback + assert adapters[0].name == "specific" + + +def test_fallback_resolves_only_when_no_specific_matches(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_FallbackAdapter()) + + # Agent without the __specific__ marker → fallback wins. + resolved = reg.resolve(object()) + assert resolved is not None + assert resolved.name == "fallback" + + +def test_clear_removes_all_adapters(): + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + reg.register(_SecondaryAdapter()) + reg.clear() + assert reg.get_all() == [] + assert reg.resolve(object()) is None + + +def test_get_all_returns_copy_not_internal_list(): + """Callers must not be able to mutate the registry through get_all().""" + reg = AdapterRegistry() + reg.register(_SpecificAdapter()) + snapshot = reg.get_all() + snapshot.clear() + assert len(reg.get_all()) == 1 # unaffected + + +# --------------------------------------------------------------------------- +# Singleton + entry-point discovery +# --------------------------------------------------------------------------- + + +def test_get_adapter_registry_returns_singleton(): + reg1 = get_adapter_registry() + reg2 = get_adapter_registry() + assert reg1 is reg2 + + +def test_reset_adapter_registry_drops_singleton(): + first = get_adapter_registry() + reset_adapter_registry() + second = get_adapter_registry() + assert first is not second + + +def test_entry_point_discovery_invokes_registrars(monkeypatch): + """Each entry-point's zero-arg callable must be loaded and called.""" + called: list[str] = [] + + def make_registrar(name: str): + def _register() -> None: + called.append(name) + + return _register + + ep_a = MagicMock() + ep_a.name = "a" + ep_a.value = "pkg_a:register" + ep_a.load.return_value = make_registrar("a") + + ep_b = MagicMock() + ep_b.name = "b" + ep_b.value = "pkg_b:register" + ep_b.load.return_value = make_registrar("b") + + monkeypatch.setattr( + "uipath.core.adapters.registry.entry_points", + lambda group: [ep_a, ep_b] if group == "uipath.governance.adapters" else [], + raising=False, + ) + + # entry_points lives in importlib.metadata; the registry imports it + # lazily inside the function. Patch the import target directly. + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_a, ep_b] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert sorted(called) == ["a", "b"] + + +def test_entry_point_discovery_skips_broken_loader(monkeypatch): + """One broken entry-point must not stop the others from registering.""" + called: list[str] = [] + + ep_broken = MagicMock() + ep_broken.name = "broken" + ep_broken.value = "pkg_broken:register" + ep_broken.load.side_effect = ImportError("cannot import") + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg_ok:register" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_broken, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() # must not raise + assert called == ["ok"] + + +def test_entry_point_discovery_skips_non_callable(monkeypatch): + """An entry-point that resolves to a non-callable must be logged and skipped.""" + called: list[str] = [] + + ep_bad = MagicMock() + ep_bad.name = "bad" + ep_bad.value = "pkg_bad:NOT_A_FUNCTION" + ep_bad.load.return_value = "not callable" + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg_ok:register" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_bad, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert called == ["ok"] + + +def test_entry_point_discovery_swallows_registrar_exception(monkeypatch): + """A registrar that raises mid-call must not stop subsequent registrars.""" + called: list[str] = [] + + def _raises() -> None: + raise RuntimeError("registrar exploded") + + ep_raising = MagicMock() + ep_raising.name = "raises" + ep_raising.value = "pkg:register" + ep_raising.load.return_value = _raises + + ep_ok = MagicMock() + ep_ok.name = "ok" + ep_ok.value = "pkg:register2" + ep_ok.load.return_value = lambda: called.append("ok") + + import importlib.metadata as importlib_metadata + + monkeypatch.setattr( + importlib_metadata, + "entry_points", + lambda group=None: ( + [ep_raising, ep_ok] if group == "uipath.governance.adapters" else [] + ), + ) + + _discover_entry_point_adapters() + assert called == ["ok"] + + +def test_entry_point_discovery_swallows_entry_points_failure(monkeypatch): + """If ``entry_points()`` itself raises, discovery must log and return cleanly.""" + import importlib.metadata as importlib_metadata + + def _boom(group=None): + raise RuntimeError("entry_points API exploded") + + monkeypatch.setattr(importlib_metadata, "entry_points", _boom) + + # Must not raise — and must not register anything. + _discover_entry_point_adapters() + reg = get_adapter_registry() + assert reg.get_all() == [] + + +# --------------------------------------------------------------------------- +# Protocol conformance smoke tests +# --------------------------------------------------------------------------- + + +def test_baseadapter_is_abc(): + """BaseAdapter must be abstract — direct instantiation must fail.""" + with pytest.raises(TypeError): + BaseAdapter() # type: ignore[abstract] + + +def test_concrete_adapter_is_baseadapter(): + """A concrete subclass must be recognized as a BaseAdapter.""" + assert isinstance(_SpecificAdapter(), BaseAdapter) diff --git a/packages/uipath-core/tests/governance/__init__.py b/packages/uipath-core/tests/governance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath-core/tests/governance/test_config.py b/packages/uipath-core/tests/governance/test_config.py new file mode 100644 index 000000000..2b4152fcc --- /dev/null +++ b/packages/uipath-core/tests/governance/test_config.py @@ -0,0 +1,47 @@ +"""Tests for the governance feature-flag gate.""" + +from __future__ import annotations + +import pytest + +from uipath.core.feature_flags import FeatureFlags +from uipath.core.governance.config import ( + GOVERNANCE_FEATURE_FLAG, + is_governance_enabled, +) + + +@pytest.fixture(autouse=True) +def _reset_flags(): + """Each test starts and ends with a clean flags registry.""" + FeatureFlags.reset_flags() + yield + FeatureFlags.reset_flags() + + +def test_governance_flag_name_is_stable(): + """The flag name is a public contract shared with the runtime layer.""" + assert GOVERNANCE_FEATURE_FLAG == "EnablePythonGovernanceChecker" + + +def test_is_governance_enabled_defaults_to_true(): + """With nothing configured, the gate defaults to enabled.""" + assert is_governance_enabled() is True + + +def test_is_governance_enabled_respects_programmatic_disable(): + """Programmatic ``False`` flips the gate off.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: False}) + assert is_governance_enabled() is False + + +def test_is_governance_enabled_respects_programmatic_enable(): + """Programmatic ``True`` keeps the gate on.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + assert is_governance_enabled() is True + + +def test_is_governance_enabled_reads_env_var_fallback(monkeypatch): + """When nothing is configured programmatically, the env-var fallback wins.""" + monkeypatch.setenv(f"UIPATH_FEATURE_{GOVERNANCE_FEATURE_FLAG}", "false") + assert is_governance_enabled() is False diff --git a/packages/uipath-core/tests/governance/test_exceptions.py b/packages/uipath-core/tests/governance/test_exceptions.py new file mode 100644 index 000000000..257feb3d4 --- /dev/null +++ b/packages/uipath-core/tests/governance/test_exceptions.py @@ -0,0 +1,205 @@ +"""Tests for GovernanceBlockException constructors. + +The classmethod constructors (:meth:`from_violation`, +:meth:`from_audit_record`) form the documented contract that the +evaluator and adapter packages depend on — the evaluator only ever +builds a block via ``from_audit_record``. These tests pin the message +format and attribute population so a future refactor cannot silently +drop the rule id, name, or detail. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from uipath.core.governance.exceptions import ( + GovernanceBlockException, + GovernanceViolation, + Severity, +) +from uipath.core.governance.models import ( + Action, + AuditRecord, + LifecycleHook, + RuleEvaluation, +) + +# --------------------------------------------------------------------------- +# GovernanceViolation +# --------------------------------------------------------------------------- + + +def test_violation_defaults_to_high_severity(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + assert v.severity == Severity.HIGH + + +def test_violation_severity_can_be_overridden(): + v = GovernanceViolation( + rule_id="A-1", + rule_name="No PII", + detail="ssn leaked", + severity=Severity.CRITICAL, + ) + assert v.severity == Severity.CRITICAL + + +# --------------------------------------------------------------------------- +# GovernanceBlockException base constructor +# --------------------------------------------------------------------------- + + +def test_default_constructor_emits_prefixed_message(): + exc = GovernanceBlockException() + assert "[Governance Policy Violation]" in str(exc) + assert exc.violation is None + assert exc.audit_record is None + + +def test_default_constructor_carries_default_rule_metadata(): + """Constructing without context still gives the documented fallback IDs.""" + exc = GovernanceBlockException() + assert exc.rule_id == "POLICY" + assert exc.rule_name == "Governance Policy" + + +def test_explicit_message_is_used_verbatim(): + exc = GovernanceBlockException("custom message") + assert str(exc) == "custom message" + + +def test_error_code_constant_for_orchestrator_categorization(): + """error_code is a class-level constant the Orchestrator UI reads.""" + assert GovernanceBlockException.error_code == "GOVERNANCE_POLICY_VIOLATION" + exc = GovernanceBlockException() + assert exc.error_code == "GOVERNANCE_POLICY_VIOLATION" + + +# --------------------------------------------------------------------------- +# from_violation +# --------------------------------------------------------------------------- + + +def test_from_violation_populates_rule_metadata(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + exc = GovernanceBlockException.from_violation(v) + assert exc.rule_id == "A-1" + assert exc.rule_name == "No PII" + assert exc.violation is v + + +def test_from_violation_message_includes_rule_id_name_detail(): + v = GovernanceViolation(rule_id="A-1", rule_name="No PII", detail="ssn leaked") + msg = str(GovernanceBlockException.from_violation(v)) + assert "A-1" in msg + assert "No PII" in msg + assert "ssn leaked" in msg + assert "[Governance Policy Violation]" in msg + + +# --------------------------------------------------------------------------- +# from_audit_record +# --------------------------------------------------------------------------- + + +def _audit_record_with(*evaluations: RuleEvaluation) -> AuditRecord: + return AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name="agent", + runtime_id="run-1", + trace_id="trace-1", + hook=LifecycleHook.BEFORE_AGENT, + evaluations=list(evaluations), + final_action=Action.DENY, + ) + + +def test_from_audit_record_picks_first_matched_rule(): + """Even when later evaluations matched, the first matched wins the message.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="UNMATCHED", + rule_name="Did not fire", + matched=False, + detail="", + action=Action.ALLOW, + ), + RuleEvaluation( + rule_id="MATCHED-FIRST", + rule_name="First match", + matched=True, + detail="bad input", + action=Action.DENY, + ), + RuleEvaluation( + rule_id="MATCHED-SECOND", + rule_name="Second match", + matched=True, + detail="also bad", + action=Action.DENY, + ), + ) + + exc = GovernanceBlockException.from_audit_record(audit) + assert exc.rule_id == "MATCHED-FIRST" + assert exc.rule_name == "First match" + assert "bad input" in str(exc) + assert exc.audit_record is audit + + +def test_from_audit_record_falls_back_when_no_match(): + """When the audit has no matches, the exception is still constructible.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="UNMATCHED", + rule_name="Did not fire", + matched=False, + detail="", + action=Action.ALLOW, + ) + ) + + exc = GovernanceBlockException.from_audit_record(audit) + assert "Rules evaluated: 1" in str(exc) + assert exc.audit_record is audit + + +def test_from_audit_record_matched_detail_default_when_empty(): + """A matched evaluation with empty detail still produces a sensible message.""" + audit = _audit_record_with( + RuleEvaluation( + rule_id="A-1", + rule_name="No PII", + matched=True, + detail="", # empty + action=Action.DENY, + ) + ) + + msg = str(GovernanceBlockException.from_audit_record(audit)) + assert "A-1" in msg + assert "No PII" in msg + # Falls back to a non-empty detail string. + assert "Policy violation detected" in msg + + +# --------------------------------------------------------------------------- +# Exception identity — must be a real Exception so callers can catch broadly +# --------------------------------------------------------------------------- + + +def test_block_exception_is_exception_subclass(): + assert issubclass(GovernanceBlockException, Exception) + + +def test_block_exception_can_be_caught_via_base_exception(): + try: + raise GovernanceBlockException.from_violation( + GovernanceViolation(rule_id="A-1", rule_name="X", detail="d") + ) + except Exception as e: # noqa: BLE001 - intentional broad catch + assert isinstance(e, GovernanceBlockException) + else: + pytest.fail("Did not raise") diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 9aa9417f4..94c7eded6 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.17" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" },