diff --git a/docs/runtime-wrapper-extension.md b/docs/runtime-wrapper-extension.md new file mode 100644 index 0000000..d077a18 --- /dev/null +++ b/docs/runtime-wrapper-extension.md @@ -0,0 +1,85 @@ +# Governance Integration Point + +`uipath-runtime` wraps runtimes with governance via a single direct +function, `apply_governance_wrapper`, gated by the +`EnablePythonGovernanceChecker` feature flag. + +Governance contracts (feature-flag, exceptions, models) live in +`uipath.core.governance` (in `uipath-core`); the runtime-side wrapper +lives here in `uipath.runtime.governance`. Runtime has **no separate +`uipath-governance` dependency** — the contracts namespace is always +available because `uipath-core` is already a hard dep. When the flag +is off, `uipath.runtime.governance.wrapper` is **not imported** — its +transitive cost stays off the startup path. + +## How it works + +``` +UiPathRuntimeFactoryRegistry.get(...) + ↓ returns +UiPathWrappedRuntimeFactory.new_runtime(...) + ↓ calls +apply_governance_wrapper(runtime, context, runtime_id) + ↓ + if _is_governance_enabled(): + from uipath.runtime.governance.wrapper import governance_wrapper # lazy + return governance_wrapper(runtime, context, runtime_id) + else: + return runtime # unwrapped, no governance import +``` + +## Feature flag + +| Setting | Effect | +|---|---| +| `FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True})` (typically via gitops) | Governance is applied | +| `UIPATH_FEATURE_EnablePythonGovernanceChecker=true` env var | Governance is applied (fallback when no programmatic config) | +| Neither set | Governance **not** applied; `uipath.runtime.governance.wrapper` is **not imported** | + +Resolution and fallback semantics come from `uipath-core`'s +`FeatureFlags.is_flag_enabled(..., default=False)`. Programmatic +configuration beats env var. + +## API + +```python +from uipath.runtime import ( + GOVERNANCE_FEATURE_FLAG, # "EnablePythonGovernanceChecker" + apply_governance_wrapper, # the call-site +) +``` + +`apply_governance_wrapper(runtime, context, runtime_id)` is an +`async` function. It returns the original runtime untouched when the +flag is off or when the wrapper itself raises — governance failures +must never break agent execution. + +## Why deferred-import matters + +When the flag is off, `apply_governance_wrapper` returns before the +`from uipath.runtime.governance.wrapper import governance_wrapper` line +ever runs. That keeps governance's transitive imports — audit, +evaluator, OpenTelemetry, the policy index — entirely off the startup +hot path. + +## Testing + +Force the flag on/off per test via `FeatureFlags`: + +```python +from uipath.core.feature_flags import FeatureFlags +from uipath.runtime.wrapper import GOVERNANCE_FEATURE_FLAG + +# Force enable +FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + +# Force disable +FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: False}) + +# Reset (typically in a teardown fixture) +FeatureFlags.reset_flags() +``` + +Use `sys.modules` patching to stub `uipath.runtime.governance.wrapper` +when you need to assert against the wrapper invocation without +actually importing it — see `tests/test_wrapper.py` for the fixture. diff --git a/pyproject.toml b/pyproject.toml index 1762d36..1e418ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,13 @@ description = "Runtime abstractions and interfaces for building agents and autom readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.17, <0.6.0" + "uipath-core>=0.5.18, <0.6.0", + # Governance native-evaluator deps. Live here because the native + # evaluator implementation lives in uipath.runtime.governance.native; + # uipath-core only carries the small governance contracts. + "pyyaml>=6.0", + "vaderSentiment>=3.3.2", # sentiment_concern (A.3.3) + "chardet>=5.2.0", # encoding_concern (A.7.4) ] classifiers = [ "Intended Audience :: Developers", @@ -40,6 +46,7 @@ dev = [ "pytest-cov>=4.1.0", "pytest-mock>=3.11.1", "pre-commit>=4.1.0", + "types-PyYAML>=6.0", ] [tool.hatch.build.targets.wheel] @@ -83,6 +90,25 @@ no_implicit_reexport = true disallow_untyped_defs = false +# Third-party governance-evaluator libs have no type stubs / py.typed marker +[[tool.mypy.overrides]] +module = [ + "yaml", + "vaderSentiment.*", + "chardet", + "price_parser", + # uipath.platform.common is imported lazily from traces.py / audit + # sinks to read UiPathConfig context attributes. It's first-party but + # not a uipath-runtime dep, so its stubs aren't installable here. + "uipath.platform.*", + # Optional framework adapters; the absence of the framework simply + # means the adapter no-ops at import time. + "agents", + "langchain_core.*", + "langgraph.*", +] +ignore_missing_imports = true + [tool.pydantic-mypy] init_forbid_extra = true init_typed = true diff --git a/src/uipath/runtime/__init__.py b/src/uipath/runtime/__init__.py index 1eed011..e7af5c5 100644 --- a/src/uipath/runtime/__init__.py +++ b/src/uipath/runtime/__init__.py @@ -43,6 +43,10 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema from uipath.runtime.storage import UiPathRuntimeStorageProtocol +from uipath.runtime.wrapper import ( + GOVERNANCE_FEATURE_FLAG, + apply_governance_wrapper, +) __all__ = [ "UiPathExecuteOptions", @@ -73,4 +77,7 @@ "UiPathResumeTriggerName", "UiPathChatProtocol", "UiPathChatRuntime", + # Governance integration (direct, FF-gated, lazy import) + "GOVERNANCE_FEATURE_FLAG", + "apply_governance_wrapper", ] diff --git a/src/uipath/runtime/governance/audit/__init__.py b/src/uipath/runtime/governance/audit/__init__.py new file mode 100644 index 0000000..6f7ecc5 --- /dev/null +++ b/src/uipath/runtime/governance/audit/__init__.py @@ -0,0 +1,70 @@ +"""Audit sink framework for governance events. + +This module provides a pluggable audit system that supports multiple +output destinations (sinks) for governance events. Events are emitted +to all registered sinks, allowing flexible audit trail configuration. + +Usage:: + + from uipath.runtime.governance.audit import get_audit_manager, AuditEvent + + # Get the global audit manager + manager = get_audit_manager() + + # Emit an event (goes to all registered sinks) + manager.emit(AuditEvent( + event_type="rule_evaluation", + trace_id="abc-123", + agent_name="my-agent", + data={"rule_id": "ASI-01", "matched": True}, + )) + + # Register a custom sink + manager.register_sink(MyCustomSink()) + +Built-in sinks: + +- :class:`TracesAuditSink` – OpenTelemetry spans for Orchestrator Traces UI +- :class:`ConsoleAuditSink` – stderr output for debugging + +Sink registration: + +- The ``traces`` sink (OpenTelemetry spans → Orchestrator audit UI) is + **platform-mandated** and always registered. It cannot be disabled by + a developer-side env var — governance is platform-owned. +- The ``console`` sink is a developer aid for local debugging and is + opt-in via env var. + +Environment variables (developer-facing, console only): + +- ``UIPATH_AUDIT_VERBOSE`` – verbose console output. +- ``UIPATH_GOVERNANCE_CONSOLE_LOG`` – enable the console sink. +""" + +from .base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, + get_audit_manager, + reset_audit_manager, +) +from .console import ConsoleAuditSink +from .factory import create_sink +from .traces import TracesAuditSink + +__all__ = [ + # Core classes + "AuditEvent", + "AuditManager", + "AuditSink", + "EventType", + # Global manager + "get_audit_manager", + "reset_audit_manager", + # Factory + "create_sink", + # Built-in sinks + "ConsoleAuditSink", + "TracesAuditSink", +] diff --git a/src/uipath/runtime/governance/audit/base.py b/src/uipath/runtime/governance/audit/base.py new file mode 100644 index 0000000..86ff3b4 --- /dev/null +++ b/src/uipath/runtime/governance/audit/base.py @@ -0,0 +1,720 @@ +"""Base classes and models for the audit sink framework. + +This module provides the core abstractions for the governance audit system: +- AuditEvent: The data model for audit events +- EventType: Constants for common event types +- AuditSink: Abstract base class for sink implementations +- AuditManager: Central hub for routing events to sinks + +The AuditManager uses a background thread to process events asynchronously, +avoiding blocking the main agent execution path during audit trace HTTP calls. +""" + +from __future__ import annotations + +import atexit +import json +import logging +import os +import queue +import threading +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Audit Event Model +# ============================================================================= + + +@dataclass +class AuditEvent: + """Generic audit event that can be sent to any sink. + + Attributes: + event_type: Type of event (e.g., "rule_evaluation", "hook_summary") + timestamp: When the event occurred (auto-set if not provided) + trace_id: Trace identifier for correlation + agent_name: Name of the agent being governed + hook: Lifecycle hook where event occurred (optional) + data: Event-specific data dictionary + metadata: Additional metadata for filtering/routing + """ + + event_type: str + trace_id: str = "" + agent_name: str = "unknown" + hook: str = "" + data: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + result = asdict(self) + result["timestamp"] = self.timestamp.isoformat() + return result + + def to_json(self) -> str: + """Convert to JSON string.""" + return json.dumps(self.to_dict()) + + +class EventType: + """Constants for common event types.""" + + RULE_EVALUATION = "rule_evaluation" + HOOK_START = "hook_start" + HOOK_END = "hook_end" + SESSION_START = "session_start" + SESSION_END = "session_end" + POLICY_VIOLATION = "policy_violation" + POLICY_ALLOW = "policy_allow" + PACKS_LOADED = "packs_loaded" + + +# ============================================================================= +# Audit Sink Base Class +# ============================================================================= + + +class AuditSink(ABC): + """Abstract base class for audit output destinations. + + Subclass this to create custom audit sinks. Each sink receives + all audit events and decides how to handle them. + + Example: + class SlackAuditSink(AuditSink): + def __init__(self, webhook_url: str): + self.webhook_url = webhook_url + self._name = "slack" + + @property + def name(self) -> str: + return self._name + + def emit(self, event: AuditEvent) -> None: + if event.data.get("matched") and event.data.get("action") == "deny": + # Send to Slack on violations + requests.post(self.webhook_url, json=event.to_dict()) + + def flush(self) -> None: + pass + """ + + @property + @abstractmethod + def name(self) -> str: + """Unique name for this sink.""" + pass + + @abstractmethod + def emit(self, event: AuditEvent) -> None: + """Emit an audit event to this sink. + + Args: + event: The audit event to emit + + Note: + Implementations should handle errors gracefully and not + raise exceptions that would disrupt governance evaluation. + """ + pass + + def flush(self) -> None: + """Flush any buffered events. + + Override if sink buffers events before writing. + """ + return + + def close(self) -> None: + """Clean up resources. + + Override if sink holds resources that need cleanup. + """ + return + + def accepts(self, event: AuditEvent) -> bool: + """Check if this sink should receive the event. + + Override to filter events. Default accepts all events. + + Args: + event: The audit event to check + + Returns: + True if sink should receive event, False to skip + """ + return True + + +# ============================================================================= +# Audit Manager +# ============================================================================= + + +class AuditManager: + """Manages multiple audit sinks and routes events to them. + + The AuditManager is the central hub for audit events. It maintains + a list of registered sinks and broadcasts events to all of them. + + Thread Safety: + Events are queued and processed by a background thread, making + emit() non-blocking. This avoids blocking agent execution during + audit trace HTTP calls. + """ + + # Trip a sink after this many consecutive emit failures (circuit-breaker). + _SINK_FAILURE_THRESHOLD = 10 + # Bound the async queue so a stuck sink can't grow memory without limit. + # Matches the order of magnitude of a long-running agent's per-session + # audit volume; on overflow the oldest event is dropped (loss visible + # via stats.events_dropped). + _DEFAULT_QUEUE_MAXSIZE = 10_000 + + def __init__( + self, + async_mode: bool = True, + queue_maxsize: int = _DEFAULT_QUEUE_MAXSIZE, + ) -> None: + """Initialize the audit manager. + + Args: + async_mode: If True (default), events are processed in a background + thread. If False, events are processed synchronously. + queue_maxsize: Max queued events in async mode. On overflow the + oldest queued event is dropped to make room. + """ + self._sinks: list[AuditSink] = [] + # Single lock guards _sinks, _sink_failures, _tripped_sinks, + # _event_count, _error_count, _dropped_count — every counter and + # collection that the worker thread and emit-caller mutate. + self._sinks_lock = threading.Lock() + # Per-sink consecutive-failure counter, keyed by sink name. + self._sink_failures: dict[str, int] = {} + self._tripped_sinks: set[str] = set() + self._event_count = 0 + self._error_count = 0 + self._dropped_count = 0 + self._async_mode = async_mode + self._pid = os.getpid() + + # Background processing + self._queue: queue.Queue[AuditEvent | None] = queue.Queue(maxsize=queue_maxsize) + self._worker_thread: threading.Thread | None = None + self._shutdown = threading.Event() + + if self._async_mode: + self._start_worker() + + def _start_worker(self) -> None: + """Start the background worker thread.""" + if self._worker_thread is not None and self._worker_thread.is_alive(): + return + + self._shutdown.clear() + self._worker_thread = threading.Thread( + target=self._worker_loop, + name="governance-audit-worker", + daemon=True, + ) + self._worker_thread.start() + logger.debug("Background audit worker started") + + def _worker_loop(self) -> None: + """Background worker loop that processes queued events.""" + while not self._shutdown.is_set(): + try: + # Wait for event with timeout to allow checking shutdown + event = self._queue.get(timeout=0.5) + if event is None: + # Shutdown signal + break + self._emit_sync(event) + self._queue.task_done() + except queue.Empty: + continue + except Exception as e: + logger.warning("Audit worker error: %s", e) + + # Drain remaining events on shutdown + self._drain_queue() + + def _drain_queue(self) -> None: + """Process any remaining events in the queue.""" + while True: + try: + event = self._queue.get_nowait() + if event is not None: + self._emit_sync(event) + self._queue.task_done() + except queue.Empty: + break + except Exception as e: + logger.warning("Audit drain error: %s", e) + + def _emit_sync(self, event: AuditEvent) -> None: + """Emit event synchronously to all sinks (called from worker thread).""" + with self._sinks_lock: + sinks = list(self._sinks) + tripped = set(self._tripped_sinks) + for sink in sinks: + if sink.name in tripped: + continue + try: + if sink.accepts(event): + sink.emit(event) + # Success — reset failure counter for this sink. + with self._sinks_lock: + if self._sink_failures.get(sink.name): + self._sink_failures[sink.name] = 0 + except Exception as e: + with self._sinks_lock: + self._error_count += 1 + fails = self._sink_failures.get(sink.name, 0) + 1 + self._sink_failures[sink.name] = fails + tripped_now = fails >= self._SINK_FAILURE_THRESHOLD + if tripped_now: + self._tripped_sinks.add(sink.name) + if tripped_now: + logger.error( + "Audit sink '%s' tripped after %d consecutive failures; " + "will be skipped for the rest of this process. Last error: %s", + sink.name, + fails, + e, + ) + else: + logger.warning( + "Audit sink '%s' failed to emit event (%d/%d): %s", + sink.name, + fails, + self._SINK_FAILURE_THRESHOLD, + e, + ) + + def register_sink(self, sink: AuditSink) -> None: + """Register an audit sink. + + Args: + sink: The sink to register + + Note: + Duplicate sinks (same name) are ignored. + The circuit-breaker failure counter is cleared so a freshly + registered sink doesn't inherit a previous instance's tripped + state. ``unregister_sink`` already clears these, but the + defensive reset here guards against external manipulation + of the internal counters (tests, future callers). + """ + with self._sinks_lock: + if any(s.name == sink.name for s in self._sinks): + logger.debug("Sink '%s' already registered, skipping", sink.name) + return + self._sinks.append(sink) + self._sink_failures.pop(sink.name, None) + self._tripped_sinks.discard(sink.name) + logger.info("Registered audit sink: %s", sink.name) + + def unregister_sink(self, name: str) -> bool: + """Unregister an audit sink by name. + + Args: + name: Name of the sink to remove + + Returns: + True if sink was removed, False if not found + """ + sink_to_close: AuditSink | None = None + with self._sinks_lock: + for i, sink in enumerate(self._sinks): + if sink.name == name: + sink_to_close = sink + del self._sinks[i] + self._sink_failures.pop(name, None) + self._tripped_sinks.discard(name) + break + if sink_to_close is not None: + try: + sink_to_close.close() + except Exception as e: + logger.warning("Audit sink '%s' failed to close: %s", name, e) + logger.info("Unregistered audit sink: %s", name) + return True + return False + + def get_sink(self, name: str) -> AuditSink | None: + """Get a registered sink by name.""" + with self._sinks_lock: + for sink in self._sinks: + if sink.name == name: + return sink + return None + + def list_sinks(self) -> list[str]: + """Get names of all registered sinks.""" + with self._sinks_lock: + return [s.name for s in self._sinks] + + def emit(self, event: AuditEvent) -> None: + """Emit an audit event to all registered sinks. + + In async mode (default), this queues the event for background + processing and returns immediately. This avoids blocking the + main agent execution path during audit trace HTTP calls. + + On post-fork callers (worker process inheriting the parent's + manager), the queue is reinitialized and the worker thread + re-spawned before enqueue — otherwise events would silently + accumulate in a queue no one is draining. + + Args: + event: The audit event to emit + """ + self._ensure_alive_after_fork() + + with self._sinks_lock: + self._event_count += 1 + + if self._async_mode: + # Non-blocking enqueue with drop-oldest backpressure: if the + # worker is wedged on a slow sink, this keeps memory bounded + # rather than growing without limit. The dropped count is + # surfaced via ``stats``. + try: + self._queue.put_nowait(event) + except queue.Full: + try: + self._queue.get_nowait() + self._queue.task_done() + except queue.Empty: + pass + with self._sinks_lock: + self._dropped_count += 1 + try: + self._queue.put_nowait(event) + except queue.Full: + # Worker is so far behind that the queue refilled + # between get_nowait and put_nowait — give up on + # this event rather than block. + pass + else: + # Synchronous processing + self._emit_sync(event) + + def _ensure_alive_after_fork(self) -> None: + """Reset queue and respawn worker if we're in a forked child.""" + current_pid = os.getpid() + if current_pid == self._pid: + return + # Child process inherited a dead worker_thread reference and a + # queue the parent owned. Rebuild both so child events drain. + self._pid = current_pid + self._queue = queue.Queue(maxsize=self._queue.maxsize) + self._shutdown = threading.Event() + self._worker_thread = None + if self._async_mode: + self._start_worker() + + def emit_rule_evaluation( + self, + rule_id: str, + rule_name: str, + pack_name: str, + hook: str, + matched: bool, + action: str, + detail: str = "", + agent_name: str = "agent", + trace_id: str = "", + description: str = "", + ) -> None: + """Convenience method to emit a rule evaluation event.""" + self.emit( + AuditEvent( + event_type=EventType.RULE_EVALUATION, + trace_id=trace_id, + agent_name=agent_name, + hook=hook, + data={ + "rule_id": rule_id, + "rule_name": rule_name, + "pack_name": pack_name, + "matched": matched, + "action": action, + "detail": detail, + "description": description, + "status": "MATCHED" if matched else "PASS", + }, + ) + ) + + def emit_hook_summary( + self, + hook: str, + agent_name: str, + total_rules: int, + matched_rules: int, + final_action: str, + trace_id: str = "", + enforcement_mode: str = "audit", + ) -> None: + """Convenience method to emit a hook summary event.""" + self.emit( + AuditEvent( + event_type=EventType.HOOK_END, + trace_id=trace_id, + agent_name=agent_name, + hook=hook, + data={ + "total_rules": total_rules, + "matched_rules": matched_rules, + "final_action": final_action, + "enforcement_mode": enforcement_mode, + }, + ) + ) + + def emit_session_start( + self, + session_id: str, + agent_name: str, + packs: list[str], + enforcement_mode: str = "audit", + ) -> None: + """Convenience method to emit a session start event.""" + self.emit( + AuditEvent( + event_type=EventType.SESSION_START, + trace_id=session_id, + agent_name=agent_name, + data={ + "session_id": session_id, + "packs": packs, + "enforcement_mode": enforcement_mode, + }, + ) + ) + + def emit_session_end( + self, + session_id: str, + agent_name: str, + total_evaluations: int, + rules_matched: int, + rules_denied: int, + ) -> None: + """Convenience method to emit a session end event.""" + self.emit( + AuditEvent( + event_type=EventType.SESSION_END, + trace_id=session_id, + agent_name=agent_name, + data={ + "session_id": session_id, + "total_evaluations": total_evaluations, + "rules_matched": rules_matched, + "rules_denied": rules_denied, + }, + ) + ) + + def flush(self, timeout: float = 5.0) -> None: + """Flush all pending events and sinks. + + In async mode, polls the queue until it drains or ``timeout`` + seconds elapse, whichever comes first. ``queue.Queue.join`` has + no timeout argument — using it would block indefinitely on a + wedged sink, which defeats the bounded-shutdown contract that + :func:`_cleanup_audit_manager` relies on at process exit. + + Args: + timeout: Maximum seconds to wait for queue to drain (default 5.0) + """ + if self._async_mode: + import time + + deadline = time.monotonic() + max(0.0, timeout) + poll_interval = min(0.05, timeout) if timeout > 0 else 0.0 + while time.monotonic() < deadline: + try: + if self._queue.unfinished_tasks == 0: + break + except Exception: # noqa: BLE001 - queue introspection is best-effort + break + time.sleep(poll_interval) + else: + # Loop didn't break — drain timed out. Log so a wedged + # sink is surfaced rather than swallowed. + try: + pending = self._queue.unfinished_tasks + except Exception: # noqa: BLE001 + pending = -1 + if pending: + logger.warning( + "Audit queue did not drain within %.2fs " + "(unfinished tasks=%s); sink may be wedged", + timeout, pending, + ) + + with self._sinks_lock: + sinks = list(self._sinks) + for sink in sinks: + try: + sink.flush() + except Exception as e: + logger.warning("Audit sink '%s' failed to flush: %s", sink.name, e) + + def close(self) -> None: + """Close all sinks and release resources. + + Stops the background worker thread and drains any remaining events. + Shutdown is bounded: ``_shutdown`` is the primary signal the + worker polls; the sentinel ``None`` enqueue is best-effort. If + the queue is full and the worker is wedged on a slow sink, + ``put_nowait`` fails fast rather than hanging process exit. + """ + if self._async_mode and self._worker_thread is not None: + # Signal shutdown first so the worker's next queue.get() loop + # iteration exits even if we can't enqueue the sentinel. + self._shutdown.set() + try: + self._queue.put_nowait(None) # Wake up worker + except queue.Full: + # Queue saturated by a stuck sink; the worker will see + # _shutdown on its next loop iteration once whatever it's + # blocked on completes (or the 2s join timeout fires). + logger.debug( + "Audit queue full at shutdown; relying on _shutdown signal" + ) + + # Wait for worker to finish (with timeout) + if self._worker_thread.is_alive(): + self._worker_thread.join(timeout=2.0) + + logger.debug("Background audit worker stopped") + + with self._sinks_lock: + sinks = list(self._sinks) + self._sinks.clear() + self._sink_failures.clear() + self._tripped_sinks.clear() + for sink in sinks: + try: + sink.close() + except Exception as e: + logger.warning("Audit sink '%s' failed to close: %s", sink.name, e) + + @property + def stats(self) -> dict[str, Any]: + """Get audit statistics.""" + with self._sinks_lock: + sink_names = [s.name for s in self._sinks] + event_count = self._event_count + error_count = self._error_count + dropped_count = self._dropped_count + return { + "sinks": len(sink_names), + "sink_names": sink_names, + "events_emitted": event_count, + "events_queued": self._queue.qsize() if self._async_mode else 0, + "events_dropped": dropped_count, + "errors": error_count, + "async_mode": self._async_mode, + } + + +# ============================================================================= +# Global Audit Manager +# ============================================================================= + +_audit_manager: AuditManager | None = None +_atexit_registered = False + + +def _cleanup_audit_manager() -> None: + """Cleanup handler called at process exit.""" + global _audit_manager + if _audit_manager is not None: + try: + _audit_manager.flush(timeout=2.0) + _audit_manager.close() + except Exception: + pass + + +def get_audit_manager() -> AuditManager: + """Get or create the global audit manager. + + On first call, initializes sinks based on environment configuration. + The manager uses a background thread for async event processing. + + Returns: + The global AuditManager instance + """ + global _audit_manager, _atexit_registered + + if _audit_manager is None: + # Check if async mode should be disabled (for testing or debugging) + async_mode = os.getenv("UIPATH_AUDIT_SYNC", "false").lower() != "true" + _audit_manager = AuditManager(async_mode=async_mode) + _configure_default_sinks(_audit_manager) + + # Register cleanup handler + if not _atexit_registered: + atexit.register(_cleanup_audit_manager) + _atexit_registered = True + + return _audit_manager + + +def _configure_default_sinks(manager: AuditManager) -> None: + """Configure default sinks. + + The traces sink (OpenTelemetry spans to the Orchestrator audit UI) + is **platform-mandated** and is always registered — no developer-side + env var can disable it. This preserves the principle that governance + is platform-owned and developers cannot bypass the audit trail. + + The console sink is a developer aid for local debugging and is + opt-in via ``UIPATH_GOVERNANCE_CONSOLE_LOG=true``. + """ + from .factory import create_sink + + sink_names: list[str] = ["traces"] # mandatory — platform-controlled + + if os.getenv("UIPATH_GOVERNANCE_CONSOLE_LOG", "false").lower() == "true": + sink_names.append("console") + + for sink_name in sink_names: + sink = create_sink(sink_name) + if sink: + manager.register_sink(sink) + logger.info("Audit sink registered: %s", sink_name) + + logger.info("Governance audit sinks configured: %s", ", ".join(sink_names)) + + +def reset_audit_manager() -> None: + """Reset the global audit manager (for testing). + + Flushes pending events and stops the background worker before resetting. + """ + global _audit_manager + if _audit_manager: + try: + _audit_manager.flush(timeout=1.0) + except Exception: + pass + _audit_manager.close() + _audit_manager = None diff --git a/src/uipath/runtime/governance/audit/console.py b/src/uipath/runtime/governance/audit/console.py new file mode 100644 index 0000000..3d28a57 --- /dev/null +++ b/src/uipath/runtime/governance/audit/console.py @@ -0,0 +1,130 @@ +"""Console audit sink for human-readable output. + +This sink writes audit events to stderr in a human-readable format, +useful for debugging and development. +""" + +from __future__ import annotations + +import json +import sys + +from .base import AuditEvent, AuditSink, EventType + + +class ConsoleAuditSink(AuditSink): + """Audit sink that writes to console (stderr). + + Useful for debugging and development. Output is human-readable. + + Args: + verbose: If True, show all events. If False, only show matches. + """ + + def __init__(self, verbose: bool = False) -> None: + """Configure the sink's verbosity (verbose shows every event).""" + self._verbose = verbose + + @property + def name(self) -> str: + """Constant sink identifier.""" + return "console" + + def accepts(self, event: AuditEvent) -> bool: + """Filter to matched rules and lifecycle events unless verbose.""" + if self._verbose: + return True + # Only show matched rules and important events + if event.event_type == EventType.RULE_EVALUATION: + return event.data.get("matched", False) + return event.event_type in ( + EventType.SESSION_START, + EventType.SESSION_END, + EventType.HOOK_END, + EventType.POLICY_VIOLATION, + ) + + def emit(self, event: AuditEvent) -> None: + """Write the event to stderr using the appropriate formatter.""" + if event.event_type == EventType.RULE_EVALUATION: + self._emit_rule_evaluation(event) + elif event.event_type == EventType.HOOK_END: + self._emit_hook_summary(event) + elif event.event_type == EventType.SESSION_START: + self._emit_session_start(event) + elif event.event_type == EventType.SESSION_END: + self._emit_session_end(event) + else: + self._emit_generic(event) + + def _emit_rule_evaluation(self, event: AuditEvent) -> None: + data = event.data + matched = data.get("matched", False) + status = "MATCHED" if matched else "PASS" + rule_id = data.get("rule_id", "?") + rule_name = data.get("rule_name", "?") + action = data.get("action", "?").upper() + detail = data.get("detail", "") + + if matched: + print( + f"[GOVERNANCE] [{status}] {rule_id} | {rule_name} | " + f"action={action} | {detail}", + file=sys.stderr, + flush=True, + ) + elif self._verbose: + print( + f"[GOVERNANCE] [{status}] {rule_id} | {rule_name}", + file=sys.stderr, + flush=True, + ) + + def _emit_hook_summary(self, event: AuditEvent) -> None: + data = event.data + hook = event.hook + total = data.get("total_rules", 0) + matched = data.get("matched_rules", 0) + action = data.get("final_action", "allow").upper() + mode = data.get("enforcement_mode", "audit") + + if mode == "audit" and action == "DENY": + action = "AUDIT (would deny)" + + print( + f"[GOVERNANCE] HOOK: {hook} | rules={total} | matched={matched} | " + f"action={action}", + file=sys.stderr, + flush=True, + ) + + def _emit_session_start(self, event: AuditEvent) -> None: + data = event.data + packs = data.get("packs", []) + mode = data.get("enforcement_mode", "audit") + print( + f"[GOVERNANCE] Session started | agent={event.agent_name} | " + f"packs={','.join(packs)} | mode={mode}", + file=sys.stderr, + flush=True, + ) + + def _emit_session_end(self, event: AuditEvent) -> None: + data = event.data + total = data.get("total_evaluations", 0) + matched = data.get("rules_matched", 0) + denied = data.get("rules_denied", 0) + print( + f"[GOVERNANCE] Session ended | evaluations={total} | " + f"matched={matched} | denied={denied}", + file=sys.stderr, + flush=True, + ) + + def _emit_generic(self, event: AuditEvent) -> None: + print( + f"[GOVERNANCE] {event.event_type} | {event.agent_name} | " + f"{json.dumps(event.data)}", + file=sys.stderr, + flush=True, + ) diff --git a/src/uipath/runtime/governance/audit/factory.py b/src/uipath/runtime/governance/audit/factory.py new file mode 100644 index 0000000..1c8e248 --- /dev/null +++ b/src/uipath/runtime/governance/audit/factory.py @@ -0,0 +1,45 @@ +"""Factory function for creating audit sinks by name. + +This module provides the create_sink function used by the AuditManager +to instantiate sinks based on environment configuration. +""" + +from __future__ import annotations + +import logging +import os + +from .base import AuditSink + +logger = logging.getLogger(__name__) + + +def create_sink(name: str) -> AuditSink | None: + """Create an audit sink by name. + + Args: + name: Name of the sink to create (``traces`` or ``console``). + + Returns: + The created sink, or ``None`` if the name is unknown. + + Supported sinks: + - ``traces``: OpenTelemetry spans for Orchestrator Traces UI + - ``console``: human-readable stderr output + """ + name = name.lower() + + if name == "traces": + from .traces import TracesAuditSink + + return TracesAuditSink() + + elif name == "console": + from .console import ConsoleAuditSink + + verbose = os.getenv("UIPATH_AUDIT_VERBOSE", "false").lower() == "true" + return ConsoleAuditSink(verbose=verbose) + + else: + logger.warning("Unknown audit sink: %s", name) + return None diff --git a/src/uipath/runtime/governance/audit/traces.py b/src/uipath/runtime/governance/audit/traces.py new file mode 100644 index 0000000..d334ebc --- /dev/null +++ b/src/uipath/runtime/governance/audit/traces.py @@ -0,0 +1,265 @@ +"""OpenTelemetry traces audit sink for Orchestrator integration. + +This sink creates OpenTelemetry spans for governance events, which +appear in the UiPath Orchestrator Traces UI for observability. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from .base import AuditEvent, AuditSink, EventType + +logger = logging.getLogger(__name__) + +# Value for the ``type`` / ``span_type`` span attributes on every +# governance span. Matches ``SpanType.AGENT_RUN`` in uipath-agents-python +# — we use the string literal here (not a cross-package import) to keep +# uipath-runtime free of a uipath-agents dependency. If the agents-side +# registry adds new values, this constant is the single place to update. +SPAN_TYPE_AGENT_RUN = "agentRun" + +# Identifies this auditor on every governance span. Lets a downstream +# consumer distinguish traces emitted by the Python in-runtime governance +# checker from those produced by the governance-server (or any future +# language-specific governance SDK). Set as the ``source`` span +# attribute on every governance trace span. +GOVERNANCE_SOURCE = "governance-checker-python" + + +class TracesAuditSink(AuditSink): + """Audit sink that creates OpenTelemetry spans. + + Spans appear in UiPath Orchestrator Traces UI, providing structured + data for each governance evaluation. + """ + + def __init__(self) -> None: + """Initialize the sink with a deferred tracer and zero span count.""" + self._tracer: Any = None # Can be None, Tracer, or False + self._spans_created = 0 + + @property + def name(self) -> str: + """Constant sink identifier.""" + return "traces" + + def _get_tracer(self) -> Any: + """Get or create the OpenTelemetry tracer.""" + if self._tracer is None: + try: + from opentelemetry import trace + + self._tracer = trace.get_tracer("uipath.governance") + logger.info("OpenTelemetry tracer initialized for governance traces") + except ImportError: + # OpenTelemetry is supplied transitively by uipath-core; an + # ImportError here means the host install is broken or + # governance is running outside the UiPath SDK environment. + logger.warning( + "OpenTelemetry not available - governance traces disabled. " + "OTel is normally provided by uipath-core; reinstall the SDK." + ) + self._tracer = False + return self._tracer if self._tracer else None + + def _get_uipath_trace_id(self) -> str | None: + """Get trace ID from UiPath config.""" + try: + from uipath.platform.common import UiPathConfig + + return UiPathConfig.trace_id + except (ImportError, AttributeError): + return None + + def _get_uipath_context(self) -> dict[str, str]: + """Get UiPath context attributes.""" + context = {} + try: + from uipath.platform.common import UiPathConfig + + if UiPathConfig.organization_id: + context["uipath.organization_id"] = UiPathConfig.organization_id + if UiPathConfig.tenant_id: + context["uipath.tenant_id"] = UiPathConfig.tenant_id + if UiPathConfig.folder_key: + context["uipath.folder_key"] = UiPathConfig.folder_key + if UiPathConfig.job_key: + context["uipath.job_key"] = UiPathConfig.job_key + except (ImportError, AttributeError): + pass + return context + + def emit(self, event: AuditEvent) -> None: + """Create a span for RULE_EVALUATION or HOOK_END events; drop others.""" + if event.event_type == EventType.RULE_EVALUATION: + self._emit_rule_span(event) + elif event.event_type == EventType.HOOK_END: + self._emit_hook_span(event) + + def _emit_hook_span(self, event: AuditEvent) -> None: + """Create a span for a hook summary (always emitted for each governance check).""" + tracer = self._get_tracer() + if tracer is None: + return + + try: + from opentelemetry import context + + data = event.data + hook = event.hook or "unknown" + span_name = f"governance.{hook.lower()}" + + # Use the current OTel context if one is active; otherwise start a + # root span. A previous version fabricated a random parent + # span_id when only a trace_id was known, which produced orphan + # parents the backend could never resolve. The governance span + # now correctly appears as a child of whichever span is current + # (e.g. the runtime's root span) or as a fresh root. + ctx = context.get_current() + uipath_trace_id = event.trace_id or self._get_uipath_trace_id() + + with tracer.start_as_current_span(span_name, context=ctx) as span: + # Required for Orchestrator Traces + span.set_attribute("type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) + # Identifies which agent emitted this audit trace. Lets + # downstream consumers (Orchestrator Traces UI, audit + # dashboards) filter governance spans by producer when + # multiple SDKs / governance backends co-exist. + span.set_attribute("source", GOVERNANCE_SOURCE) + span.set_attribute("uipath.custom_instrumentation", True) + if uipath_trace_id: + span.set_attribute("uipath.trace_id", uipath_trace_id) + + # UiPath context + for key, value in self._get_uipath_context().items(): + span.set_attribute(key, value) + + # Hook summary attributes + span.set_attribute("governance.hook", hook) + span.set_attribute("governance.total_rules", data.get("total_rules", 0)) + span.set_attribute( + "governance.matched_rules", data.get("matched_rules", 0) + ) + span.set_attribute( + "governance.final_action", data.get("final_action", "allow") + ) + span.set_attribute( + "governance.enforcement_mode", data.get("enforcement_mode", "audit") + ) + span.set_attribute("governance.agent_name", event.agent_name) + + # Hook spans are summary containers — they're left at + # Status.UNSET regardless of final_action. Severity is + # carried by the per-rule spans (see _emit_rule_span); + # marking the hook span as ERROR would falsely paint + # the entire lifecycle phase as failed when only a + # specific rule fired underneath. + + self._spans_created += 1 + + except Exception as e: + logger.warning("Failed to create governance hook span: %s", e) + + def _emit_rule_span(self, event: AuditEvent) -> None: + """Create a span for a rule evaluation.""" + tracer = self._get_tracer() + if tracer is None: + return + + try: + from opentelemetry import context + + data = event.data + rule_id = data.get("rule_id", "unknown") + span_name = f"governance.rule.{rule_id}" + + # See note in _emit_hook_span: rely on the current OTel context + # rather than fabricating a remote-parent span_id. + ctx = context.get_current() + uipath_trace_id = event.trace_id or self._get_uipath_trace_id() + + with tracer.start_as_current_span(span_name, context=ctx) as span: + # Required for Orchestrator Traces + span.set_attribute("type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) + # Identifies which agent emitted this audit trace. Lets + # downstream consumers (Orchestrator Traces UI, audit + # dashboards) filter governance spans by producer when + # multiple SDKs / governance backends co-exist. + span.set_attribute("source", GOVERNANCE_SOURCE) + span.set_attribute("uipath.custom_instrumentation", True) + if uipath_trace_id: + span.set_attribute("uipath.trace_id", uipath_trace_id) + + # UiPath context + for key, value in self._get_uipath_context().items(): + span.set_attribute(key, value) + + # Governance attributes + span.set_attribute("governance.rule_id", rule_id) + span.set_attribute("governance.rule_name", data.get("rule_name", "")) + span.set_attribute("governance.pack_name", data.get("pack_name", "")) + span.set_attribute("governance.hook", event.hook) + span.set_attribute("governance.matched", data.get("matched", False)) + span.set_attribute("governance.action", data.get("action", "allow")) + span.set_attribute("governance.status", data.get("status", "PASS")) + span.set_attribute("governance.agent_name", event.agent_name) + + detail = data.get("detail", "") + if detail: + span.set_attribute("governance.detail", detail[:500]) + + # Severity classification for matched non-allow rules. + # OTel ``StatusCode`` only has OK / ERROR / UNSET — no + # WARNING — so we use a free-form ``severity`` attribute + # to differentiate violations that actually blocked the + # agent from those that were merely audited. + # + # - Audit mode (and any audit-action rule even in + # enforce mode): runtime did NOT block the agent → + # severity=WARNING, Status stays UNSET. The agent's + # span shouldn't be marked failed just because an + # advisory rule fired. + # - Enforce mode + deny / escalate: runtime actually + # blocked → severity=ERROR + Status.ERROR. The agent + # span genuinely failed. + action_str = data.get("action", "allow").lower() + if data.get("matched") and action_str != "allow": + from uipath.runtime.governance.config import ( + EnforcementMode, + get_enforcement_mode, + ) + + mode = get_enforcement_mode() + will_block = ( + mode == EnforcementMode.ENFORCE + and action_str in {"deny", "escalate"} + ) + severity = "ERROR" if will_block else "WARNING" + span.set_attribute("severity", severity) + span.set_attribute("governance.severity", severity) + if will_block: + try: + from opentelemetry.trace import StatusCode + + span.set_status( + StatusCode.ERROR, + f"Policy violation: " + f"{data.get('rule_name', rule_id)} " + f"(action={action_str})", + ) + except ImportError: + pass + + self._spans_created += 1 + + except Exception as e: + logger.warning("Failed to create governance span: %s", e) + + @property + def spans_created(self) -> int: + """Number of spans created.""" + return self._spans_created diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py new file mode 100644 index 0000000..078d800 --- /dev/null +++ b/src/uipath/runtime/governance/config.py @@ -0,0 +1,75 @@ +"""Runtime-level governance enforcement-mode state. + +The feature-flag gate (``is_governance_enabled``) lives in +:mod:`uipath.core.governance.config` because it is process-level and +must be resolvable by callers that do not depend on +``uipath-runtime``. The enforcement mode is *per-policy* — set by the +backend on each policy fetch via the ``/runtime/policy`` endpoint — +and therefore lives here in the runtime package alongside the policy +loader that applies it. +""" + +from __future__ import annotations + +import logging +import os +from enum import Enum + +logger = logging.getLogger(__name__) + +ENV_ENFORCEMENT_MODE = "UIPATH_GOVERNANCE_MODE" + + +class EnforcementMode(str, Enum): + """Governance enforcement modes.""" + + AUDIT = "audit" # Evaluate and log; never block. + ENFORCE = "enforce" # Block on DENY rules. + DISABLED = "disabled" # Skip evaluation entirely. + + +_enforcement_mode: EnforcementMode | None = None + + +def get_enforcement_mode() -> EnforcementMode: + """Return the current enforcement mode. + + The mode is cached after first read. Resolution order: + + 1. A value previously set via :func:`set_enforcement_mode` (the + policy loader calls this with the backend-supplied mode on every + successful policy fetch — that's the canonical source). + 2. ``UIPATH_GOVERNANCE_MODE`` env var (developer override). + 3. Default :attr:`EnforcementMode.DISABLED` — skip evaluation + entirely until the server explicitly opts the tenant in. This + keeps empty-policy / failed-fetch / pre-fetch scenarios free of + per-call audit overhead; a tenant with policies wins the cache + on the first ``set_enforcement_mode`` call from the loader. + """ + global _enforcement_mode + if _enforcement_mode is not None: + return _enforcement_mode + + mode_str = os.getenv(ENV_ENFORCEMENT_MODE, "disabled").lower() + try: + _enforcement_mode = EnforcementMode(mode_str) + except ValueError: + _enforcement_mode = EnforcementMode.DISABLED + + return _enforcement_mode + + +def set_enforcement_mode(mode: EnforcementMode) -> None: + """Set the enforcement mode programmatically. + + The policy loader calls this with the backend-supplied mode on each + fetch so the evaluator picks up the platform-controlled value. + """ + global _enforcement_mode + _enforcement_mode = mode + + +def reset_enforcement_mode() -> None: + """Clear cached enforcement mode (intended for tests).""" + global _enforcement_mode + _enforcement_mode = None diff --git a/src/uipath/runtime/governance/delegation_guard.py b/src/uipath/runtime/governance/delegation_guard.py new file mode 100644 index 0000000..f872f62 --- /dev/null +++ b/src/uipath/runtime/governance/delegation_guard.py @@ -0,0 +1,248 @@ +"""Delegation depth guard. + +Patches an agent's ``invoke`` method to track recursion depth and raise +a ``GovernanceBlockException`` when the configured maximum is exceeded. +This prevents runaway sub-agent chains. +""" + +from __future__ import annotations + +import asyncio +import functools +import logging +import os +from contextvars import ContextVar, Token +from typing import Any + +from uipath.core.governance.exceptions import ( + GovernanceBlockException, + GovernanceViolation, +) + +logger = logging.getLogger(__name__) + +_DEFAULT_MAX_DELEGATION_DEPTH = 25 +_ENV_MAX_DELEGATION_DEPTH = "UIPATH_GOVERNANCE_MAX_DELEGATION_DEPTH" + +# Single module-level ContextVar holding per-agent delegation depths +# keyed by ``id(agent)``. Each install / uninstall pair shares this one +# ContextVar instead of allocating a new one per agent — the interpreter +# interns ContextVars and never GCs them, so per-agent allocation was an +# unbounded leak in long-running hosts (every `install_delegation_guard` +# call permanently grew the interpreter's ContextVar registry). +# +# Per-context isolation (asyncio task / thread) still works the standard +# ContextVar way: each context sees its own copy of the depths dict, and +# nested invokes use ``set`` / ``reset`` for LIFO depth tracking. The +# dict itself is copied on every increment (copy-on-write) so concurrent +# contexts don't share state through a mutable mapping. +_DELEGATION_DEPTHS: ContextVar[dict[int, int]] = ContextVar( + "_uipath_delegation_depths" +) + + +def _current_depth(agent_key: int) -> int: + """Return the current depth for ``agent_key`` in this context.""" + try: + return _DELEGATION_DEPTHS.get().get(agent_key, 0) + except LookupError: + return 0 + + +def _enter_depth_if_under( + agent_key: int, max_depth: int +) -> tuple[int, Token[dict[int, int]] | None]: + """Attempt to increment depth for ``agent_key``. + + Returns ``(new_depth, token)`` where ``token`` is ``None`` if the + new depth would exceed ``max_depth`` — caller raises and does not + need to clean up. On success, caller must reset via ``token``. + """ + try: + depths = _DELEGATION_DEPTHS.get() + except LookupError: + depths = {} + new_depth = depths.get(agent_key, 0) + 1 + if new_depth > max_depth: + return new_depth, None + new_depths = dict(depths) + new_depths[agent_key] = new_depth + token = _DELEGATION_DEPTHS.set(new_depths) + return new_depth, token + + +def _exit_depth(token: Token[dict[int, int]]) -> None: + """Undo a successful :func:`_enter_depth_if_under` call. + + Tolerates cross-context resets (token created in a different + context — happens when a child task awaits an agent invoke) by + accepting the leak rather than crashing the agent on dispose. + """ + try: + _DELEGATION_DEPTHS.reset(token) + except (ValueError, LookupError): + logger.debug("Delegation depth reset from foreign context") + + +def _resolve_max_depth() -> int: + """Read max-depth from env at call time, falling back to default on parse error.""" + raw = os.getenv(_ENV_MAX_DELEGATION_DEPTH) + if raw is None: + return _DEFAULT_MAX_DELEGATION_DEPTH + try: + return int(raw) + except ValueError: + logger.warning( + "Invalid %s=%r; using default %d", + _ENV_MAX_DELEGATION_DEPTH, + raw, + _DEFAULT_MAX_DELEGATION_DEPTH, + ) + return _DEFAULT_MAX_DELEGATION_DEPTH + + +def _build_violation(current: int, resolved_max: int) -> GovernanceBlockException: + """Build the depth-exceeded exception (shared by sync and async guards).""" + return GovernanceBlockException.from_violation( + GovernanceViolation( + rule_id="ASI-02", + rule_name="Excessive Agency", + detail=f"Delegation depth {current} exceeds max {resolved_max}", + ) + ) + + +def _wrap_invoke(original: Any, agent_key: int, resolved_max: int) -> Any: + """Return a depth-guarded wrapper matching the sync/async shape of ``original``. + + Coroutine functions get an ``async def`` wrapper so the returned object + is itself an awaitable — wrapping with a sync function would return an + un-awaited coroutine and silently bypass the guard entirely. + + Depth lives in the module-level :data:`_DELEGATION_DEPTHS` ContextVar + keyed by ``agent_key`` (``id(agent)``), so every guarded agent shares + the same ContextVar instance and the interpreter's ContextVar + registry doesn't grow with each install. + """ + if asyncio.iscoroutinefunction(original): + + @functools.wraps(original) + async def _guarded_async(input_data: Any, **kwargs: Any) -> Any: + current, token = _enter_depth_if_under(agent_key, resolved_max) + if token is None: + raise _build_violation(current, resolved_max) + try: + return await original(input_data, **kwargs) + finally: + _exit_depth(token) + + return _guarded_async + + @functools.wraps(original) + def _guarded_sync(input_data: Any, **kwargs: Any) -> Any: + current, token = _enter_depth_if_under(agent_key, resolved_max) + if token is None: + raise _build_violation(current, resolved_max) + try: + return original(input_data, **kwargs) + finally: + _exit_depth(token) + + return _guarded_sync + + +# Method names we guard on the agent. ``ainvoke`` is required because +# LangChain / LangGraph / LlamaIndex agents expose it as the primary +# async entrypoint; wrapping only ``invoke`` would let async callers +# bypass the depth check entirely. A single ContextVar is shared across +# both so an async call that internally falls through to sync ``invoke`` +# still increments the same counter. +_GUARDED_METHODS = ("invoke", "ainvoke") + + +def install_delegation_guard(agent: Any, max_depth: int | None = None) -> None: + """Patch the agent's invoke methods to enforce a maximum delegation depth. + + Patches both ``invoke`` and ``ainvoke`` when present; each wrapper + matches the sync/async shape of the original so awaitables stay + awaitable. No-op when neither attribute exists or the agent has + already been guarded. + + Per-call-chain depth is tracked in a single :class:`contextvars.ContextVar` + shared across both methods so an ``ainvoke`` that internally calls + ``invoke`` still increments the same counter. Concurrent invokes on + the same agent (across threads or asyncio tasks) keep separate + counters because ContextVar values are per-context. + + Originals are stashed on the agent under + ``_uipath_original_`` so :func:`uninstall_delegation_guard` + can restore them on dispose. + """ + if max_depth is None: + max_depth = _resolve_max_depth() + if getattr(agent, "_delegation_wrapped", False): + return + + originals = { + name: getattr(agent, name, None) + for name in _GUARDED_METHODS + if callable(getattr(agent, name, None)) + } + if not originals: + return + + agent_key = id(agent) + resolved_max = max_depth + + for name, original in originals.items(): + try: + setattr(agent, name, _wrap_invoke(original, agent_key, resolved_max)) + setattr(agent, f"_uipath_original_{name}", original) + except (AttributeError, TypeError) as exc: + # Some agent objects expose `invoke` via __getattr__ or via a + # slot/descriptor that can't be re-assigned. Skip those — + # better to guard partial coverage than to crash the runtime. + logger.debug("Could not patch %s on agent: %s", name, exc) + agent._delegation_wrapped = True + logger.debug( + "Delegation guard installed (max=%d, methods=%s)", + resolved_max, + list(originals), + ) + + +def uninstall_delegation_guard(agent: Any) -> None: + """Restore the agent's invoke methods if a delegation guard was installed. + + Safe to call on agents that were never guarded. Also clears the + agent's entry from the current context's depth map — ``id(agent)`` + is reused by Python after GC, so a stale entry could mis-attribute + a future agent's count to this one. + """ + if not getattr(agent, "_delegation_wrapped", False): + return + for name in _GUARDED_METHODS: + attr = f"_uipath_original_{name}" + original = getattr(agent, attr, None) + if original is not None: + try: + setattr(agent, name, original) + except Exception as exc: # noqa: BLE001 - dispose path; never raise + logger.debug("Could not restore original %s: %s", name, exc) + try: + delattr(agent, attr) + except AttributeError: + pass + agent._delegation_wrapped = False + # Drop the agent's depth entry in the current context. Best-effort + # — if dispose runs from a different context than where the depth + # was set, the foreign context still owns its own copy and will + # discard it when it ends. + agent_key = id(agent) + try: + depths = _DELEGATION_DEPTHS.get() + except LookupError: + return + if agent_key in depths: + new_depths = {k: v for k, v in depths.items() if k != agent_key} + _DELEGATION_DEPTHS.set(new_depths) diff --git a/src/uipath/runtime/governance/native/__init__.py b/src/uipath/runtime/governance/native/__init__.py new file mode 100644 index 0000000..c7671b6 --- /dev/null +++ b/src/uipath/runtime/governance/native/__init__.py @@ -0,0 +1,51 @@ +"""Native UiPath governance policy evaluator. + +YAML-defined rules evaluated in-process at each agent lifecycle hook. +Reads policies from the UiPath governance backend +(``GET /api/v1/policy``) at startup and runs the deterministic +detectors backing ISO 42001 controls. + +This subpackage owns: + +- :class:`GovernanceEvaluator` – the evaluator implementation. +- The native policy model: :class:`Rule`, :class:`Check`, + :class:`Condition`, :class:`PolicyIndex`. +- Policy fetch + YAML compilation plumbing. + +Shared output types (``Action``, ``AuditRecord``, …) live in +:mod:`uipath.core.governance`. +""" + +from .evaluator import GovernanceEvaluator +from .loader import ( + get_policy_index, + load_policy_index, + prefetch_policy_index, + reset_policy_index, +) +from .models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, + Severity, +) + +__all__ = [ + "GovernanceEvaluator", + # Loader + "get_policy_index", + "load_policy_index", + "prefetch_policy_index", + "reset_policy_index", + # Native policy model + "Check", + "CheckContext", + "Condition", + "PolicyIndex", + "PolicyPack", + "Rule", + "Severity", +] diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py new file mode 100644 index 0000000..2deb463 --- /dev/null +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -0,0 +1,459 @@ +"""Runtime YAML → PolicyIndex parser. + +Mirrors the shape produced by ``packs/compile_packs.py`` but builds the +PolicyIndex directly from parsed YAML data rather than generating Python +source. Used by :mod:`uipath.runtime.governance.native.loader` when policies are fetched +from the governance backend at startup. + +Accepts either a single YAML document (one pack) or a multi-document +stream (``---``-separated packs). Unknown check types and malformed +rules are skipped with a warning — partial packs are preferred over +failing the whole load. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import yaml +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.native.models import ( + Check, + Condition, + PolicyIndex, + PolicyPack, + Rule, + Severity, +) + +logger = logging.getLogger(__name__) + + +_HOOK_MAP: dict[str, LifecycleHook] = { + "before_agent": LifecycleHook.BEFORE_AGENT, + "after_agent": LifecycleHook.AFTER_AGENT, + "before_model": LifecycleHook.BEFORE_MODEL, + "after_model": LifecycleHook.AFTER_MODEL, + "wrap_tool_call": LifecycleHook.TOOL_CALL, + "tool_call": LifecycleHook.TOOL_CALL, + "after_tool": LifecycleHook.AFTER_TOOL, +} + +_ACTION_MAP: dict[str, Action] = { + "block": Action.DENY, + "deny": Action.DENY, + "log": Action.AUDIT, + "audit": Action.AUDIT, + "allow": Action.ALLOW, + "require_approval": Action.ESCALATE, + "escalate": Action.ESCALATE, +} + +_SEVERITY_MAP: dict[str, Severity] = { + "low": Severity.LOW, + "medium": Severity.MEDIUM, + "high": Severity.HIGH, + "critical": Severity.CRITICAL, +} + + +def build_policy_index_from_yaml(yaml_text: str) -> PolicyIndex: + """Parse YAML policy packs into a PolicyIndex. + + Args: + yaml_text: YAML body, either a single document or ``---``-separated + multi-document stream. Each document is one pack. + + Returns: + PolicyIndex with all successfully parsed packs added. Empty when + the input has no parseable packs. + + Raises: + yaml.YAMLError: If the YAML itself is malformed. Callers are + expected to fall back to the compiled index on this error. + """ + index = PolicyIndex() + documents = list(yaml.safe_load_all(yaml_text)) + + for doc in documents: + if not isinstance(doc, dict): + continue + pack = _build_pack(doc) + if pack is not None and pack.rules: + index.add_pack(pack) + + logger.debug( + "Built PolicyIndex from YAML: packs=%s, rules=%d", + index.pack_names, + index.total_rules, + ) + return index + + +def _build_pack(data: dict[str, Any]) -> PolicyPack | None: + """Build a PolicyPack from one YAML document.""" + name = data.get("standard") or data.get("name") + if not name: + logger.warning("Skipping pack: missing 'standard'/'name' field") + return None + + default_action_str = data.get("default_action", "block") + default_action = _ACTION_MAP.get(default_action_str, Action.DENY) + + rules: list[Rule] = [] + for i, rule_data in enumerate(data.get("rules", []) or []): + if not isinstance(rule_data, dict): + continue + rule = _build_rule(rule_data, default_action, i) + if rule is not None: + rules.append(rule) + + return PolicyPack( + name=str(name), + version=str(data.get("version", "1.0.0")), + description=str(data.get("description", "")), + rules=rules, + ) + + +def _build_rule( + data: dict[str, Any], default_action: Action, index: int +) -> Rule | None: + """Build a single Rule from a YAML rule entry.""" + hook = _HOOK_MAP.get(data.get("hook", "before_model")) + if hook is None: + logger.warning( + "Skipping rule %s: unknown hook %r", data.get("id"), data.get("hook") + ) + return None + + action_str = data.get("action") + action = ( + _ACTION_MAP.get(action_str, default_action) if action_str else default_action + ) + + default_sev = "high" if action == Action.DENY else "medium" + severity = _SEVERITY_MAP.get(data.get("severity", default_sev), Severity.HIGH) + + checks = _build_checks( + data.get("checks", []) or [], + action, + mapped_to_uipath=bool(data.get("mapped_to_uipath", False)), + policy_enabled=bool(data.get("policy_enabled", True)), + ) + + # If checks were declared but none could be parsed (e.g. all unknown + # types), skip the rule. A rule with zero checks "always matches" in + # the evaluator, so keeping it would make it fire on every request. + declared = data.get("checks", []) or [] + if declared and not checks: + logger.warning( + "Skipping rule %s: none of its %d declared check(s) could be parsed", + data.get("id"), + len(declared), + ) + return None + + return Rule( + rule_id=str(data.get("id", f"RULE-{index}")), + name=str(data.get("name", data.get("id", f"RULE-{index}"))), + clause=str(data.get("clause", data.get("owasp_ref", ""))), + hook=hook, + action=action, + severity=severity, + checks=checks, + enabled=bool(data.get("enabled", True)), + description=str(data.get("description", "")), + ) + + +def _build_checks( + checks_data: list[dict[str, Any]], + default_action: Action, + *, + mapped_to_uipath: bool = False, + policy_enabled: bool = True, +) -> list[Check]: + """Build the checks list for a rule. + + ``mapped_to_uipath`` / ``policy_enabled`` are rule-level flags read + by ``guardrail_fallback`` checks so the per-check condition can + decide whether to fire the compensating governance call. + """ + checks: list[Check] = [] + for check_data in checks_data: + if not isinstance(check_data, dict): + continue + check = _build_check( + check_data, + default_action, + mapped_to_uipath=mapped_to_uipath, + policy_enabled=policy_enabled, + ) + if check is not None: + checks.append(check) + return checks + + +def _build_check( + data: dict[str, Any], + default_action: Action, + *, + mapped_to_uipath: bool = False, + policy_enabled: bool = True, +) -> Check | None: + """Build one Check from a YAML check entry. + + Supports the same check types as ``compile_packs.py``: explicit + conditions, regex, budget, tool_allowlist, parameter_validation, + rate_limit, field_regex, sentiment_concern, data_quality_score, + incident_taxonomy, commitment_extractor, plus ``guardrail_fallback`` + (reads the rule-level ``mapped_to_uipath`` / ``policy_enabled`` flags + threaded in from ``_build_rule``). + """ + conditions: list[Condition] = [] + message = "" + + raw_conditions = data.get("conditions") + has_explicit_conditions = ( + isinstance(raw_conditions, list) + and raw_conditions + and isinstance(raw_conditions[0], dict) + and "operator" in raw_conditions[0] + ) + + check_type = data.get("type", "regex") + + if has_explicit_conditions: + assert isinstance(raw_conditions, list) # narrowed by has_explicit_conditions + conditions.extend(_make_conditions(raw_conditions)) + message = str(data.get("message", "")) + + elif check_type == "regex": + patterns = data.get("patterns", []) or [] + scope = data.get("scope", ["human", "ai"]) + field = _field_for_scope(scope) + for pattern in patterns: + conditions.append(Condition(operator="regex", field=field, value=pattern)) + message = f"Pattern matched in {scope}" + + elif check_type == "budget": + if "max_tool_calls_per_session" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.tool_calls", + value=data["max_tool_calls_per_session"], + ) + ) + if "max_tool_calls_per_minute" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.tool_calls_per_minute", + value=data["max_tool_calls_per_minute"], + ) + ) + if "max_consecutive_tool_calls" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.consecutive_tool_calls", + value=data["max_consecutive_tool_calls"], + ) + ) + message = "Tool budget exceeded" + + elif check_type == "tool_allowlist": + blocked_tools = data.get("blocked_tools", []) or [] + if blocked_tools: + conditions.append( + Condition(operator="in_list", field="tool_name", value=blocked_tools) + ) + message = "Tool not allowed" + + elif check_type == "parameter_validation": + for pattern in data.get("additional_patterns", []) or []: + conditions.append( + Condition(operator="regex", field="tool_args", value=pattern) + ) + message = "Suspicious pattern in tool parameters" + + elif check_type == "rate_limit": + if "max_llm_calls_per_session" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.llm_calls", + value=data["max_llm_calls_per_session"], + ) + ) + if "max_llm_calls_per_minute" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.llm_calls_per_minute", + value=data["max_llm_calls_per_minute"], + ) + ) + message = "Rate limit exceeded" + + elif check_type == "field_regex": + conditions.extend(_make_conditions(data.get("conditions", []) or [])) + message = str(data.get("message", "Field regex check failed")) + + elif check_type == "data_quality_score": + field = data.get("field", "tool_result") + if data.get("check_encoding", True): + conditions.append( + Condition( + operator="encoding_concern", + field=field, + value={ + "min_confidence": float(data.get("min_confidence", 0.5)), + "max_replacement_ratio": float( + data.get("max_replacement_ratio", 0.05) + ), + "min_corruption_events": int( + data.get("min_corruption_events", 2) + ), + }, + ) + ) + if data.get("check_entropy", True): + conditions.append( + Condition( + operator="entropy_concern", + field=field, + value={ + "min": float(data.get("entropy_min", 1.5)), + "max": float(data.get("entropy_max", 7.5)), + }, + ) + ) + message = str( + data.get("message", "A.7.4: Data quality signal (encoding or entropy)") + ) + + elif check_type == "incident_taxonomy": + field = data.get("field", "model_output") + categories = data.get("categories") + value: dict[str, Any] = {} + if categories: + value["categories"] = list(categories) + conditions.append( + Condition(operator="incident_concern", field=field, value=value) + ) + message = str(data.get("message", "A.8.4: Incident signal detected")) + + elif check_type == "commitment_extractor": + field = data.get("field", "model_output") + conditions.append( + Condition( + operator="commitment_concern", + field=field, + value={ + "require_amount": bool(data.get("require_amount", True)), + "require_deadline": bool(data.get("require_deadline", False)), + }, + ) + ) + message = str( + data.get("message", "A.10.4: Customer commitment language detected") + ) + + elif check_type == "sentiment_concern": + field = data.get("field", "model_input") + threshold = float(data.get("threshold", -0.3)) + conditions.append( + Condition( + operator="vader_concern", + field=field, + value={"threshold": threshold}, + ) + ) + message = str( + data.get( + "message", + f"Negative sentiment detected (VADER compound <= {threshold})", + ) + ) + + elif check_type == "guardrail_fallback": + # Centralized guardrail compensating control. The on/off state + # lives at the RULE level (mapped_to_uipath / policy_enabled), + # threaded in from ``_build_rule``; ``validator`` names which + # guardrail check the server should run on behalf of the agent. + # The condition matches only when the guardrail is mapped to + # UiPath but disabled — see the ``guardrail_fallback`` operator + # in :class:`GovernanceEvaluator`. + conditions.append( + Condition( + operator="guardrail_fallback", + field="", + value={ + "validator": str(data.get("validator", "")), + "mapped_to_uipath": mapped_to_uipath, + "policy_enabled": policy_enabled, + }, + ) + ) + message = str( + data.get("message", "Guardrail disabled — compensating check needed.") + ) + + else: + logger.debug("Skipping check: unknown type %r", check_type) + return None + + if not conditions: + return None + + action_str = data.get("action") + action = ( + _ACTION_MAP.get(action_str, default_action) if action_str else default_action + ) + + message = str(data.get("message", message)) + + # Multi-pattern regex/parameter_validation defaults to OR semantics + # (any pattern indicates a hit); explicit `logic` in YAML wins. + if check_type in ("parameter_validation", "regex") and len(conditions) > 1: + default_logic = "any" + else: + default_logic = "all" + logic = str(data.get("logic", default_logic)) + + return Check(conditions=conditions, action=action, message=message, logic=logic) + + +def _make_conditions(raw: list[dict[str, Any]]) -> list[Condition]: + """Translate a list of YAML condition dicts into Condition objects.""" + out: list[Condition] = [] + for cond in raw: + if not isinstance(cond, dict): + continue + out.append( + Condition( + operator=str(cond.get("operator", "regex")), + field=str(cond.get("field", "model_input")), + value=cond.get("value", ""), + negate=bool(cond.get("negate", False)), + ) + ) + return out + + +def _field_for_scope(scope: list[str] | str) -> str: + """Map a YAML `scope` value to the CheckContext field it targets.""" + if isinstance(scope, str): + scope = [scope] + if "system" in scope or "human" in scope: + return "model_input" + if "ai" in scope: + return "model_output" + if "tool_result" in scope: + return "tool_result" + return "model_input" diff --git a/src/uipath/runtime/governance/native/backend_client.py b/src/uipath/runtime/governance/native/backend_client.py new file mode 100644 index 0000000..8269ea7 --- /dev/null +++ b/src/uipath/runtime/governance/native/backend_client.py @@ -0,0 +1,383 @@ +"""Governance backend client. + +Hosts the shared infrastructure used by every governance-backend call: + +- :func:`get_backend_base_url` — resolves the cloud host (with the + org/tenant path segments stripped) so each endpoint builder can + append its own scoped path. +- :func:`governance_request_headers` — composes the headers shared by + the policy fetch and the ``/runtime/govern`` compensating POST + (Accept, User-Agent, optional Content-Type, optional Bearer auth). +- :func:`build_governance_url` — composes an org-scoped URL against + the ``agenticgovernance_`` ingress. +- :func:`resolve_organization_id` / :func:`resolve_tenant_id` — read + the active org/tenant from ``UiPathConfig`` with an env-var fallback + for installations that don't have ``uipath-platform``. +- :func:`safe_call` — fail-open helper that catches every non-block + exception so governance hooks never crash an agent run. +- Module-level constants — request timeout, service path prefix, + compensation pool size — all the tunables an operator might care + about. Defined once here so the policy fetch, the compensating + ``/runtime/govern`` call, and the loader share one definition. + +The endpoint clients live next door: + +- :mod:`uipath.runtime.governance.native.policy_api_client` — policy fetch +- :mod:`uipath.runtime.governance.native.guardrail_compensation` — /runtime/govern +""" + +from __future__ import annotations + +import logging +import os +from functools import lru_cache +from typing import Callable +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# ---------------------------------------------------------------------------- +# Env-var names (consumed by the helpers below + diagnostic messages) +# ---------------------------------------------------------------------------- + +# Explicit dev/test override — used verbatim, no path-stripping. +ENV_BACKEND_BASE_URL = "UIPATH_GOVERNANCE_BACKEND_URL" +# The canonical platform URL env var (also backs ``UiPathConfig.base_url``). +ENV_PLATFORM_BASE_URL = "UIPATH_URL" +# Bearer token; missing means the policy fetch and compensating call are +# skipped (and that fact is logged) rather than producing 401s on every call. +ENV_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" +# Org / tenant scoping for the agenticgovernance_ ingress. +ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" +ENV_TENANT_ID = "UIPATH_TENANT_ID" +# Job-execution context forwarded in the /runtime/govern payload so the +# server can populate the LLMOps trace record (Doc-2 audit structure). +# Each falls back to the named env var when uipath-platform isn't present. +ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" +ENV_JOB_KEY = "UIPATH_JOB_KEY" +ENV_PROCESS_KEY = "UIPATH_PROCESS_UUID" +ENV_REFERENCE_ID = "UIPATH_AGENT_ID" +ENV_AGENT_VERSION = "UIPATH_PROCESS_VERSION" + +# ---------------------------------------------------------------------------- +# Endpoint shape — all governance calls hit the org-scoped agenticgovernance_ +# service. Centralised so adding a third endpoint is "one new path constant" +# instead of "a new path template that someone forgets to keep in sync." +# ---------------------------------------------------------------------------- + +GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" +POLICY_API_PATH = "api/v1/runtime/policy" +GOVERN_API_PATH = "api/v1/runtime/govern" +TENANT_HEADER = "x-uipath-internal-tenantid" +# Query param on the policy fetch that selects the agent-type view of the +# policy: the server's clause-resolver reads the matching container key +# (``*-in-flight-conversational-agents`` vs ``*-in-flight-agents``). It's a +# representation selector (it changes the returned policy), so it travels as a +# query param — cache-correct and part of resource identification — not a +# header. Values: "conversational" | "autonomous". +AGENT_TYPE_PARAM = "agentType" +AGENT_TYPE_CONVERSATIONAL = "conversational" +AGENT_TYPE_AUTONOMOUS = "autonomous" + +# Default base URL when no override and no UiPathConfig / UIPATH_URL value is +# available. Used only on developer machines doing fully-offline work; real +# deployments always have UIPATH_URL injected by the host. +_DEFAULT_BACKEND_BASE_URL = "https://alpha.uipath.com" + +# ---------------------------------------------------------------------------- +# Tunables — one place so an ops change is one edit. The values that bound +# how long a single agent run can spend on governance traffic. +# ---------------------------------------------------------------------------- + +# Per-request timeout for any governance backend HTTP call (policy fetch, +# /runtime/govern compensating POST). Same value used everywhere so an agent +# can't accidentally end up with a "long" timeout on one call and "short" on +# another. +BACKEND_REQUEST_TIMEOUT_SECONDS = 10.0 + +# Bound on concurrent /runtime/govern requests in flight. A misbehaving +# agent that fires `before_model` 100 times in a session with three matched +# fallback rules each would otherwise spawn 100 daemon threads; this pool +# caps the concurrency. Saturated submissions are logged and dropped — the +# server still receives traces from the requests that did land. +COMPENSATION_MAX_WORKERS = 4 + +# Browser-shaped User-Agent. Required because the alpha/production +# governance ingress runs a WAF whose default scanner rule set blocks +# ``Python-urllib/``. Identifying as a real browser keeps the +# request from being rejected before any auth/tenant logic runs. +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/148.0.0.0 Safari/537.36" +) + + +# ---------------------------------------------------------------------------- +# Headers +# ---------------------------------------------------------------------------- + + +def governance_request_headers(*, json_body: bool = False) -> dict[str, str]: + """Return the common HTTP headers for governance backend requests. + + Centralises the headers shared between the policy fetch and the + compensating ``/runtime/govern`` POST so the UA and auth shape are + declared once. + + Args: + json_body: When ``True`` (POST/PATCH/etc. with a JSON payload), + adds ``Content-Type: application/json``. GETs leave it off + so origin servers that 415 on unexpected Content-Type stay + happy. + + Returns: + A new dict with: + + - ``Accept: application/json`` + - ``User-Agent`` (the browser-shaped string above) + - ``Content-Type: application/json`` when ``json_body=True`` + - ``Authorization: Bearer `` when the env + var is set; omitted otherwise (caller decides whether the + missing token is fatal). + + Endpoint-specific headers (e.g. ``x-uipath-internal-tenantid``) are + added by the caller after this helper returns. + """ + headers: dict[str, str] = { + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + if json_body: + headers["Content-Type"] = "application/json" + token = os.environ.get(ENV_ACCESS_TOKEN) + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +# ---------------------------------------------------------------------------- +# URL composition +# ---------------------------------------------------------------------------- + + +def _strip_to_origin(raw_url: str) -> str: + """Return ``scheme://host[:port]`` for ``raw_url``, dropping any path. + + Platform URLs are commonly ``https://cloud.uipath.com//``; + the governance endpoints construct their own + ``/{org}/agenticgovernance_/...`` suffix, so the org/tenant segments + in the base must be stripped to avoid a duplicated org path. + """ + parsed = urlparse(raw_url) + if not parsed.scheme or not parsed.netloc: + # Not a parseable absolute URL — leave it to the caller. + return raw_url.rstrip("/") + return f"{parsed.scheme}://{parsed.netloc}" + + +def get_backend_base_url() -> str: + """Resolve the governance backend base URL on each call. + + Resolution order (first hit wins): + + 1. ``UIPATH_GOVERNANCE_BACKEND_URL`` — explicit dev/test override, + used verbatim. + 2. ``UiPathConfig.base_url`` from ``uipath-platform`` — the + canonical platform URL. Org/tenant path segments are stripped + so the caller can append its own org-scoped path. + 3. ``UIPATH_URL`` env var — same as (2) but works when + ``uipath-platform`` is not installed. + 4. ``https://alpha.uipath.com`` — last-resort default for offline + development; real deployments always have ``UIPATH_URL`` set. + + Reading on each call (not at import) lets the runtime entrypoint + configure the env vars after this module is already loaded. + """ + explicit_override = os.environ.get(ENV_BACKEND_BASE_URL) + if explicit_override: + return explicit_override.rstrip("/") + + # Lazy import — uipath-platform is optional; falls through to the + # env-var path when only uipath-core / uipath-runtime are installed. + platform_url: str | None = None + try: + from uipath.platform.common import UiPathConfig + + platform_url = UiPathConfig.base_url + except (ImportError, AttributeError): + pass + + raw = platform_url or os.environ.get(ENV_PLATFORM_BASE_URL) + if raw: + return _strip_to_origin(raw) + + return _DEFAULT_BACKEND_BASE_URL + + +def build_governance_url(org_id: str, path: str) -> str: + """Compose an org-scoped governance backend URL. + + Final shape: ``{backend_base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}``. + + Args: + org_id: Active organization id; the URL is meaningless without it. + path: API suffix WITHOUT the org/service prefix + (e.g. :data:`POLICY_API_PATH` or :data:`GOVERN_API_PATH`). + """ + base = get_backend_base_url() + return f"{base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}" + + +# ---------------------------------------------------------------------------- +# Org / tenant resolution +# ---------------------------------------------------------------------------- + + +def _resolve_uipath_config_field(attr: str, env_var: str) -> str | None: + """Read a single ``UiPathConfig`` attribute with an env-var fallback. + + Lazy-imports ``UiPathConfig`` so ``uipath-runtime`` doesn't require + ``uipath-platform`` at install time. When the platform package is + missing (``ImportError``) or the attribute isn't yet exposed + (``AttributeError``), falls back to reading the named env var. + """ + try: + from uipath.platform.common import UiPathConfig + + return getattr(UiPathConfig, attr, None) or os.environ.get(env_var) + except ImportError: + return os.environ.get(env_var) + + +# ---------------------------------------------------------------------------- +# Agent-type selector (conversational vs autonomous) +# +# Set once by the governance wrapper at runtime init (before the background +# policy prefetch is kicked off) and read by the policy fetch when composing +# the request URL. A process-level holder — not a ContextVar — because the +# prefetch runs on a separate thread that wouldn't inherit a ContextVar, and a +# coded-agent process hosts a single agent so the value is stable per process. +# ---------------------------------------------------------------------------- + +_agent_is_conversational: bool | None = None + + +def set_agent_conversational(value: bool | None) -> None: + """Record whether the hosted agent is conversational. + + ``None`` clears the selector (used by tests / direct callers); the policy + fetch then omits the param and the server applies its default. + """ + global _agent_is_conversational + _agent_is_conversational = value + + +def agent_type_param() -> str | None: + """Return the ``agentType`` query value, or ``None`` when unknown. + + ``"conversational"`` / ``"autonomous"`` map to the server's + conversational-vs-autonomous container keys; ``None`` (selector never set) + omits the param so the server's default applies. + """ + if _agent_is_conversational is None: + return None + return AGENT_TYPE_CONVERSATIONAL if _agent_is_conversational else AGENT_TYPE_AUTONOMOUS + + +def resolve_organization_id() -> str | None: + """Return the current organization id from ``UiPathConfig`` / env. + + Returns ``None`` when neither source yields a value — callers skip + the backend interaction (no URL can be built without an org id) + and the agent runs with no policies / no compensation. + """ + return _resolve_uipath_config_field("organization_id", ENV_ORGANIZATION_ID) + + +def resolve_tenant_id() -> str | None: + """Return the current tenant id from ``UiPathConfig`` / env. + + Returns ``None`` when neither source yields a value — callers skip + the backend interaction since the ``x-uipath-internal-tenantid`` + header would be missing. + """ + return _resolve_uipath_config_field("tenant_id", ENV_TENANT_ID) + + +@lru_cache(maxsize=1) +def _resolved_job_context() -> tuple[tuple[str, str], ...]: + """Resolve and freeze the job context once per process. + + Returned as a tuple of ``(key, value)`` pairs so the cached value is + immutable — callers materialize a fresh dict each call. Tests that + mutate env vars can invalidate via ``resolve_job_context.cache_clear()``. + """ + candidates = { + "folderKey": _resolve_uipath_config_field("folder_key", ENV_FOLDER_KEY), + "jobKey": _resolve_uipath_config_field("job_key", ENV_JOB_KEY), + "processKey": _resolve_uipath_config_field("process_uuid", ENV_PROCESS_KEY), + "referenceId": _resolve_uipath_config_field("agent_id", ENV_REFERENCE_ID), + "agentVersion": _resolve_uipath_config_field( + "process_version", ENV_AGENT_VERSION + ), + } + return tuple((k, v) for k, v in candidates.items() if v) + + +def resolve_job_context() -> dict[str, str]: + """Return the agent's job-execution context for the govern payload. + + Each field is read from ``UiPathConfig`` (env-var fallback) and only + included when it resolves to a truthy value, so the server receives + exactly the keys the agent actually knows. Cached per-process — the + underlying values are immutable for the agent's lifetime. The server + maps these onto the LLMOps trace record: + + - ``folderKey`` → ``FolderKey`` / ``uipath.folder_key`` + - ``jobKey`` → ``JobKey`` / ``uipath.job_key`` + - ``processKey`` → ``ProcessKey`` + - ``referenceId`` → ``ReferenceId`` (typically the agent id) + - ``agentVersion`` → ``AgentVersion`` + """ + return dict(_resolved_job_context()) + + +resolve_job_context.cache_clear = _resolved_job_context.cache_clear # type: ignore[attr-defined] + + +# ---------------------------------------------------------------------------- +# Generic safe-call helper. Used by callers that want "log and continue" on +# any unexpected failure path without spelling out the same try/except every +# time. The intentional GovernanceBlockException ALWAYS propagates — only +# this exception type carries policy intent; anything else is a bug. +# ---------------------------------------------------------------------------- + + +def safe_call( + fn: Callable[..., None], + *args: object, + what: str, + **kwargs: object, +) -> None: + """Call ``fn(*args, **kwargs)`` and swallow any non-block exception. + + ``GovernanceBlockException`` propagates (intentional policy block); + everything else is logged at WARNING with the ``what`` label and + swallowed so the agent can continue. Designed for fire-and-forget + governance paths that should never fail an agent run. + + Args: + fn: Callable to invoke. + what: Short label used in the log line on failure + (e.g. ``"BEFORE_AGENT governance check"``). + """ + # Lazy import to avoid pulling uipath-core into module load. + from uipath.core.governance.exceptions import GovernanceBlockException + + try: + fn(*args, **kwargs) + except GovernanceBlockException: + raise + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning("%s failed (continuing): %s", what, exc) diff --git a/src/uipath/runtime/governance/native/evaluator.py b/src/uipath/runtime/governance/native/evaluator.py new file mode 100644 index 0000000..0635302 --- /dev/null +++ b/src/uipath/runtime/governance/native/evaluator.py @@ -0,0 +1,1061 @@ +"""Governance rule evaluator.""" + +from __future__ import annotations + +import logging +import math +import re +from collections import Counter +from datetime import datetime, timezone +from functools import lru_cache +from typing import Any + +from uipath.core.governance.exceptions import GovernanceBlockException +from uipath.core.governance.models import ( + Action, + AuditRecord, + LifecycleHook, + RuleEvaluation, +) + +from uipath.runtime.governance.audit import get_audit_manager +from uipath.runtime.governance.config import EnforcementMode, get_enforcement_mode +from uipath.runtime.governance.native.guardrail_compensation import ( + disabled_guardrails, + submit_compensation, +) +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + Rule, +) + +logger = logging.getLogger(__name__) + + +def _compensation_data_for_hook(context: CheckContext) -> dict[str, Any]: + """Build the ``data`` payload for the /runtime/govern compensating call. + + The server runs the guardrail check against the same content the + evaluator was looking at — so we forward whichever + :class:`CheckContext` field is populated for the active hook. Fields + not relevant to the hook are omitted to keep the payload tight. + """ + if context.hook in (LifecycleHook.BEFORE_AGENT,): + return {"content": context.agent_input} + if context.hook in (LifecycleHook.AFTER_AGENT,): + return {"content": context.agent_output} + if context.hook in (LifecycleHook.BEFORE_MODEL,): + payload: dict[str, Any] = {"content": context.model_input} + if context.messages: + payload["messages"] = context.messages + return payload + if context.hook in (LifecycleHook.AFTER_MODEL,): + return {"content": context.model_output} + if context.hook in (LifecycleHook.TOOL_CALL,): + return {"tool_name": context.tool_name, "tool_args": context.tool_args} + if context.hook in (LifecycleHook.AFTER_TOOL,): + return {"tool_name": context.tool_name, "tool_result": context.tool_result} + # Memory-write and unknown hooks: pass an empty content so the + # server still receives a structurally-valid payload. + return {"content": ""} + + +@lru_cache(maxsize=256) +def _compile_regex(pattern: str) -> re.Pattern[str] | None: + """Compile and cache a regex pattern. + + Args: + pattern: The regex pattern string + + Returns: + Compiled pattern or None if invalid + """ + try: + return re.compile(pattern) + except re.error as e: + logger.warning("Invalid regex pattern '%s': %s", pattern, e) + return None + + +# --- vaderSentiment: lazy-imported singleton --- +# Hard dependency, but lazy-loaded to keep import-time cost off the +# critical path. The except branch is defence against a corrupted +# install (file present in METADATA but module unimportable) — the +# operator no-ops rather than crashing the agent. +_VADER_UNINITIALIZED = object() +_vader_analyzer: Any = _VADER_UNINITIALIZED + + +def _get_vader_analyzer() -> Any: + """Return a cached SentimentIntensityAnalyzer, or None if unavailable.""" + global _vader_analyzer + if _vader_analyzer is _VADER_UNINITIALIZED: + try: + from vaderSentiment.vaderSentiment import ( + SentimentIntensityAnalyzer, + ) + + _vader_analyzer = SentimentIntensityAnalyzer() + except ImportError: + logger.error( + "vaderSentiment failed to import despite being a hard dependency; " + "sentiment_concern checks will not fire. Reinstall uipath-core." + ) + _vader_analyzer = None + return _vader_analyzer + + +# --- chardet: lazy-imported module for encoding integrity (A.7.4) --- +# Hard dependency, lazy-loaded for symmetry with the other library +# wrappers. The except branch covers corrupted installs only. +_CHARDET_UNINITIALIZED = object() +_chardet_module: Any = _CHARDET_UNINITIALIZED + + +def _get_chardet() -> Any: + """Return the chardet module, or None if unavailable.""" + global _chardet_module + if _chardet_module is _CHARDET_UNINITIALIZED: + try: + import chardet + + _chardet_module = chardet + except ImportError: + logger.error( + "chardet failed to import despite being a hard dependency; " + "encoding_concern confidence check will not fire (stdlib " + "signals still apply). Reinstall uipath-core." + ) + _chardet_module = None + return _chardet_module + + +# --- Static patterns for encoding_concern (A.7.4) --- +# Latin-1-as-UTF-8 mojibake bigrams — the visible artefacts when +# UTF-8-encoded text is re-decoded as Latin-1 / Windows-1252. +_MOJIBAKE_BIGRAMS: tuple[str, ...] = ( + "é", + "è", + "â", + "à ", + "ù", + "î", + "ô", + "ç", # accented vowels + "Ä", + "Ö", + "Ü", + "ß", # German umlauts / eszett + "’", + "“", + "â€\x9d", + "–", + "—", + "•", # smart quotes / dashes + "£", + "°", + "§", + "¶", + "©", + "®", # NBSP-leading symbols + "ï¿", + "¿½", # mojibake'd U+FFFD (0xEF 0xBF 0xBD as Latin-1) + "ï»", + "»¿", # mojibake'd BOM (0xEF 0xBB 0xBF as Latin-1) +) + +# Literal hex escape sequences ("\x80" as 4 source chars) indicate raw +# bytes leaked through a string layer rather than being decoded. +_HEX_ESCAPE_PATTERN = re.compile(r"\\x[0-9a-fA-F]{2}") + + +# --- Static patterns for incident_concern (A.8.4) --- +# Stdlib-only categorical taxonomy. Mirrors sentry-sdk's incident shape +# (categorical types over stack/status), but for string payloads from +# model output / tool result rather than exception objects. +_INCIDENT_PATTERNS: dict[str, list[re.Pattern[str]]] = { + "safety_refusal": [ + re.compile( + r"(?i)\b(i\s+(?:cannot|can'?t|am\s+unable\s+to|won'?t\s+be\s+able\s+to)" + r"\s+(?:help|assist|provide|answer|do\s+that))\b" + ), + re.compile(r"(?i)\b(i'?m\s+sorry,?\s+but\s+i\s+(?:cannot|can'?t))\b"), + re.compile(r"(?i)\b(against\s+my\s+(?:guidelines|policies|programming))\b"), + ], + "tool_failure": [ + re.compile( + r"\b(5\d{2})\b\s*(?:internal\s+server\s+error|service\s+unavailable)" + ), + re.compile(r"(?i)\b(ERR_[A-Z_]+|connection\s+refused|ECONNREFUSED)\b"), + re.compile(r"(?i)\b(timed?\s*out|timeout)\b"), + ], + "auth_failure": [ + re.compile(r"\b(401|403)\b\s*(?:unauthori[sz]ed|forbidden)"), + re.compile( + r"(?i)\b(authentication\s+failed|invalid\s+(?:token|credentials))\b" + ), + ], + "quota_exceeded": [ + re.compile(r"\b(429)\b"), + re.compile( + r"(?i)\b(rate\s+limit\s+exceeded|quota\s+exceeded|too\s+many\s+requests)\b" + ), + ], + "hallucination": [ + re.compile(r"(?i)\b(i\s+(?:made\s+(?:that|this)\s+up|am\s+just\s+guessing))\b"), + re.compile(r"(?i)\b(i\s+don'?t\s+actually\s+know|i\s+fabricat(?:ed|ing))\b"), + ], +} + +# --- Static patterns for commitment_concern (A.10.4) --- +# Commitment-language signals. The verb pattern covers both first-person +# promise verbs ("we will refund") and formal-business commitment markers +# common in proposal / SOW outputs ("Cost: $X", "fixed scope", +# "Deliverables", "Timeline: N days", "I propose"). Verb, amount, and +# deadline signals combine via OR semantics — see +# :meth:`_check_commitment_concern`. +_COMMITMENT_VERB_PATTERN = re.compile( + r"(?i)(" + # First-person promise / liability verbs + r"\brefund\b|\breimburse\b|" + r"\bwarranty\b|\bwarrant(?:y|ed|ies)\b|\bguarante[ed]+\b|" + r"\bsla\b|" + r"\bwaive[d]?\b|" + r"\b(?:we|i)\s+(?:will|shall|promise|commit|guarantee)\b|" + r"\b(?:we|i|i'?ll)\s+(?:deliver|provide|complete|finish|" + r"handover|hand\s+over|ship)\b|" + # Proposal / SOW commitment markers + r"\bfixed\s+(?:price|cost|fee|scope|bid|rate)\b|" + r"\bcost\s*:\s*\$?\d|" + r"\bquote\s*:\s*\$?\d|" + r"\bdeliverables?\b|" + r"\btimeline\s*:\s*\d+\s*(?:second|minute|hour|day|week|month|year)s?\b|" + r"\bI\s+propose\b" + r")" +) +# Currency-anchored amount detection. Requires a currency marker adjacent +# to the number so URL fragments (e.g. ``/667851``) don't false-positive. +# Covers symbol-then-number ($780) and number-then-code (780 USD). +# +# Bare percentages (``75%``, ``99.9%``) are deliberately NOT matched +# here — they fire on benign status / progress text ("75% complete", +# "99.9% uptime") under OR semantics. Real percentage-bearing +# commitments ("we'll give you a 20% discount", "refund 100%") still +# fire via the verb pattern. +_COMMITMENT_AMOUNT_FALLBACK = re.compile( + r"(?:\$|€|£|¥|₹|USD|EUR|GBP|JPY|INR)\s*\d[\d,]*(?:\.\d+)?" + r"|\b\d[\d,]*(?:\.\d+)?\s*(?:USD|EUR|GBP|JPY|INR|" + r"dollars?|euros?|pounds?|yen|rupees?)\b" +) +_COMMITMENT_DEADLINE_PATTERN = re.compile( + r"(?i)\bwithin\s+\d+\s*(?:second|minute|hour|day|week|month|year)s?\b" + r"|\bby\s+(?:tomorrow|next\s+\w+|\d+/\d+(?:/\d+)?)\b" +) + + +class GovernanceEvaluator: + """Evaluates governance rules against check contexts. + + Supports two enforcement modes: + - AUDIT: Log all violations but never block (DENY becomes AUDIT in final action) + - ENFORCE: Actually block on DENY rules + + Default mode is AUDIT for safety. + """ + + def __init__( + self, + policy_index: PolicyIndex, + mode: EnforcementMode | None = None, + ) -> None: + """Initialize with a compiled policy index and optional mode override.""" + self._policy_index = policy_index + self._mode = mode + + @property + def policy_index(self) -> PolicyIndex: + """Return the compiled policy index this evaluator runs against.""" + return self._policy_index + + @property + def mode(self) -> EnforcementMode: + """Get the enforcement mode (uses config default if not set).""" + if self._mode is not None: + return self._mode + return get_enforcement_mode() + + @mode.setter + def mode(self, value: EnforcementMode) -> None: + """Set the enforcement mode.""" + self._mode = value + + def is_audit_mode(self) -> bool: + """Check if running in audit-only mode.""" + return self.mode == EnforcementMode.AUDIT + + def is_enforce_mode(self) -> bool: + """Check if running in enforce mode (will block on DENY).""" + return self.mode == EnforcementMode.ENFORCE + + def evaluate(self, context: CheckContext) -> AuditRecord: + """Evaluate rules registered for ``context.hook`` against the context. + + Only rules whose ``hook`` field matches the current lifecycle hook + are evaluated — a ``tool_call`` rule does not fire on + ``before_model``, and vice versa. This avoids running checks + against fields the context cannot provide and keeps the audit + stream scoped to the active phase. + + The final action depends on the enforcement mode: + - DISABLED mode: Short-circuit; no rules evaluated, no audit emitted. + - AUDIT mode: Even DENY rules result in AUDIT action (log only, don't block) + - ENFORCE mode: DENY rules result in DENY action AND a + :class:`GovernanceBlockException` is raised. + + Audit events (per-rule + hook summary) are emitted via the + global :func:`get_audit_manager` so callers do not need to do + any emission themselves. + + Args: + context: The check context with hook and content + + Returns: + AuditRecord with all evaluations and final action. + + Raises: + GovernanceBlockException: In ENFORCE mode when a DENY rule matches. + """ + mode = self.mode + if mode == EnforcementMode.DISABLED: + return AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name=context.agent_name, + runtime_id=context.runtime_id, + trace_id=context.trace_id, + hook=context.hook, + evaluations=[], + final_action=Action.ALLOW, + metadata={**context.metadata, "enforcement_mode": mode.value}, + ) + + rules = self._policy_index.get_rules_for_hook(context.hook) + + evaluations: list[RuleEvaluation] = [] + raw_action = Action.ALLOW # The action before mode adjustment + deny_would_fire = False # Track if DENY would have fired + + for rule in rules: + if not rule.enabled: + continue + + evaluation = self._evaluate_rule(rule, context) + evaluations.append(evaluation) + + if evaluation.matched: + # Take the most restrictive action + if rule.action == Action.DENY: + raw_action = Action.DENY + deny_would_fire = True + elif rule.action == Action.ESCALATE and raw_action != Action.DENY: + raw_action = Action.ESCALATE + elif rule.action == Action.AUDIT and raw_action == Action.ALLOW: + raw_action = Action.AUDIT + + # Apply enforcement mode + final_action = self._apply_enforcement_mode(raw_action) + + # Build metadata with mode info + record_metadata = dict(context.metadata) + record_metadata["enforcement_mode"] = mode.value + if deny_would_fire and self.is_audit_mode(): + record_metadata["audit_mode_would_deny"] = True + + audit = AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name=context.agent_name, + runtime_id=context.runtime_id, + trace_id=context.trace_id, + hook=context.hook, + evaluations=evaluations, + final_action=final_action, + metadata=record_metadata, + ) + + self._emit_audit(audit, mode) + + # For any guardrail mapped to UiPath but currently disabled, hand + # the disabled guardrails to the governance-server's + # /runtime/govern endpoint. The SERVER runs the guardrail check + # AND writes the trace (the payload carries traceId / src_timestamp + # / hook / agent so it can correlate) — the agent does NOT emit a + # trace itself, to avoid double-writing. Fire-and-forget on a + # daemon thread so a slow or unreachable endpoint never blocks + # the agent. + self._dispatch_compensation(audit, context) + + if final_action == Action.DENY: + raise GovernanceBlockException.from_audit_record(audit) + + return audit + + def _dispatch_compensation( + self, audit: AuditRecord, context: CheckContext + ) -> None: + """Schedule compensating governance for any matched fallback rules. + + Hands the call to the bounded background pool in + :func:`uipath.runtime.governance.native.guardrail_compensation.submit_compensation`. + That helper owns concurrency, queue caps, exception isolation, + and graceful process-exit cancellation — this method just + builds the payload, logs the summary, and submits. + """ + try: + disabled = disabled_guardrails(audit, self._policy_index) + if not disabled: + return + + validators = [rule["validator"] for rule in disabled] + + # Surface the disabled-guardrail fire-up: how many rules + # triggered the compensating call, and which validators + # they map to (e.g. pii_detection / prompt_injection / + # harmful_content). One line per dispatch so an operator + # can see the volume + breakdown at a glance. + logger.info( + "Compensating governance triggered: hook=%s, count=%d, validators=[%s]", + audit.hook.value, + len(disabled), + ", ".join(validators), + ) + + submit_compensation( + rules=disabled, + data=_compensation_data_for_hook(context), + hook=audit.hook.value, + trace_id=audit.trace_id, + src_timestamp=audit.timestamp.isoformat(), + agent_name=audit.agent_name, + runtime_id=audit.runtime_id, + ) + except Exception as exc: # noqa: BLE001 - fail-open + logger.warning( + "Failed to dispatch compensating governance call: %s", exc + ) + + def _emit_audit(self, audit: AuditRecord, mode: EnforcementMode) -> None: + """Emit per-rule and hook-summary events to the global audit manager. + + Failure-isolated: audit-sink errors must never break evaluation. + Sink-level circuit breaking is handled inside :class:`AuditManager`. + """ + try: + manager = get_audit_manager() + except Exception as exc: # pragma: no cover - defensive + logger.debug("Audit manager unavailable; skipping emission: %s", exc) + return + + hook_name = audit.hook.name + + # ``guardrail_fallback`` rules are server-traced: the agent POSTs + # to ``/runtime/govern`` (see :meth:`_dispatch_compensation`) and + # the governance-server emits the audit event with the actual + # validator verdict. Emitting a Python-side ``rule_evaluation`` + # event here would produce a duplicate trace carrying no + # verdict, so filter these rules out of every event the Python + # evaluator emits (per-rule AND the hook summary's counts). + emittable = [ + ev for ev in audit.evaluations + if not self._is_guardrail_fallback_rule(ev.rule_id) + ] + + for evaluation in emittable: + manager.emit_rule_evaluation( + rule_id=evaluation.rule_id, + rule_name=evaluation.rule_name, + pack_name=evaluation.pack_name, + hook=hook_name, + matched=evaluation.matched, + action=evaluation.action.value if evaluation.matched else "allow", + detail=evaluation.detail, + agent_name=audit.agent_name, + trace_id=audit.trace_id, + description=evaluation.description, + ) + + manager.emit_hook_summary( + hook=hook_name, + agent_name=audit.agent_name, + total_rules=len(emittable), + matched_rules=sum(1 for ev in emittable if ev.matched), + final_action=audit.final_action.value, + trace_id=audit.trace_id, + enforcement_mode=mode.value, + ) + + def _is_guardrail_fallback_rule(self, rule_id: str) -> bool: + """Return True if the rule is a UiPath-compensating fallback rule. + + Such rules carry a ``guardrail_fallback`` condition; their audit + trace is emitted by the governance-server in response to the + ``/runtime/govern`` POST, so the Python evaluator must not emit + a duplicate trace for them. + """ + rule = self._policy_index.get_rule(rule_id) + if rule is None: + return False + for check in rule.checks: + for cond in check.conditions: + if cond.operator == "guardrail_fallback": + return True + return False + + def _apply_enforcement_mode(self, raw_action: Action) -> Action: + """Apply enforcement mode to the raw action. + + In AUDIT mode: + - DENY becomes AUDIT (log but don't block) + - ESCALATE becomes AUDIT (log but don't escalate) + - AUDIT stays AUDIT + - ALLOW stays ALLOW + + In ENFORCE mode: + - All actions pass through unchanged + """ + if self.mode == EnforcementMode.AUDIT: + if raw_action in (Action.DENY, Action.ESCALATE): + return Action.AUDIT + return raw_action + + def evaluate_before_agent( + self, + agent_input: str, + agent_name: str, + runtime_id: str, + trace_id: str, + model_name: str = "", + **kwargs: Any, + ) -> AuditRecord: + """Evaluate BEFORE_AGENT rules.""" + context = CheckContext( + hook=LifecycleHook.BEFORE_AGENT, + agent_name=agent_name, + runtime_id=runtime_id, + trace_id=trace_id, + agent_input=agent_input, + model_name=model_name, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_agent( + self, + agent_output: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_AGENT rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_AGENT, + agent_name=agent_name, + runtime_id=runtime_id, + trace_id=trace_id, + agent_output=agent_output, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + 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, + ) -> AuditRecord: + """Evaluate BEFORE_MODEL rules.""" + context = CheckContext( + hook=LifecycleHook.BEFORE_MODEL, + agent_name=agent_name, + runtime_id=runtime_id, + trace_id=trace_id, + model_input=model_input, + model_name=model_name, + messages=messages or [], + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_model( + self, + model_output: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_MODEL rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_MODEL, + agent_name=agent_name, + runtime_id=runtime_id, + trace_id=trace_id, + model_output=model_output, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + 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, + ) -> AuditRecord: + """Evaluate TOOL_CALL rules.""" + context = CheckContext( + hook=LifecycleHook.TOOL_CALL, + agent_name=agent_name, + runtime_id=runtime_id, + trace_id=trace_id, + tool_name=tool_name, + tool_args=tool_args, + session_state=session_state or {}, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_tool( + self, + tool_name: str, + tool_result: str, + agent_name: str, + runtime_id: str, + trace_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_TOOL rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_TOOL, + agent_name=agent_name, + runtime_id=runtime_id, + trace_id=trace_id, + tool_name=tool_name, + tool_result=tool_result, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def _evaluate_rule(self, rule: Rule, context: CheckContext) -> RuleEvaluation: + """Evaluate a single rule against the context.""" + if not rule.checks: + # No checks = always matches (for audit-only rules) + return RuleEvaluation( + rule_id=rule.rule_id, + rule_name=rule.name, + matched=True, + detail="Rule has no conditions (always matches)", + pack_name=rule.pack_name, + action=rule.action, + description=rule.description, + ) + + check_results: list[dict[str, Any]] = [] + any_check_matched = False + + for check in rule.checks: + matched, detail = self._evaluate_check(check, context) + check_results.append( + { + "matched": matched, + "detail": detail, + "action": check.action.value, + } + ) + if matched: + any_check_matched = True + + # Surface the FIRST matched check's message; falls back to the + # first check's detail (empty string when none matched) for + # backward compatibility with rules that have a single check. + first_matched_detail = next( + (cr["detail"] for cr in check_results if cr["matched"]), + check_results[0]["detail"] if check_results else "", + ) + + return RuleEvaluation( + rule_id=rule.rule_id, + rule_name=rule.name, + matched=any_check_matched, + detail=first_matched_detail, + pack_name=rule.pack_name, + action=rule.action if any_check_matched else Action.ALLOW, + description=rule.description, + check_results=check_results, + ) + + def _evaluate_check(self, check: Check, context: CheckContext) -> tuple[bool, str]: + """Evaluate a single check against the context.""" + if not check.conditions: + return True, "No conditions (always matches)" + + results = [] + for condition in check.conditions: + matched = self._evaluate_condition(condition, context) + results.append(matched) + + if check.logic == "any": + final_match = any(results) + else: # "all" is default + final_match = all(results) + + detail = check.message if final_match else "" + return final_match, detail + + def _evaluate_condition(self, condition: Condition, context: CheckContext) -> bool: + """Evaluate a single condition against the context.""" + field_value = self._get_field_value(condition.field, context) + result = self._apply_operator(condition.operator, field_value, condition.value) + + if condition.negate: + result = not result + + return result + + def _get_field_value(self, field: str, context: CheckContext) -> Any: + """Get a field value from the context.""" + parts = field.split(".") + + # Start with context + value: Any = context + + for part in parts: + if hasattr(value, part): + value = getattr(value, part) + elif isinstance(value, dict) and part in value: + value = value[part] + else: + return None + + return value + + def _apply_operator( + self, operator: str, field_value: Any, check_value: Any + ) -> bool: + """Apply an operator to compare field value against check value.""" + # Handle existence checks before the None check + if operator == "exists": + return field_value is not None + if operator == "not_exists": + return field_value is None + + # guardrail_fallback fires only when the guardrail is mapped to + # UiPath but its policy is disabled. Config travels in + # ``check_value``; the rule's ``field`` is unused (so + # ``field_value`` is ``None`` here, which is expected — we must + # special-case this before the generic ``None`` short-circuit + # below). + if operator == "guardrail_fallback": + cfg = check_value if isinstance(check_value, dict) else {} + return bool(cfg.get("mapped_to_uipath", False)) and not bool( + cfg.get("policy_enabled", True) + ) + + if field_value is None: + return False + + # Numeric operators don't need stringification — short-circuit + # before `str(field_value)` (expensive for dict / large payloads). + if operator in ("gt", "gte", "lt", "lte"): + try: + lhs = float(field_value) + rhs = float(check_value) + except (ValueError, TypeError): + return False + if operator == "gt": + return lhs > rhs + if operator == "gte": + return lhs >= rhs + if operator == "lt": + return lhs < rhs + return lhs <= rhs + + field_str = str(field_value) + + match operator: + case "equals" | "eq": + return field_str == str(check_value) + + case "not_equals" | "ne": + return field_str != str(check_value) + + case "contains": + return str(check_value).lower() in field_str.lower() + + case "not_contains": + return str(check_value).lower() not in field_str.lower() + + case "regex" | "matches": + compiled = _compile_regex(str(check_value)) + if compiled is None: + return False + return bool(compiled.search(field_str)) + + case "in_list": + if isinstance(check_value, list): + return field_str in check_value + return False + + case "not_in_list": + if isinstance(check_value, list): + return field_str not in check_value + return True + + case "vader_concern": + # VADER compound score <= threshold. + # check_value: dict like {"threshold": -0.3} (default -0.3) + return self._check_vader_concern(field_str, check_value) + + case "encoding_concern": + # chardet-backed encoding integrity check (A.7.4). + # check_value: dict with optional `min_confidence` (default 0.5) + # and `max_replacement_ratio` (default 0.05). + return self._check_encoding_concern(field_str, check_value) + + case "entropy_concern": + # Shannon entropy outside expected range (A.7.4). + # check_value: dict with optional `min` (default 1.5) and + # `max` (default 7.5) bits/byte. Stdlib only. + return self._check_entropy_concern(field_str, check_value) + + case "incident_concern": + # Categorical incident detection (A.8.4). + # check_value: dict with optional `categories` list + # (subset of safety_refusal/tool_failure/auth_failure/ + # quota_exceeded/hallucination). Default: all categories. + return self._check_incident_concern(field_str, check_value) + + case "commitment_concern": + # Customer commitment language detection (A.10.4). + # check_value: dict with optional `require_amount` (default + # True) and `require_deadline` (default False). Fires when + # a commitment verb co-occurs with the configured signals. + return self._check_commitment_concern(field_str, check_value) + + case _: + logger.debug("Unknown operator: %s", operator) + return False + + @staticmethod + def _check_vader_concern(text: str, params: Any) -> bool: + """Return True if VADER compound score on `text` is <= threshold. + + Args: + text: Text to analyse. + params: Either a dict with `threshold` key, or a numeric threshold + directly. Default threshold is -0.3 (clearly-negative). + + Returns: + True iff vaderSentiment is available AND compound score <= threshold. + Returns False on empty input or if the library is not installed — + sentiment checks no-op rather than crash. + """ + if not text or not text.strip(): + return False + + analyzer = _get_vader_analyzer() + if analyzer is None: + return False + + if isinstance(params, dict): + threshold = float(params.get("threshold", -0.3)) + else: + try: + threshold = float(params) + except (TypeError, ValueError): + threshold = -0.3 + + try: + compound = float(analyzer.polarity_scores(text)["compound"]) + except Exception as exc: # pragma: no cover - defensive + logger.debug("VADER analysis failed: %s", exc) + return False + + return compound <= threshold + + @staticmethod + def _check_encoding_concern(text: str, params: Any) -> bool: + r"""Return True if `text` shows encoding integrity issues. + + Sums multiple deterministic corruption signals against text length: + - U+FFFD replacement characters (already-decoded lossy text) + - Literal ``�`` escape sequences carried through a JSON + / repr layer rather than being decoded + - Literal ``\xHH`` hex escapes (raw bytes leaked into a string) + - Latin-1-as-UTF-8 mojibake bigrams (e.g. ``é``, ``’``) + If the corruption ratio exceeds ``max_replacement_ratio`` the + check fires. chardet (when installed) is consulted as a + secondary low-confidence signal. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + min_confidence = float(params.get("min_confidence", 0.5)) + max_replacement_ratio = float(params.get("max_replacement_ratio", 0.05)) + min_corruption_events = int(params.get("min_corruption_events", 2)) + + length = max(len(text), 1) + + replacement_chars = text.count("�") + literal_ufffd_escapes = text.count("\\ufffd") + hex_escapes = len(_HEX_ESCAPE_PATTERN.findall(text)) + mojibake_bigrams = sum(text.count(bigram) for bigram in _MOJIBAKE_BIGRAMS) + + # Absolute count of distinct corruption *events* (one per + # U+FFFD, one per literal escape sequence, one per mojibake + # bigram). Even diluted by a lot of clean text, a few of these + # in production output is a strong signal. + corruption_events = ( + replacement_chars + literal_ufffd_escapes + hex_escapes + mojibake_bigrams + ) + if corruption_events >= min_corruption_events: + return True + + # Ratio-based fallback for cases below the absolute floor: still + # catches very short payloads where a single corruption char is + # disproportionate. + # Weight each event by its source-char span so denser corruption + # in shorter text trips the ratio sooner: + # U+FFFD = 1 char, "�" = 6 chars, "\xHH" = 4 chars, + # mojibake bigram = 2 chars. + corruption_chars = ( + replacement_chars + + 6 * literal_ufffd_escapes + + 4 * hex_escapes + + 2 * mojibake_bigrams + ) + if corruption_chars / length > max_replacement_ratio: + return True + + # Secondary: chardet on the encoded bytes. For pure str input + # this almost always reports high UTF-8/ASCII confidence (the + # branch is intentionally permissive), but it does catch bytes + # routed through `repr()` or `__str__` of a `bytes` object that + # chardet recognises as a non-UTF8 encoding with low confidence. + chardet = _get_chardet() + if chardet is None: + return False + try: + detection = chardet.detect(text.encode("utf-8", errors="replace")) + confidence = float(detection.get("confidence") or 0.0) + except Exception as exc: # pragma: no cover - defensive + logger.debug("chardet detection failed: %s", exc) + return False + + return confidence < min_confidence + + @staticmethod + def _check_entropy_concern(text: str, params: Any) -> bool: + """Return True if Shannon entropy of `text` is outside an expected range. + + Stdlib-only. Entropy is computed in bits per symbol over byte + frequencies. English prose typically lands ~3.5–4.5 bits/byte; + binary noise approaches 8 bits/byte; constant/repetitive text + approaches 0. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + lo = float(params.get("min", 1.5)) + hi = float(params.get("max", 7.5)) + + data = text.encode("utf-8", errors="replace") + total = len(data) + if total == 0: + return False + + counts = Counter(data) + entropy = 0.0 + for c in counts.values(): + p = c / total + entropy -= p * math.log2(p) + + return entropy < lo or entropy > hi + + @staticmethod + def _check_incident_concern(text: str, params: Any) -> bool: + """Return True if `text` matches any configured incident pattern (A.8.4). + + Categories: safety_refusal, tool_failure, auth_failure, + quota_exceeded, hallucination. Pass ``{"categories": [...]}`` to + restrict; default scans all categories. + """ + if not text or not text.strip(): + return False + + if isinstance(params, dict): + requested = params.get("categories") + else: + requested = None + + if not requested: + categories = list(_INCIDENT_PATTERNS.keys()) + else: + categories = [c for c in requested if c in _INCIDENT_PATTERNS] + + for category in categories: + for pattern in _INCIDENT_PATTERNS[category]: + if pattern.search(text): + return True + return False + + @staticmethod + def _check_commitment_concern(text: str, params: Any) -> bool: + """Return True if `text` carries customer-commitment language (A.10.4). + + OR semantics: a commitment-verb match always fires; when + ``require_amount`` is true, a currency-anchored amount alone also + fires; when ``require_deadline`` is true, a deadline phrase alone + also fires. With both flags false the rule matches on verb only + (verb-only mode). + + The verb pattern covers first-person promise verbs *and* proposal + / SOW commitment markers ("Cost: $X", "fixed scope", + "Deliverables", "Timeline: N days", "I propose"). The amount + pattern requires a currency marker adjacent to the number so URL + fragments don't false-positive. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + require_amount = bool(params.get("require_amount", True)) + require_deadline = bool(params.get("require_deadline", False)) + + verb_match = bool(_COMMITMENT_VERB_PATTERN.search(text)) + + # Verb-only mode: neither supporting signal is enabled. + if not require_amount and not require_deadline: + return verb_match + + amount_match = require_amount and bool( + _COMMITMENT_AMOUNT_FALLBACK.search(text) + ) + deadline_match = require_deadline and bool( + _COMMITMENT_DEADLINE_PATTERN.search(text) + ) + return verb_match or amount_match or deadline_match diff --git a/src/uipath/runtime/governance/native/guardrail_compensation.py b/src/uipath/runtime/governance/native/guardrail_compensation.py new file mode 100644 index 0000000..04368ec --- /dev/null +++ b/src/uipath/runtime/governance/native/guardrail_compensation.py @@ -0,0 +1,380 @@ +"""Compensating governance for disabled centralized guardrails. + +When a ``guardrail_fallback`` rule fires (the guardrail is mapped to +UiPath but the centralized policy is disabled), the framework asks the +governance-server to run the real guardrail check via its +``/{org_id}/agenticgovernance_/api/v1/runtime/govern`` endpoint. + +This call is **fire-and-forget**: the server runs the guardrail AND +writes the audit trace from its side. The agent doesn't inspect the +response — it only cares about whether the call reached the server. + +The call also runs on a **bounded background pool** so even an agent +that fires hundreds of compensation events in a session can't pile up +threads or memory. :data:`COMPENSATION_MAX_WORKERS` workers process +the queue, and an in-flight semaphore drops submissions when the pool +is genuinely saturated — at that point the next call is logged and +skipped rather than queued indefinitely. + +URL composition, request headers, org/tenant resolution, and the +request timeout all come from +:mod:`uipath.runtime.governance.native.backend_client` so the policy +fetch and the compensating call share one definition of every +operator-tunable. +""" + +from __future__ import annotations + +import atexit +import json +import logging +import os +import threading +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from typing import Any, TypedDict + +from uipath.runtime.governance.native.backend_client import ( + BACKEND_REQUEST_TIMEOUT_SECONDS, + COMPENSATION_MAX_WORKERS, + ENV_ACCESS_TOKEN, + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + GOVERN_API_PATH, + TENANT_HEADER, + build_governance_url, + governance_request_headers, + resolve_job_context, + resolve_organization_id, + resolve_tenant_id, +) + +logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------------- +# Bounded thread pool — caps both concurrent threads AND queued work. +# +# ThreadPoolExecutor alone caps concurrent worker threads, but its internal +# queue is unbounded — a misbehaving agent that fires compensation faster than +# the server can absorb would queue indefinitely (memory pressure). The +# semaphore caps total in-flight submissions (running + queued) at a +# multiple of the worker count. Saturated submissions are dropped with a +# warning. Process exit cancels queued work and lets running tasks finish +# (bounded by their HTTP timeout) via the atexit handler. +# ---------------------------------------------------------------------------- + +_INFLIGHT_OVERSUBSCRIPTION = 4 # queue up to (workers × this many) before dropping +_INFLIGHT_CAP = COMPENSATION_MAX_WORKERS * _INFLIGHT_OVERSUBSCRIPTION + +_pool = ThreadPoolExecutor( + max_workers=COMPENSATION_MAX_WORKERS, + thread_name_prefix="governance-compensation", +) +_inflight = threading.BoundedSemaphore(_INFLIGHT_CAP) + + +@atexit.register +def _shutdown_pool() -> None: + """Cancel queued compensation tasks at process exit. + + ``wait=False`` returns immediately so process shutdown isn't held + up; ``cancel_futures=True`` (Python 3.9+) drops anything not yet + running. Tasks already running finish bounded by their HTTP + timeout (``BACKEND_REQUEST_TIMEOUT_SECONDS``). + """ + try: + _pool.shutdown(wait=False, cancel_futures=True) + except Exception: # noqa: BLE001 - shutdown must never raise from atexit + pass + + +# ---------------------------------------------------------------------------- +# Public API +# ---------------------------------------------------------------------------- + + +class FiredRule(TypedDict): + """Per-rule metadata carried in the /runtime/govern payload. + + One entry per matching ``guardrail_fallback`` condition (in practice + one per rule, since each fallback-rule typically declares a single + such condition). The server uses these to write per-rule LLMOps + trace records (Doc-2 audit structure). + """ + + ruleId: str + ruleName: str + packName: str + validator: str + + +def disabled_guardrails(audit: Any, policy_index: Any) -> list[FiredRule]: + """Return per-rule metadata for each fired guardrail-fallback rule. + + A guardrail rule fires only when it is mapped to UiPath + (``mapped_to_uipath`` true) but disabled (``policy_enabled`` false) — + see the ``guardrail_fallback`` operator. The validator name (e.g. + ``pii_detection``) is read from the rule's ``guardrail_fallback`` + check config and used as the ``type`` of the compensating call. + + One :class:`FiredRule` entry is emitted per matching + ``guardrail_fallback`` condition. Rules in this codebase declare a + single fallback condition each, so the returned list has one entry + per fired rule in practice; multi-condition rules would emit more + than one entry sharing the same ``ruleId``. + """ + out: list[FiredRule] = [] + for ev in audit.evaluations: + if not ev.matched: + continue + rule = policy_index.get_rule(ev.rule_id) + if rule is None: + continue + for check in rule.checks: + for cond in check.conditions: + if cond.operator != "guardrail_fallback": + continue + if not isinstance(cond.value, dict): + continue + # The ``guardrail_fallback`` operator at evaluation time + # only matches when ``mapped_to_uipath=True`` AND + # ``policy_enabled=False``. We re-check here defensively + # so a future code path that bypasses the evaluator (or + # a multi-condition rule that fired on a sibling check) + # can't trigger a compensation call for a guardrail + # that isn't actually disabled. + if not bool(cond.value.get("mapped_to_uipath", False)): + continue + if bool(cond.value.get("policy_enabled", True)): + continue + validator = str(cond.value.get("validator", "")) + if validator: + out.append( + { + "ruleId": ev.rule_id, + "ruleName": ev.rule_name, + "packName": getattr(rule, "pack_name", "") or "", + "validator": validator, + } + ) + return out + + +def _validators(rules: list[FiredRule]) -> list[str]: + """Distinct validator names from the fired rules, preserving order.""" + return list(dict.fromkeys(r["validator"] for r in rules if r.get("validator"))) + + +def submit_compensation( + rules: list[FiredRule], + data: dict[str, Any], + hook: str, + trace_id: str, + src_timestamp: str, + agent_name: str, + runtime_id: str, +) -> None: + """Schedule a /runtime/govern call on the bounded background pool. + + Fire-and-forget. Returns immediately; the call runs on a worker + thread bounded by :data:`COMPENSATION_MAX_WORKERS`. When the + in-flight queue is saturated (cap = workers × oversubscription), + the call is dropped with a warning and the agent continues. + + ``rules`` is the per-rule metadata from :func:`disabled_guardrails`; + the validators sent to the guardrail API are derived from it. + + Never raises — including when the pool has already been shut down + by process exit. + """ + if not rules: + return + + validators = _validators(rules) + if not validators: + return + + if not _inflight.acquire(blocking=False): + logger.warning( + "Compensation pool saturated (>%d in flight); dropping call " + "(validators=[%s])", + _INFLIGHT_CAP, + ", ".join(validators), + ) + return + + def _run() -> None: + try: + request_governance( + rules=rules, + data=data, + hook=hook, + trace_id=trace_id, + src_timestamp=src_timestamp, + agent_name=agent_name, + runtime_id=runtime_id, + ) + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning( + "Compensation worker failed (validators=[%s]): %s", + ", ".join(validators), + exc, + ) + finally: + _inflight.release() + + try: + _pool.submit(_run) + except RuntimeError as exc: + # Pool was shut down (atexit or test teardown) — release the + # semaphore slot we took and log; never raise. + _inflight.release() + logger.warning( + "Compensation pool unavailable (validators=[%s]): %s", + ", ".join(validators), + exc, + ) + + +def request_governance( + rules: list[FiredRule], + data: dict[str, Any], + hook: str, + trace_id: str, + src_timestamp: str, + agent_name: str, + runtime_id: str, +) -> None: + """Synchronous POST to the org-scoped ``/runtime/govern`` endpoint. + + Most callers should use :func:`submit_compensation` to run this on + the bounded background pool. ``request_governance`` is exposed + directly only for callers that already manage their own + concurrency (and for tests). + + POSTs:: + + { + "type": ["pii_detection", "harmful_content"], + "rules": [ + {"ruleId": "...", "ruleName": "...", + "packName": "...", "validator": "pii_detection"} + ], + "data": {...}, + "hook": "before_model", + "traceId": "...", + "src_timestamp": "...", + "agentName": "...", + "runtimeId": "...", + "folderKey": "...", "jobKey": "...", "processKey": "...", + "referenceId": "...", "agentVersion": "..." + } + + ``type`` (the distinct validators) drives the guardrail API call; + ``rules`` + the job-context fields let the server write one LLMOps + trace record per rule (Doc-2 audit structure). The job-context keys + are included only when resolvable from ``UiPathConfig`` / env. + + Skipped if the org or tenant id can't be resolved (no URL / no + header). The server runs the disabled guardrails AND writes the + audit trace itself — the agent does not consume or parse the + response body. The only thing this function reports back is + *whether the call landed*: + + - **Success** → ``INFO`` log ``Govern call has been made``. + - **Failure** → ``WARNING`` log; returns ``None``. + + Never raises. + """ + if not rules: + return + + validators = _validators(rules) + if not validators: + return + + org_id = resolve_organization_id() + if not org_id: + logger.warning( + "Govern call skipped: UiPathConfig.organization_id is not " + "available (set %s or ensure uipath-platform is installed). " + "validators=[%s]", + ENV_ORGANIZATION_ID, + ", ".join(validators), + ) + return + + tenant_id = resolve_tenant_id() + if not tenant_id: + logger.warning( + "Govern call skipped: UiPathConfig.tenant_id is not " + "available (set %s or ensure uipath-platform is installed). " + "validators=[%s]", + ENV_TENANT_ID, + ", ".join(validators), + ) + return + + # Bearer token is required by the backend; sending without one + # produces a 401 per call and pollutes logs. Skip cleanly when the + # token isn't present (e.g. local dev, missing host bootstrap) + # rather than burning quota on guaranteed auth failures. + if not os.environ.get(ENV_ACCESS_TOKEN): + logger.warning( + "Govern call skipped: %s is not set in the environment; " + "compensation requires a bearer token. validators=[%s]", + ENV_ACCESS_TOKEN, + ", ".join(validators), + ) + return + + try: + payload = json.dumps( + { + "type": validators, + "rules": rules, + "data": data, + "hook": hook, + "traceId": trace_id, + "src_timestamp": src_timestamp, + "agentName": agent_name, + "runtimeId": runtime_id, + **resolve_job_context(), + }, + default=str, # coerce any non-JSON-native value safely + ).encode("utf-8") + except Exception as exc: # noqa: BLE001 - fail-open + logger.warning( + "Govern call payload serialization failed (validators=[%s]): %s", + ", ".join(validators), + exc, + ) + return + + url = build_governance_url(org_id, GOVERN_API_PATH) + headers = governance_request_headers(json_body=True) + headers[TENANT_HEADER] = tenant_id + + request = urllib.request.Request( + url, + data=payload, + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen( # noqa: S310 - URL is built from config + request, timeout=BACKEND_REQUEST_TIMEOUT_SECONDS + ) as response: + logger.info( + "Govern call has been made (status=%s, validators=[%s])", + getattr(response, "status", "?"), + ", ".join(validators), + ) + except Exception as exc: # noqa: BLE001 - fail-and-log + logger.warning( + "Govern call failed (validators=[%s]): %s", + ", ".join(validators), + exc, + ) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py new file mode 100644 index 0000000..e2fd138 --- /dev/null +++ b/src/uipath/runtime/governance/native/loader.py @@ -0,0 +1,340 @@ +"""Policy pack loader. + +Resolves the active PolicyIndex at startup. Policies are fetched +exclusively from the governance backend (``api/v1/policy``); there is +no local compiled fallback. When the backend is unavailable, the +access token is unset, or the fetch times out, the loader returns an +empty PolicyIndex and the agent runs without any rules. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from collections import Counter + +import yaml +from uipath.core.governance.config import is_governance_enabled + +from uipath.runtime.governance.config import EnforcementMode, set_enforcement_mode +from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml +from uipath.runtime.governance.native.backend_client import ENV_ACCESS_TOKEN +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.native.policy_api_client import ( + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + POLICY_API_TIMEOUT_SECONDS, + fetch_policy_response, + resolve_organization_id, + resolve_tenant_id, +) + +logger = logging.getLogger(__name__) + +# Pack name aliases for backward compatibility +PACK_ALIASES: dict[str, str] = { + "owasp": "owasp_agentic", + "hipaa": "hipaa_runtime", + "soc2": "soc2_runtime", + "nist": "nist_ai_rmf_runtime", + "eu_ai": "eu_ai_act_runtime", + "iso": "iso42001_runtime", +} + + +# Module-level cache +_policy_index: PolicyIndex | None = None + +# Background-prefetch coordination. ``_prefetch_event`` is set once the +# background load_policy_index() call finishes (success OR failure); +# callers of ``get_policy_index()`` wait on it. ``_prefetch_lock`` +# protects the start-once semantics so concurrent ``prefetch`` calls +# don't kick off duplicate threads. +_prefetch_event: threading.Event | None = None +_prefetch_lock = threading.Lock() + +# Default wait when ``get_policy_index()`` blocks on an in-flight +# prefetch. Matched to the policy-API HTTP timeout so a stuck backend +# bounds the total time spent waiting at first hook fire to +# ~POLICY_API_TIMEOUT_SECONDS. If the wait expires we return an empty +# PolicyIndex — the agent runs without any policies rather than +# blocking further or retrying. +_PREFETCH_WAIT_SECONDS = POLICY_API_TIMEOUT_SECONDS + + +def prefetch_policy_index() -> None: + """Kick off a background load of the policy index. + + Non-blocking. Designed to be called as early as possible (at + ``GovernanceRuntime.__init__``) so the HTTP call to the governance + backend overlaps with the rest of agent setup. The result lands in + the same module cache that ``get_policy_index()`` reads from; + ``get_policy_index()`` waits on this prefetch when it's in flight. + + Idempotent: subsequent calls while the first is running are no-ops, + and calls after completion are no-ops. Skipped entirely when the + governance feature flag is OFF so no network call is made. + """ + global _prefetch_event + + if not is_governance_enabled(): + return + + with _prefetch_lock: + if _policy_index is not None: + return # already loaded + if _prefetch_event is not None: + return # already in flight + event = threading.Event() + _prefetch_event = event + + def _worker() -> None: + global _policy_index + try: + loaded = load_policy_index() + except Exception as exc: # noqa: BLE001 - logged; first hook will retry sync + logger.warning("Policy prefetch failed: %s", exc) + else: + with _prefetch_lock: + _policy_index = loaded + finally: + event.set() + + threading.Thread( + target=_worker, + name="governance-policy-prefetch", + daemon=True, + ).start() + + +def get_policy_index() -> PolicyIndex: + """Get the cached policy index, loading if necessary. + + Resolution order on first call: + 1. If the governance feature flag is OFF, return an empty + PolicyIndex (cached). No network call. + 2. If a prefetch (see :func:`prefetch_policy_index`) is in flight, + wait for it to complete (bounded by ``_PREFETCH_WAIT_SECONDS``). + 3. Governance backend at ``api/v1/policy`` (one HTTP GET, cached). + 4. Empty PolicyIndex when the backend is unavailable or times out. + + Result is cached for the process lifetime; per-hook evaluation never + touches the network. Call :func:`clear_policy_cache` to force a + refetch (mainly for tests). + """ + global _policy_index + + if _policy_index is not None: + return _policy_index + + if not is_governance_enabled(): + logger.info( + "Governance feature flag is OFF; returning empty PolicyIndex. " + "No rules will fire. Set EnablePythonGovernanceChecker=True to enable." + ) + _policy_index = PolicyIndex() + return _policy_index + + event = _prefetch_event + if event is not None: + completed = event.wait(timeout=_PREFETCH_WAIT_SECONDS) + if completed and _policy_index is not None: + return _policy_index + if not completed: + logger.warning( + "Policy prefetch did not complete in %.1fs; " + "agent will run without any policies", + _PREFETCH_WAIT_SECONDS, + ) + else: + # Distinguish from the timeout path so production triage + # can tell "prefetch hung" from "prefetch returned empty" + # (auth failure, server error, parse failure). + logger.warning( + "Policy prefetch completed but produced no PolicyIndex " + "(see prior WARN for the root cause); agent will run " + "without any policies" + ) + _policy_index = PolicyIndex() + return _policy_index + + # No prefetch was started (direct callers / tests). Sync load — bounded + # by the HTTP timeout in the API client. + _policy_index = load_policy_index() + return _policy_index + + +def load_policy_index(pack_name: str | None = None) -> PolicyIndex: + """Load the active PolicyIndex from the governance backend. + + Args: + pack_name: Ignored. Pack selection is controlled entirely by the + backend. + + Returns: + PolicyIndex parsed from the backend response. Empty PolicyIndex + when the backend is unavailable, the token is unset, the YAML + is malformed, or the response yields zero rules. + """ + start = time.perf_counter() + + api_index = _load_from_api() + if api_index is not None: + _log_index_summary(api_index) + logger.info( + "Policy index ready: source=backend, total_ms=%.1f", + (time.perf_counter() - start) * 1000, + ) + return api_index + + reason = _empty_index_reason() + logger.info( + "Policy index ready: source=empty (%s), total_ms=%.1f", + reason, + (time.perf_counter() - start) * 1000, + ) + return PolicyIndex() + + +def _empty_index_reason() -> str: + """Diagnose why the policy fetch produced nothing.""" + if not resolve_organization_id(): + return ( + f"UiPathConfig.organization_id unavailable — set {ENV_ORGANIZATION_ID} " + "or install uipath-platform; backend API not contacted" + ) + if not resolve_tenant_id(): + return ( + f"UiPathConfig.tenant_id unavailable — set {ENV_TENANT_ID} " + "or install uipath-platform; backend API not contacted" + ) + if not os.environ.get(ENV_ACCESS_TOKEN): + return f"{ENV_ACCESS_TOKEN} unset — backend API not contacted" + return "backend returned no policies (timeout / error / empty body)" + + +def _apply_enforcement_mode(mode_str: str | None) -> None: + """Map a backend-supplied mode string onto :class:`EnforcementMode`. + + Unknown values log a warning and leave the existing mode untouched. + """ + if not mode_str: + return + try: + mode = EnforcementMode(mode_str.lower()) + except ValueError: + logger.warning( + "Backend returned unknown enforcement mode %r; keeping current mode", + mode_str, + ) + return + set_enforcement_mode(mode) + logger.info("Enforcement mode set from backend: %s", mode.value) + + +def _load_from_api() -> PolicyIndex | None: + """Fetch and parse the policy index from the governance backend. + + Applies the backend-supplied enforcement mode as a side effect. + Returns ``None`` when the backend skips/errors, when the YAML is + malformed, or when the resulting index has no rules — caller returns + an empty PolicyIndex in those cases. + """ + start = time.perf_counter() + response = fetch_policy_response() + if response is None: + return None + + # Apply the platform-controlled enforcement mode before building the + # index, so anything that reads ``get_enforcement_mode()`` during + # index compilation already sees the right value. + _apply_enforcement_mode(response.mode) + + if not response.policy: + logger.warning( + "Policy fetch returned empty policy field; " + "agent will run without any policies" + ) + return None + + try: + index = build_policy_index_from_yaml(response.policy) + except yaml.YAMLError as exc: + logger.warning("Policy YAML from backend was malformed: %s", exc) + return None + except Exception as exc: # noqa: BLE001 - never let load break agent startup + logger.warning("Failed to build PolicyIndex from backend YAML: %s", exc) + return None + + if index.total_rules == 0: + logger.warning( + "Policy YAML from backend yielded zero rules; " + "agent will run without any policies" + ) + return None + + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info( + "Loaded policy index from backend: packs=%s, rules=%d, elapsed_ms=%.1f", + index.pack_names, + index.total_rules, + elapsed_ms, + ) + return index + + +def _backend_base_url() -> str: + """Return the backend base URL for logging; imported lazily to avoid cycles.""" + try: + from uipath.runtime.governance.native.backend_client import ( + get_backend_base_url, + ) + + return get_backend_base_url() + except Exception: # noqa: BLE001 + return "backend" + + +def _log_index_summary(index: PolicyIndex) -> None: + """Log summary of loaded policy index.""" + # Count rules by hook + hook_counts: Counter[str] = Counter() + for rule in index.all_rules: + hook_counts[rule.hook.value] += 1 + + logger.debug( + "Policy packs: %s, total rules: %d, by hook: %s", + index.pack_names, + index.total_rules, + dict(hook_counts), + ) + + +def get_available_packs() -> list[str]: + """Get list of pack names from the currently loaded policy index. + + Returns whatever the backend supplied on the most recent load. + Empty list if no index has been loaded yet or the backend yielded + no packs. + """ + if _policy_index is None: + return [] + return _policy_index.pack_names + + +def clear_policy_cache() -> None: + """Clear the cached policy index and any in-flight prefetch state. + + Next call to ``get_policy_index()`` will refetch from the backend. + """ + global _policy_index, _prefetch_event + with _prefetch_lock: + _policy_index = None + _prefetch_event = None + logger.debug("Policy index cache cleared") + + +# Backward compatibility alias +reset_policy_index = clear_policy_cache diff --git a/src/uipath/runtime/governance/native/models.py b/src/uipath/runtime/governance/native/models.py new file mode 100644 index 0000000..d021d81 --- /dev/null +++ b/src/uipath/runtime/governance/native/models.py @@ -0,0 +1,153 @@ +"""Native policy model. + +Rules, checks, conditions and pack indexes consumed by +:class:`uipath.runtime.governance.native.evaluator.GovernanceEvaluator`. + +These are the inputs of the native evaluator. The evaluator-agnostic +*output* types (``Action``, ``AuditRecord``, …) live in +:mod:`uipath.core.governance.models`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from uipath.core.governance.models import Action, LifecycleHook + + +class Severity(Enum): + """Rule severity levels.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +@dataclass +class Condition: + """A single condition within a rule check.""" + + operator: str + field: str + value: Any + negate: bool = False + + +@dataclass +class Check: + """A check within a rule - contains conditions and action.""" + + conditions: list[Condition] + action: Action = Action.DENY + message: str = "" + logic: str = "all" # "all" (AND) or "any" (OR) + + +@dataclass +class Rule: + """A compliance rule with checks evaluated at a specific lifecycle hook.""" + + rule_id: str + name: str + clause: str + hook: LifecycleHook + action: Action + severity: Severity = Severity.HIGH + checks: list[Check] = field(default_factory=list) + enabled: bool = True + description: str = "" + pack_name: str = "" + + # Approval configuration (for ESCALATE action) + approval_config: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class CheckContext: + """Context passed to rule evaluation.""" + + hook: LifecycleHook + agent_name: str + runtime_id: str + trace_id: str + + # Content fields (populated based on hook) + agent_input: str = "" + agent_output: str = "" + model_input: str = "" + model_output: str = "" + model_name: str = ( + "" # LLM model name (e.g., "gpt-4", "claude-3-opus") - available at agent start + ) + tool_name: str = "" + tool_args: dict[str, Any] = field(default_factory=dict) + tool_result: str = "" + messages: list[dict[str, Any]] = field(default_factory=list) + + # Session state + session_state: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + # Ring level (privilege level: 0=system, 1=admin, 2=user, 3=untrusted) + ring: int = 2 + + +@dataclass +class PolicyPack: + """A collection of rules for a compliance standard.""" + + name: str + version: str + description: str + rules: list[Rule] + enabled: bool = True + + +@dataclass +class PolicyIndex: + """Index of all loaded policy packs and rules.""" + + packs: dict[str, PolicyPack] = field(default_factory=dict) + _rules_by_id: dict[str, Rule] = field(default_factory=dict) + _rules_by_hook: dict[LifecycleHook, list[Rule]] = field(default_factory=dict) + + def add_pack(self, pack: PolicyPack) -> None: + """Add a policy pack to the index.""" + self.packs[pack.name] = pack + for rule in pack.rules: + rule.pack_name = pack.name + self._rules_by_id[rule.rule_id] = rule + if rule.hook not in self._rules_by_hook: + self._rules_by_hook[rule.hook] = [] + self._rules_by_hook[rule.hook].append(rule) + + def get_rule(self, rule_id: str) -> Rule | None: + """Get a rule by ID.""" + return self._rules_by_id.get(rule_id) + + def get_rules_for_hook(self, hook: LifecycleHook) -> list[Rule]: + """Get all rules for a lifecycle hook.""" + return self._rules_by_hook.get(hook, []) + + def get_rules_for_pack(self, pack_name: str) -> list[Rule]: + """Get all rules for a pack.""" + pack = self.packs.get(pack_name) + return pack.rules if pack else [] + + @property + def pack_names(self) -> list[str]: + """Get all pack names.""" + return list(self.packs.keys()) + + @property + def total_rules(self) -> int: + """Get total number of rules.""" + return len(self._rules_by_id) + + @property + def all_rules(self) -> list[Rule]: + """Get all rules.""" + return list(self._rules_by_id.values()) diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py new file mode 100644 index 0000000..325b4e0 --- /dev/null +++ b/src/uipath/runtime/governance/native/policy_api_client.py @@ -0,0 +1,227 @@ +"""Governance policy API client. + +Fetches the governance backend response so policies can be controlled +centrally without redeploying agents. Called once at process startup +from :mod:`uipath.runtime.governance.native.loader`; per-hook evaluation +stays in-process. + +Response shape (JSON):: + + { + "mode": "audit" | "enforce" | "disabled", + "policies": "" + } + +``mode`` is the platform-controlled enforcement mode for the tenant; +the loader applies it via +:func:`uipath.runtime.governance.config.set_enforcement_mode`. ``policies`` +is the YAML the evaluator compiles into a :class:`PolicyIndex`. + +Failure mode is fail-open: when the organization id is unknown, the +access token is missing, the backend errors, or the body can't be +parsed, the caller falls back to an empty PolicyIndex. The fetch is +single-shot (no retry by design — see :func:`_get_once`) so a slow +backend can't extend agent startup beyond +:data:`BACKEND_REQUEST_TIMEOUT_SECONDS`. Nothing in this module ever +raises to the caller. +""" + +from __future__ import annotations + +import json +import logging +import os +import urllib.error +import urllib.request +from dataclasses import dataclass +from urllib.parse import urlencode + +from uipath.runtime.governance.native.backend_client import ( + AGENT_TYPE_PARAM, + BACKEND_REQUEST_TIMEOUT_SECONDS, + ENV_ACCESS_TOKEN, + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + POLICY_API_PATH, + TENANT_HEADER, + agent_type_param, + build_governance_url, + governance_request_headers, + resolve_organization_id, + resolve_tenant_id, +) + +logger = logging.getLogger(__name__) + +# Re-exported alias kept for callers that imported the old name. +POLICY_API_TIMEOUT_SECONDS = BACKEND_REQUEST_TIMEOUT_SECONDS + + +@dataclass(frozen=True) +class PolicyResponse: + """Parsed governance backend response. + + Attributes: + mode: Enforcement mode string the backend returned + (``"audit"`` / ``"enforce"`` / ``"disabled"``), or ``None`` + when the backend omitted it. Loader applies this via + :func:`uipath.runtime.governance.config.set_enforcement_mode`. + policy: Policy pack YAML to compile into a ``PolicyIndex``. May + be an empty string if the backend returned no rules. + """ + + mode: str | None + policy: str + + +def build_policy_url(org_id: str) -> str: + """Build the policy endpoint URL for the given organization id. + + The tenant id is not part of the URL; it travels in the + ``x-uipath-internal-tenantid`` request header (see + :func:`fetch_policy_response`). + + When the hosted agent's type is known (see + :func:`uipath.runtime.governance.native.backend_client.set_agent_conversational`), + an ``agentType`` query param is appended so the server resolves the + conversational-vs-autonomous container key. Omitted when unknown — the + server then applies its default. + """ + url = build_governance_url(org_id, POLICY_API_PATH) + agent_type = agent_type_param() + if agent_type: + url = f"{url}?{urlencode({AGENT_TYPE_PARAM: agent_type})}" + return url + + +def fetch_policy_response() -> PolicyResponse | None: + """Fetch the governance backend's policy response. + + Single shot, no retry: a failed fetch (timeout / network error / + HTTP error / malformed body) returns ``None`` and the caller falls + back to an empty PolicyIndex. The agent must not spend time on a + second attempt — keeping governance off the critical path is more + important than maximising policy availability. + + Returns: + :class:`PolicyResponse` on success. ``None`` on any failure + path — caller falls back to an empty PolicyIndex. + + Never raises. + """ + try: + return _fetch_policy_response_inner() + except Exception as exc: # noqa: BLE001 - loader path must never raise + logger.warning("Policy fetch failed unexpectedly: %s", exc) + return None + + +def _fetch_policy_response_inner() -> PolicyResponse | None: + org_id = resolve_organization_id() + if not org_id: + logger.warning( + "Policy fetch skipped: UiPathConfig.organization_id is not " + "available (set %s in the environment, or ensure uipath-platform " + "is installed); governance will run with no policies. The " + "backend API was NOT contacted.", + ENV_ORGANIZATION_ID, + ) + return None + + tenant_id = resolve_tenant_id() + if not tenant_id: + logger.warning( + "Policy fetch skipped: UiPathConfig.tenant_id is not " + "available (set %s in the environment, or ensure uipath-platform " + "is installed); governance will run with no policies. The " + "backend API was NOT contacted.", + ENV_TENANT_ID, + ) + return None + + policy_url = build_policy_url(org_id) + + token = os.environ.get(ENV_ACCESS_TOKEN) + if not token: + logger.warning( + "Policy fetch skipped: %s is not set in the environment; " + "governance will run with no policies.", + ENV_ACCESS_TOKEN, + ) + return None + + # Policy fetch is a GET; ``json_body=False`` so ``Content-Type`` is + # omitted. Strict origin servers may 415 on unexpected Content-Type + # for GETs (see :func:`governance_request_headers` docstring). + headers = governance_request_headers(json_body=False) + headers[TENANT_HEADER] = tenant_id + logger.info("Policy fetch starting (org=%s, tenant=%s)", org_id, tenant_id) + + body = _get_once(policy_url, headers) + if body is None: + return None + return _parse_policy_body(body) + + +def _get_once(url: str, headers: dict[str, str]) -> bytes | None: + """GET ``url`` once. Returns body bytes, or ``None`` on any failure. + + No retry by design — see :func:`fetch_policy_response` for the + rationale. Every failure path logs a single WARNING and returns + ``None`` so the caller (the loader) falls back to an empty + PolicyIndex without delay. + """ + request = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen( # noqa: S310 - URL is built from config + request, timeout=BACKEND_REQUEST_TIMEOUT_SECONDS + ) as response: + return response.read() + except urllib.error.HTTPError as exc: + logger.warning("Policy fetch returned HTTP %d: %s", exc.code, exc) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + logger.warning("Policy fetch failed: %s", exc) + return None + + +def _parse_policy_body(body: bytes) -> PolicyResponse | None: + """Parse the JSON envelope into a :class:`PolicyResponse`.""" + if not body: + logger.warning("Policy fetch returned empty body") + return None + + try: + payload = json.loads(body.decode("utf-8")) + except UnicodeDecodeError as exc: + logger.warning("Policy fetch returned non-UTF8 body: %s", exc) + return None + except json.JSONDecodeError as exc: + logger.warning( + "Policy fetch returned malformed JSON " + "(server may have returned an HTML error page): %s", + exc, + ) + return None + + if not isinstance(payload, dict): + logger.warning( + "Policy fetch returned unexpected JSON shape (expected object, got %s)", + type(payload).__name__, + ) + return None + + raw_mode = payload.get("mode") + mode = raw_mode if isinstance(raw_mode, str) and raw_mode else None + + raw_policy = payload.get("policies", "") + if not isinstance(raw_policy, str): + logger.warning( + "Policy fetch returned non-string 'policies' field (got %s)", + type(raw_policy).__name__, + ) + return None + + logger.info( + "Policy fetch ok: mode=%s, policy_bytes=%d", mode, len(raw_policy) + ) + return PolicyResponse(mode=mode, policy=raw_policy) diff --git a/src/uipath/runtime/governance/wrapper.py b/src/uipath/runtime/governance/wrapper.py new file mode 100644 index 0000000..1292174 --- /dev/null +++ b/src/uipath/runtime/governance/wrapper.py @@ -0,0 +1,978 @@ +"""SDK runtime wrapper integration with adapter-based framework support. + +This module provides the wrapper function that integrates with the +UiPath Runtime SDK's UiPathRuntimeWrapperRegistry. + +Architecture: + The wrapper automatically detects and wraps agents using framework-specific + adapters. This provides governance at all lifecycle hooks: + + - BEFORE_AGENT / AFTER_AGENT: Intercepted at runtime level in execute()/stream() + - BEFORE_MODEL / AFTER_MODEL / TOOL_CALL: Via framework-specific adapters + + Agent Detection Flow: + 1. GovernanceRuntime receives delegate runtime from SDK + 2. Extracts agent from delegate (looks for _agent, agent, _runnable, etc.) + 3. Uses AdapterRegistry.resolve() to find matching adapter + 4. Calls adapter.attach() to wrap agent with governance hooks + 5. Replaces original agent in delegate with governed version + + Supported Frameworks (via adapters): + - LangChain / LangGraph + - Microsoft AutoGen + - CrewAI + - LlamaIndex + - OpenAI Agents SDK + - PydanticAI + - Microsoft Semantic Kernel + - HuggingFace smolagents +""" + +from __future__ import annotations + +import logging +import threading +from contextvars import ContextVar, Token +from typing import Any, AsyncGenerator, Dict, Optional +from uuid import uuid4 + +from uipath.core.adapters import get_adapter_registry +from uipath.core.governance.config import is_governance_enabled +from uipath.core.governance.exceptions import GovernanceBlockException + +from uipath.runtime.base import UiPathRuntimeProtocol +from uipath.runtime.governance.config import EnforcementMode, get_enforcement_mode +from uipath.runtime.governance.delegation_guard import ( + install_delegation_guard, + uninstall_delegation_guard, +) +from uipath.runtime.governance.native.backend_client import set_agent_conversational +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator +from uipath.runtime.governance.native.loader import ( + get_policy_index, + prefetch_policy_index, +) +from uipath.runtime.result import UiPathRuntimeResult + +logger = logging.getLogger(__name__) + +# Per-call-context model name set by GovernanceRuntime, read by adapters. +# A ContextVar keeps concurrent agent runs from stomping each other's +# value across threads and asyncio tasks. +_current_model_name: ContextVar[str] = ContextVar( + "_uipath_governance_current_model_name", default="" +) + + +def get_current_model_name() -> str: + """Get the model name captured from the runtime context.""" + return _current_model_name.get() + + +# Content keys we prioritize when walking dict-shaped agent payloads. +# Covers chat-message shapes (``{"content": "..."}``, ``{"text": "..."}``), +# the plural ``messages`` list (LangGraph state), and the OpenAI +# function-call shape (``{"arguments": ""}``). Any remaining keys +# are walked after these so the actual reply / latest user message leads +# the extracted blob. +_GOVERNANCE_CONTENT_KEYS: tuple[str, ...] = ( + "content", + "text", + "output", + "answer", + "messages", # plural — chat history list in LangGraph-style state + "message", + "result", + "arguments", + "thinking", +) + +# Total cap on the extracted governance-text blob. Sized for multi-turn +# chat where each turn can produce a couple KB and the latest content +# must fit even when the conversation history is long. Bounded so a +# runaway nested payload can't blow memory or dominate regex scan time. +_GOVERNANCE_TEXT_CAP = 64000 + +# Depth cap for the recursive walk. Anything beyond this is almost +# certainly framework plumbing, not user-facing content. +_GOVERNANCE_TEXT_MAX_DEPTH = 10 + + +def _extract_governable_text( + value: Any, + *, + budget: int = _GOVERNANCE_TEXT_CAP, + seen: set[int] | None = None, + depth: int = 0, + latest_only: bool = False, +) -> str: + """Pull governance-relevant text out of an arbitrary runtime payload. + + Replaces the prior ``str(value)[:2000]`` shortcut, which produced + ``"{'content': '...'}"``-style garbled prefixes for structured + outputs and ate into the budget with dict-syntax noise. Walks dicts + (prioritising :data:`_GOVERNANCE_CONTENT_KEYS`), lists, pydantic + models, and plain objects up to :data:`_GOVERNANCE_TEXT_MAX_DEPTH`, + joining text fragments with newlines. Non-text scalars and unknown + block types contribute nothing. Cycles and over-deep nesting are + skipped silently. + + **List ordering:** lists are walked in reverse, so the most recent + entry (latest chat-history message, latest assistant content block) + gets first claim on the budget. Long conversation histories no + longer crowd the latest user message out of the scanned blob. + + **latest_only:** when True (used by BEFORE_AGENT in conversational + agents), only the last item of any list is extracted — chat history + is reduced to the most recent message. The flag resets to False + when recursing into the chosen item so multi-block content within + that message is still fully extracted. + """ + if value is None or budget <= 0 or depth > _GOVERNANCE_TEXT_MAX_DEPTH: + return "" + if isinstance(value, str): + return value[:budget] + if isinstance(value, (bool, int, float)): + # Numeric / boolean scalars aren't governance text — skip them + # so dict walks don't pad the blob with ints / flags. + return "" + + # Pydantic / dataclass-like shapes are easier to walk via their + # dict form than via attribute introspection. If the first dumper + # raises (e.g. ``model_dump`` blows up on a partial pydantic v1 + # model), fall through to the next one rather than abandoning the + # whole pydantic/dataclass path. + for dumper in ("model_dump", "dict"): + fn = getattr(value, dumper, None) + if callable(fn): + try: + return _extract_governable_text( + fn(), + budget=budget, + seen=seen, + depth=depth + 1, + latest_only=latest_only, + ) + except Exception: # noqa: BLE001 - try the next dumper + continue + + obj_id = id(value) + if seen is None: + seen = set() + if obj_id in seen: + return "" + if isinstance(value, dict): + seen.add(obj_id) + # Walk recognized content keys first so the actual reply leads + # the extracted blob; any remaining keys follow. + keys: list[Any] = [k for k in _GOVERNANCE_CONTENT_KEYS if k in value] + keys.extend(k for k in value if k not in _GOVERNANCE_CONTENT_KEYS) + parts: list[str] = [] + remaining = budget + for key in keys: + if remaining <= 0: + break + piece = _extract_governable_text( + value[key], + budget=remaining, + seen=seen, + depth=depth + 1, + latest_only=latest_only, + ) + if piece: + parts.append(piece) + remaining -= len(piece) + 1 # +1 accounts for the newline + return "\n".join(parts) + if isinstance(value, (list, tuple)): + seen.add(obj_id) + # Reverse so the latest entry (new user message in conversation + # history, latest assistant content block) gets the budget first. + # When the caller asked for ``latest_only`` (BEFORE_AGENT in a + # conversational agent), stop after the first reversed item — + # i.e., evaluate only the latest input, not the whole history. + items = list(reversed(value)) + if latest_only: + items = items[:1] + parts = [] + remaining = budget + for item in items: + if remaining <= 0: + break + # Reset latest_only when recursing into the chosen item so + # multi-block content inside the latest message is walked + # in full (text + tool_use + thinking blocks all extracted). + piece = _extract_governable_text( + item, + budget=remaining, + seen=seen, + depth=depth + 1, + latest_only=False, + ) + if piece: + parts.append(piece) + remaining -= len(piece) + 1 + return "\n".join(parts) + + # Last-resort: walk public attributes on opaque objects (e.g. a + # framework-specific result class without model_dump/dict). + public = { + name: getattr(value, name) + for name in dir(value) + if not name.startswith("_") and not callable(getattr(value, name, None)) + } + if public: + return _extract_governable_text( + public, + budget=budget, + seen=seen, + depth=depth + 1, + latest_only=latest_only, + ) + return "" + + +class GovernanceRuntime: + """Runtime wrapper that adds governance evaluation at the runtime level. + + Automatically detects and wraps agents using the AdapterRegistry: + - LangChain / LangGraph + - Microsoft AutoGen + - CrewAI + - LlamaIndex + - OpenAI Agents SDK + - PydanticAI + - Microsoft Semantic Kernel + - HuggingFace smolagents + + Boundary hooks (BEFORE_AGENT, AFTER_AGENT) are handled at the runtime level. + Inner hooks (BEFORE_MODEL, TOOL_CALL, etc.) are handled by framework adapters + which are automatically attached to the agent when the runtime is created. + """ + + # Attributes to search for agent extraction (in priority order) + _AGENT_ATTRS = [ + "_agent", + "agent", + "_runnable", + "runnable", + "_graph", + "graph", + "_chain", + "chain", + "_crew", + "crew", + ] + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + context: Any, + runtime_id: str, + ) -> None: + """Wrap ``delegate`` and prime per-run governance state. + + Captures the runtime/trace identifiers, resolves the agent name and + model from ``context``, and kicks off the background policy fetch. + """ + self._delegate = delegate + self._context = context + self._runtime_id = runtime_id + self._trace_id = str(uuid4()) + self._governed_agent: Any = None + self._original_agent: Any = None + self._agent_attr_name: str | None = None + self._agent_holder: Any = None # The object holding the agent attribute + self._init_failed = False # Track if initialization failed + self._model_name = "" + self._model_name_token: Token[str] | None = None + self._evaluator: GovernanceEvaluator | None = None + self._evaluator_ready: bool = False + self._evaluator_lock = threading.Lock() + self._adapter_registry: Any = None + + # Agent name is needed even in the FF-off path so dispose / log + # messages have something to print. Cheap and exception-free. + self._agent_name = "agent" + if context is not None and hasattr(context, "entrypoint"): + self._agent_name = context.entrypoint or "agent" + + # Governance feature flag gate. When OFF: no extraction, no + # ContextVar binding, no agent-type selector mutation, no + # prefetch. All hook checks see _init_failed=True and no-op. + # Everything below this point is governance-side state — running + # it under FF-off would (a) violate the lazy/no-op contract and + # (b) expose the wrapper to extraction exceptions on hosts where + # governance is intentionally disabled. + if not is_governance_enabled(): + self._init_failed = True + self._evaluator_ready = True # don't try to materialise later + logger.info( + "GovernanceRuntime initialized as no-op: governance feature " + "flag is OFF (agent='%s', runtime_id='%s')", + self._agent_name, + runtime_id, + ) + return + + try: + # Bind the model-name ContextVar so adapters running in this + # runtime's context see the right value under concurrent + # agents. The token is stashed so dispose() can reset the + # var — without that, the value leaks into sibling tasks + # that inherit this context and outlive the runtime. + model_name = self._extract_model_name(delegate, context) + self._model_name = model_name + self._model_name_token = _current_model_name.set(model_name) + + # Record agent-type before the policy prefetch fires so the + # fetch can ask the server for the matching container key + # (conversational vs autonomous). + set_agent_conversational( + self._extract_is_conversational(delegate, context) + ) + + # Fire the network-bound policy fetch in the background so + # it overlaps with the rest of the agent setup. The + # evaluator and adapter wrap are materialised lazily on the + # first hook fire (see _ensure_evaluator), which is where + # we wait for the prefetch to land. + prefetch_policy_index() + self._adapter_registry = get_adapter_registry() + + logger.info( + "GovernanceRuntime initialized (prefetching policy): agent='%s', " + "runtime_id='%s', model='%s', mode=%s, adapters=%d", + self._agent_name, + runtime_id, + model_name or "unknown", + get_enforcement_mode().value, + len(self._adapter_registry.get_all()), + ) + except Exception as e: + # Fail-safe: log error but don't break runtime initialization + self._init_failed = True + self._evaluator = None + self._adapter_registry = None + logger.warning( + "GovernanceRuntime initialization failed (continuing without governance): %s", + e, + ) + + def _extract_model_name( + self, + delegate: UiPathRuntimeProtocol, + context: Any, + ) -> str: + """Extract model name from delegate or context.""" + model_name = "" + + # Try _agent_definition.settings.model (LicensedRuntime pattern) + agent_def = getattr(delegate, "_agent_definition", None) + if agent_def: + settings = getattr(agent_def, "settings", None) + if settings: + model_name = getattr(settings, "model", None) or "" + + # Try direct attributes on delegate + if not model_name: + for attr in ["model", "model_name", "_model", "model_id"]: + val = getattr(delegate, attr, None) + if val: + model_name = str(val) + break + + # Try nested delegate chain (unwrap wrappers) + if not model_name: + inner = getattr(delegate, "_delegate", None) or getattr( + delegate, "delegate", None + ) + while inner and not model_name: + agent_def = getattr(inner, "_agent_definition", None) + if agent_def: + settings = getattr(agent_def, "settings", None) + if settings: + model_name = getattr(settings, "model", None) or "" + break + inner = getattr(inner, "_delegate", None) or getattr( + inner, "delegate", None + ) + + # Try context + if not model_name and context is not None: + for attr in ["model", "model_name", "model_id"]: + val = getattr(context, attr, None) + if val: + model_name = str(val) + break + + return model_name + + def _extract_is_conversational( + self, + delegate: UiPathRuntimeProtocol, + context: Any, + ) -> bool: + """Determine whether the hosted agent is conversational. + + Reads ``AgentDefinition.is_conversational`` off the delegate's + ``_agent_definition`` (the LicensedRuntime pattern, same source as + :meth:`_extract_model_name`), unwrapping nested delegates. Falls back + to the runtime context's conversation id when no agent definition is + reachable. Defaults to ``False`` (autonomous) — fail-safe: an + unknown agent is treated as autonomous, matching the server default. + """ + + def _from_agent_def(obj: Any) -> bool | None: + agent_def = getattr(obj, "_agent_definition", None) + if agent_def is None: + return None + value = getattr(agent_def, "is_conversational", None) + return bool(value) if value is not None else None + + # Delegate, then the unwrapped delegate chain. Depth cap mirrors + # :meth:`_extract_agent` — a pathological wrapper chain shouldn't + # turn this synchronous init into a loop. + node: Any = delegate + for _ in range(10): + if node is None: + break + result = _from_agent_def(node) + if result is not None: + return result + node = getattr(node, "_delegate", None) or getattr(node, "delegate", None) + + # Fallback: a populated conversation id implies a conversational run. + if context is not None: + conversation_id = getattr(context, "conversation_id", None) + if conversation_id: + return True + + return False + + def _wrap_delegate_agent(self) -> None: + """Extract agent from delegate and wrap with governance via adapters. + + This method: + 1. Searches delegate for known agent attributes + 2. Uses AdapterRegistry to find matching adapter + 3. Wraps agent with governance hooks + 4. Replaces original agent in delegate with governed version + + IMPORTANT: This method is fail-safe. Any error during adapter wrapping + will continue without inner hooks. It will NEVER break the execution flow. + BEFORE_AGENT/AFTER_AGENT checks at the runtime boundary still provide governance. + """ + try: + # Extract agent from delegate + agent, attr_name = self._extract_agent(self._delegate) + + if agent is None: + logger.debug( + "No agent found in delegate - continuing without inner hooks" + ) + return + + if self._adapter_registry is None: + # Defensive: should never happen because _ensure_evaluator + # is gated on _init_failed which is set with adapter_registry=None. + return + + # Find matching adapter + adapter = self._adapter_registry.resolve(agent) + if adapter is None: + logger.debug( + "No adapter found for agent type '%s' - continuing without inner hooks", + type(agent).__name__, + ) + return + + if self._evaluator is None: + # _wrap_delegate_agent is now called from _ensure_evaluator + # after the evaluator is materialised; this guard exists + # only for defensive symmetry with wrap_agent(). + logger.debug("Skipping adapter attach: evaluator not yet materialised") + return + + # Wrap agent with governance + governed = adapter.attach( + agent=agent, + agent_id=self._agent_name, + session_id=self._runtime_id, + evaluator=self._evaluator, + ) + + install_delegation_guard(agent) + + # Store references + self._original_agent = agent + self._governed_agent = governed + self._agent_attr_name = attr_name + + # Replace agent in delegate with governed version + if attr_name is not None: + self._replace_agent_in_delegate(governed, attr_name) + + logger.info( + "Agent wrapped with governance: type=%s, adapter=%s, attr=%s", + type(agent).__name__, + adapter.name, + attr_name, + ) + + except Exception as e: + # Catch-all: ensure we never break execution + logger.warning( + "Agent wrapping failed: %s - continuing without inner hooks", + e, + ) + + def _extract_agent(self, delegate: Any) -> tuple[Any, str | None]: + """Extract agent from delegate runtime. + + Searches known attribute names in priority order. + This method is fail-safe and will return (None, None) on any error. + + Returns: + Tuple of (agent, attribute_name) or (None, None) if not found + """ + try: + # First check direct attributes on delegate + for attr in self._AGENT_ATTRS: + try: + agent = getattr(delegate, attr, None) + if agent is not None: + logger.debug("Found agent at delegate.%s", attr) + return agent, attr + except Exception: + continue + + # Check nested delegate chain (unwrap wrapper layers) + inner = getattr(delegate, "_delegate", None) or getattr( + delegate, "delegate", None + ) + depth = 0 + while inner is not None and depth < 10: # Prevent infinite loops + for attr in self._AGENT_ATTRS: + try: + agent = getattr(inner, attr, None) + if agent is not None: + logger.debug( + "Found agent at nested delegate.%s (depth=%d)", + attr, + depth, + ) + # Return the inner delegate that holds the agent + self._agent_holder = inner + return agent, attr + except Exception: + continue + inner = getattr(inner, "_delegate", None) or getattr( + inner, "delegate", None + ) + depth += 1 + + except Exception as e: + logger.debug("Agent extraction failed: %s", e) + + return None, None + + def _replace_agent_in_delegate(self, governed: Any, attr_name: str) -> bool: + """Replace original agent in delegate with governed version. + + This method is fail-safe and will not raise exceptions. + + Args: + governed: The governed agent proxy + attr_name: Attribute name where agent was found + + Returns: + True if replacement succeeded, False otherwise + """ + # If we found agent in a nested delegate, use that + holder = getattr(self, "_agent_holder", None) or self._delegate + + try: + setattr(holder, attr_name, governed) + logger.debug("Replaced agent at %s.%s", type(holder).__name__, attr_name) + return True + except AttributeError: + # Some objects have read-only attributes + logger.debug( + "Cannot replace agent at %s.%s (read-only) - adapter hooks still active", + type(holder).__name__, + attr_name, + ) + return False + except Exception as e: + logger.debug( + "Failed to replace agent at %s.%s: %s - adapter hooks still active", + type(holder).__name__, + attr_name, + e, + ) + return False + + def wrap_agent(self, agent: Any, agent_id: str | None = None) -> Any: + """Wrap an agent with governance using the appropriate adapter. + + This method detects the agent framework and applies the correct adapter. + + Args: + agent: The agent to wrap + agent_id: Optional agent identifier (defaults to type name) + + Returns: + Governed agent proxy + """ + agent_id = agent_id or type(agent).__name__ + session_id = self._runtime_id + + if self._adapter_registry is None: + logger.warning( + "wrap_agent called but adapter registry not initialised " + "(governance likely disabled by feature flag); returning agent unwrapped" + ) + return agent + + # Find matching adapter + adapter = self._adapter_registry.resolve(agent) + if adapter is None: + logger.warning("No adapter found for agent type: %s", type(agent).__name__) + return agent + + if self._evaluator is None: + logger.warning( + "wrap_agent called before evaluator materialised; " + "ensuring evaluator is ready before attaching adapter" + ) + self._ensure_evaluator() + if self._evaluator is None: + logger.warning( + "Evaluator failed to materialise; returning agent unwrapped" + ) + return agent + + # Attach governance via adapter + governed = adapter.attach( + agent=agent, + agent_id=agent_id, + session_id=session_id, + evaluator=self._evaluator, + ) + + logger.info( + "Agent wrapped with governance: agent=%s, adapter=%s", + agent_id, + adapter.name, + ) + + return governed + + def _ensure_evaluator(self) -> None: + """Materialise the evaluator and attach adapters on first hook fire. + + Idempotent — subsequent calls are no-ops. The first call blocks + on the policy prefetch that ``__init__`` kicked off; the user + request was explicit that the wait happens here, not at init. + """ + if self._evaluator_ready or self._init_failed: + return + with self._evaluator_lock: + if self._evaluator_ready or self._init_failed: + return + try: + policy_index = get_policy_index() + self._evaluator = GovernanceEvaluator(policy_index) + self._wrap_delegate_agent() + logger.info( + "GovernanceRuntime ready: agent='%s', packs=%s, rules=%d, wrapped=%s", + self._agent_name, + policy_index.pack_names, + policy_index.total_rules, + self._governed_agent is not None, + ) + except Exception as exc: + self._init_failed = True + self._evaluator = None + logger.warning( + "Lazy evaluator materialisation failed; " + "governance disabled for this runtime: %s", + exc, + ) + finally: + self._evaluator_ready = True + + def _check_before_agent(self, input: Dict[str, Any] | None) -> None: + """Evaluate BEFORE_AGENT rules at runtime boundary. + + The evaluator owns audit emission and DENY-raising; this method + just primes the evaluator and propagates blocks. + + Fail-safe: Only GovernanceBlockException propagates. All other + errors are logged and execution continues. + """ + try: + self._ensure_evaluator() + if self._init_failed or self._evaluator is None: + return + + # In conversational agents the runtime ``input`` carries the + # full chat history (e.g. ``{"messages": [...]}``). Pass + # ``latest_only=True`` so governance evaluates the most + # recent user message and not the entire transcript. + agent_input = _extract_governable_text(input, latest_only=True) + + self._evaluator.evaluate_before_agent( + agent_input=agent_input, + agent_name=self._agent_name, + runtime_id=self._runtime_id, + trace_id=self._trace_id, + model_name=self._model_name, + ) + + except GovernanceBlockException: + raise # Allow intentional blocks to propagate + except Exception as e: + # Fail-safe: log and continue without blocking + logger.warning("BEFORE_AGENT governance check failed (continuing): %s", e) + + def _check_after_agent(self, output: Any) -> None: + """Evaluate AFTER_AGENT rules at runtime boundary. + + The evaluator owns audit emission and DENY-raising; this method + just primes the evaluator and propagates blocks. + + Fail-safe: Only GovernanceBlockException propagates. All other + errors are logged and execution continues. + """ + try: + self._ensure_evaluator() + if self._init_failed or self._evaluator is None: + return + + # Pull the agent's textual output for governance. UiPathRuntimeResult + # wraps the actual payload under ``.output``; everything else (raw + # dicts, strings, framework result objects) goes through the + # extractor directly. The extractor handles list-of-blocks, + # dict-content, pydantic, etc. — no more dict-repr garble. + payload: Any = getattr(output, "output", output) + agent_output = _extract_governable_text(payload) + + self._evaluator.evaluate_after_agent( + agent_output=agent_output, + agent_name=self._agent_name, + runtime_id=self._runtime_id, + trace_id=self._trace_id, + ) + + except GovernanceBlockException: + raise # Allow intentional blocks to propagate + except Exception as e: + # Fail-safe: log and continue without blocking + logger.warning("AFTER_AGENT governance check failed (continuing): %s", e) + + @property + def delegate(self) -> UiPathRuntimeProtocol: + """Return the wrapped runtime this proxy delegates to.""" + return self._delegate + + @property + def evaluator(self) -> GovernanceEvaluator | None: + """Get the governance evaluator (None until first hook fires).""" + return self._evaluator + + async def execute( + self, + input: Dict[str, Any] | None = None, + options: Optional[Any] = None, + ) -> Any: + """Execute with governance checks at runtime boundary.""" + # BEFORE_AGENT: Check input before any agent execution + self._check_before_agent(input) + + # Delegate to actual runtime + result = await self._delegate.execute(input, options) + + # AFTER_AGENT: Check output before returning + self._check_after_agent(result) + + return result + + async def stream( + self, + input: Dict[str, Any] | None = None, + options: Optional[Any] = None, + ) -> AsyncGenerator[Any, None]: + """Stream with governance checks at runtime boundary.""" + # BEFORE_AGENT: Check input before any agent execution + self._check_before_agent(input) + + # Delegate to actual runtime and collect final result. The + # terminal stream event is detected structurally via the + # Same-package class import — no cross-package shim needed. + # importing the concrete UiPathRuntimeResult class from + # uipath-runtime (which would create a core→runtime cycle). + final_result = None + async for event in self._delegate.stream(input, options): + if isinstance(event, UiPathRuntimeResult): + final_result = event + yield event + + # AFTER_AGENT: Check output after stream completes + if final_result is not None: + self._check_after_agent(final_result) + + async def get_schema(self) -> Any: + """Delegate to the wrapped runtime; governance has no schema layer.""" + return await self._delegate.get_schema() + + async def dispose(self) -> None: + """Dispose the runtime and restore original agent if wrapped. + + Each governance-side cleanup step is isolated so a single + failure can't strand later steps. ``self._delegate.dispose()`` + always runs last and is the only step whose exception is allowed + to propagate — that's the caller's contract with the wrapped + runtime. + """ + # Step 1: restore the original agent attribute. + if self._original_agent is not None and self._agent_attr_name is not None: + try: + holder = self._agent_holder or self._delegate + setattr(holder, self._agent_attr_name, self._original_agent) + logger.debug("Restored original agent on dispose") + except Exception as e: + logger.warning("Failed to restore original agent: %s", e) + + # Step 2: uninstall the delegation guard (its own try/except so + # a failed restore in step 1 doesn't skip the guard removal). + if self._original_agent is not None: + try: + uninstall_delegation_guard(self._original_agent) + except Exception as e: + logger.warning("Failed to uninstall delegation guard: %s", e) + + # Step 3: reset the model-name ContextVar so the value doesn't + # leak into sibling tasks that inherited this context. + if self._model_name_token is not None: + try: + _current_model_name.reset(self._model_name_token) + except ValueError: + # Token created in a different context — happens when + # dispose runs from a child task. Accept the leak rather + # than calling set(""), which would create yet another + # ContextVar level on top of the original. + logger.debug("Model-name ContextVar reset from foreign context") + except Exception as e: + logger.warning("Failed to reset model-name context: %s", e) + finally: + self._model_name_token = None + + # Step 4: delegate dispose — always last, exception propagates. + # The caller controls the contract with the wrapped runtime; we + # don't swallow its cleanup failures. + await self._delegate.dispose() + + def __getattr__(self, name: str) -> Any: + """Forward non-protocol attribute access to the wrapped runtime.""" + # Guard against recursion when __init__ raises before _delegate is + # bound, or against probes for our own private attributes that + # haven't been set yet. + if name.startswith("_"): + raise AttributeError(name) + try: + delegate = object.__getattribute__(self, "_delegate") + except AttributeError as exc: + raise AttributeError(name) from exc + return getattr(delegate, name) + + +def governance_wrapper( + runtime: UiPathRuntimeProtocol, + context: Any, + runtime_id: str, +) -> UiPathRuntimeProtocol: + """Wrapper function for UiPathRuntimeWrapperRegistry. + + Creates a GovernanceRuntime that wraps the given runtime with + compliance evaluation at each lifecycle hook. + + Args: + runtime: The runtime to wrap + context: Runtime context from the SDK (only ``entrypoint`` is read) + runtime_id: Unique identifier for this runtime instance + """ + if not is_governance_enabled(): + logger.debug( + "governance_wrapper: %s feature flag is OFF; returning unwrapped runtime", + "EnablePythonGovernanceChecker", + ) + return runtime + mode = get_enforcement_mode() + if mode == EnforcementMode.DISABLED: + logger.debug("Governance disabled - returning unwrapped runtime") + return runtime + return GovernanceRuntime(runtime, context, runtime_id) + + +def wrap_agent(agent: Any, agent_id: str | None = None) -> Any: + """Convenience function to wrap an agent with governance. + + Uses the AdapterRegistry to detect the framework and apply appropriate hooks. + + Args: + agent: The agent to wrap + agent_id: Optional agent identifier + + Returns: + Governed agent proxy + + Example: + from uipath.core.governance import wrap_agent + + governed_agent = wrap_agent(my_langchain_agent, "my-agent") + result = governed_agent.invoke({"input": "Hello"}) + """ + if not is_governance_enabled(): + logger.debug( + "wrap_agent: %s feature flag is OFF; returning unwrapped agent", + "EnablePythonGovernanceChecker", + ) + return agent + + mode = get_enforcement_mode() + if mode == EnforcementMode.DISABLED: + logger.debug("Governance disabled - returning unwrapped agent") + return agent + + agent_id = agent_id or type(agent).__name__ + session_id = str(uuid4()) + + # Get evaluator + policy_index = get_policy_index() + evaluator = GovernanceEvaluator(policy_index) + + # Find matching adapter + registry = get_adapter_registry() + adapter = registry.resolve(agent) + + if adapter is None: + logger.warning("No adapter found for agent type: %s", type(agent).__name__) + return agent + + # Attach governance + governed = adapter.attach( + agent=agent, + agent_id=agent_id, + session_id=session_id, + evaluator=evaluator, + ) + + logger.info( + "Agent wrapped: agent=%s, adapter=%s, packs=%s", + agent_id, + adapter.name, + policy_index.pack_names, + ) + + return governed diff --git a/src/uipath/runtime/registry.py b/src/uipath/runtime/registry.py index 032aee3..ffa7041 100644 --- a/src/uipath/runtime/registry.py +++ b/src/uipath/runtime/registry.py @@ -3,14 +3,90 @@ from pathlib import Path from typing import Callable, TypeAlias +from uipath.runtime.base import UiPathRuntimeProtocol from uipath.runtime.context import UiPathRuntimeContext -from uipath.runtime.factory import UiPathRuntimeFactoryProtocol +from uipath.runtime.factory import ( + UiPathRuntimeFactoryProtocol, + UiPathRuntimeFactorySettings, +) +from uipath.runtime.storage import UiPathRuntimeStorageProtocol +from uipath.runtime.wrapper import apply_governance_wrapper FactoryCallable: TypeAlias = Callable[ [UiPathRuntimeContext | None], UiPathRuntimeFactoryProtocol ] +class UiPathWrappedRuntimeFactory(UiPathRuntimeFactoryProtocol): + """Factory that delegates creation and applies governance to every runtime. + + Implements ``UiPathRuntimeFactoryProtocol`` so callers using the + protocol surface are unaffected. Non-protocol attribute access falls + through to the underlying factory via ``__getattr__``; the underlying + factory is also reachable directly via the :attr:`inner` property — + useful for callers that need the concrete registered type (e.g. + ``isinstance`` checks) and for tests. + """ + + def __init__( + self, + delegate: UiPathRuntimeFactoryProtocol, + context: UiPathRuntimeContext | None = None, + ) -> None: + """Initialize with the underlying factory and the runtime context.""" + self._delegate = delegate + self._context = context + + @property + def inner(self) -> UiPathRuntimeFactoryProtocol: + """Return the underlying registered factory. + + Use this when a caller needs the concrete factory type + (``isinstance`` checks, access to non-protocol public API). + Prefer passing ``apply_wrappers=False`` to + :meth:`UiPathRuntimeFactoryRegistry.get` when you want the + registry to return the concrete factory directly. + """ + return self._delegate + + def discover_entrypoints(self) -> list[str]: + """Delegate to the underlying factory.""" + return self._delegate.discover_entrypoints() + + async def new_runtime( + self, entrypoint: str, runtime_id: str, **kwargs + ) -> UiPathRuntimeProtocol: + """Create a runtime via the delegate and apply governance.""" + runtime = await self._delegate.new_runtime(entrypoint, runtime_id, **kwargs) + return await apply_governance_wrapper(runtime, self._context, runtime_id) + + async def get_storage(self) -> UiPathRuntimeStorageProtocol | None: + """Delegate to the underlying factory.""" + return await self._delegate.get_storage() + + async def get_settings(self) -> UiPathRuntimeFactorySettings | None: + """Delegate to the underlying factory.""" + return await self._delegate.get_settings() + + async def dispose(self) -> None: + """Delegate to the underlying factory.""" + return await self._delegate.dispose() + + def __getattr__(self, name: str): + """Forward attribute lookups to the delegate. + + Only invoked when normal attribute resolution fails on this + wrapper. Lets callers reach any non-protocol public API on the + registered concrete factory without knowing they hold a wrapper. + ``isinstance`` checks still see this class, not the delegate — + use :attr:`inner` or ``apply_wrappers=False`` for those. + """ + # Guard against recursion during construction (before _delegate is set). + if name == "_delegate": + raise AttributeError(name) + return getattr(self._delegate, name) + + class UiPathRuntimeFactoryRegistry: """Registry for UiPath runtime factories.""" @@ -41,6 +117,7 @@ def get( name: str | None = None, search_path: str = ".", context: UiPathRuntimeContext | None = None, + apply_wrappers: bool = True, ) -> UiPathRuntimeFactoryProtocol: """Get factory instance by name or auto-detect from config files. @@ -48,28 +125,52 @@ def get( name: Optional factory name search_path: Path to search for config files context: UiPathRuntimeContext to pass to factory + apply_wrappers: When True (default), the registered factory + is wrapped in :class:`UiPathWrappedRuntimeFactory` so + every runtime it produces passes through + :func:`apply_governance_wrapper`. Set False to obtain the + concrete registered factory unchanged — required for + callers that ``isinstance``-check the result or rely on + non-protocol public API. The wrapper exposes the + underlying factory via its :attr:`inner` property and + forwards unknown attribute access to it, but its type is + still :class:`UiPathWrappedRuntimeFactory`. Returns: - Factory instance + A :class:`UiPathRuntimeFactoryProtocol`; concretely a + :class:`UiPathWrappedRuntimeFactory` when + ``apply_wrappers=True``, otherwise the registered factory. """ + factory: UiPathRuntimeFactoryProtocol | None = None + if name: if name not in cls._factories: raise ValueError(f"Factory '{name}' not registered") factory_callable, _ = cls._factories[name] - return factory_callable(context) - - # Auto-detect based on config files in reverse registration order - search_dir = Path(search_path) - for factory_name in reversed(cls._registration_order): - factory_callable, config_file = cls._factories[factory_name] - if (search_dir / config_file).exists(): - return factory_callable(context) - - # Fallback to default - if cls._default_name is None: - raise ValueError("No default factory registered and no config file found") - factory_callable, _ = cls._factories[cls._default_name] - return factory_callable(context) + factory = factory_callable(context) + else: + # Auto-detect based on config files in reverse registration order + search_dir = Path(search_path) + for factory_name in reversed(cls._registration_order): + factory_callable, config_file = cls._factories[factory_name] + if (search_dir / config_file).exists(): + factory = factory_callable(context) + break + + # Fallback to default + if factory is None: + if cls._default_name is None: + raise ValueError( + "No default factory registered and no config file found" + ) + factory_callable, _ = cls._factories[cls._default_name] + factory = factory_callable(context) + + # Wrap factory to auto-apply runtime wrappers + if apply_wrappers: + factory = UiPathWrappedRuntimeFactory(factory, context) + + return factory @classmethod def set_default(cls, name: str) -> None: diff --git a/src/uipath/runtime/wrapper.py b/src/uipath/runtime/wrapper.py new file mode 100644 index 0000000..046673c --- /dev/null +++ b/src/uipath/runtime/wrapper.py @@ -0,0 +1,54 @@ +"""Governance integration for the runtime. + +Wraps a runtime with governance when the ``EnablePythonGovernanceChecker`` +feature flag is enabled. ``uipath.runtime.governance.wrapper`` is +imported lazily — only when the gate passes — so its transitive cost +(audit, evaluator, OTel, …) stays off the startup path when governance +is disabled. + +The feature flag name and gate function are re-exported from +``uipath.core.governance.config`` so there is a single source of truth. +""" + +from __future__ import annotations + +import logging + +from uipath.core.governance.config import ( + GOVERNANCE_FEATURE_FLAG, + is_governance_enabled, +) + +from uipath.runtime.base import UiPathRuntimeProtocol +from uipath.runtime.context import UiPathRuntimeContext + +logger = logging.getLogger(__name__) + +__all__ = ["GOVERNANCE_FEATURE_FLAG", "apply_governance_wrapper"] + + +async def apply_governance_wrapper( + runtime: "UiPathRuntimeProtocol", + context: "UiPathRuntimeContext | None", + runtime_id: str, +) -> "UiPathRuntimeProtocol": + """Wrap a runtime with governance when the feature flag is enabled. + + Returns the inner runtime unchanged when the flag is off or when + the wrapper itself raises — governance failures must never break + the agent run. + """ + if not is_governance_enabled(): + logger.debug( + "Skipping governance wrapper: %s feature flag is not enabled", + GOVERNANCE_FEATURE_FLAG, + ) + return runtime + + from uipath.runtime.governance.wrapper import governance_wrapper + + try: + return governance_wrapper(runtime, context, runtime_id) + except Exception as exc: + logger.warning("Failed to apply governance wrapper: %s", exc) + return runtime diff --git a/tests/conftest.py b/tests/conftest.py index 2556e75..6f1d146 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,25 @@ def temp_dir() -> Generator[str, None, None]: """Provide a temporary directory for test files.""" with tempfile.TemporaryDirectory() as tmp_dir: yield tmp_dir + + +@pytest.fixture(autouse=True) +def _reset_governance_process_state() -> Generator[None, None, None]: + """Clear process-level governance state around every test. + + The native governance layer keeps two pieces of state at module scope: + the conversational/autonomous selector consumed by the policy fetch, + and the memoized job-context. Both are stable per process in + production but leak across tests when not reset, masking ordering + bugs and producing flakes. + """ + from uipath.runtime.governance.native.backend_client import ( + resolve_job_context, + set_agent_conversational, + ) + + set_agent_conversational(None) + resolve_job_context.cache_clear() + yield + set_agent_conversational(None) + resolve_job_context.cache_clear() diff --git a/tests/test_audit_register_sink.py b/tests/test_audit_register_sink.py new file mode 100644 index 0000000..ff03710 --- /dev/null +++ b/tests/test_audit_register_sink.py @@ -0,0 +1,103 @@ +"""Tests for ``AuditManager.register_sink`` failure-counter semantics. + +A re-registered same-name sink must NOT inherit the previous instance's +tripped circuit-breaker state. ``unregister_sink`` already clears these +counters, but ``register_sink`` also clears them on a successful add as +defense-in-depth (covers tests / external callers that touch the +internal counter dicts directly). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from uipath.runtime.governance.audit.base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, +) + + +class _NoopSink(AuditSink): + """Sink that records emit calls and never raises.""" + + def __init__(self, name: str = "test-sink") -> None: + self._name = name + self.events: list[AuditEvent] = [] + + @property + def name(self) -> str: + return self._name + + def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +def _event() -> AuditEvent: + return AuditEvent(event_type=EventType.RULE_EVALUATION, agent_name="a") + + +@pytest.fixture +def manager() -> Any: + """Build a fresh, sync-mode AuditManager for the test.""" + return AuditManager(async_mode=False) + + +def test_register_clears_stale_failure_counter(manager: AuditManager) -> None: + """A new sink with a name that previously tripped starts fresh.""" + # Simulate prior instance having tripped the circuit-breaker without + # going through unregister (e.g. test code or external code that + # mutated the counters directly). + manager._sink_failures["test-sink"] = manager._SINK_FAILURE_THRESHOLD + manager._tripped_sinks.add("test-sink") + + new_sink = _NoopSink(name="test-sink") + manager.register_sink(new_sink) + + # Counter and tripped-set must be cleared. + assert manager._sink_failures.get("test-sink", 0) == 0 + assert "test-sink" not in manager._tripped_sinks + + # And the new sink actually receives events (would be skipped if + # still considered tripped). + manager.emit(_event()) + assert len(new_sink.events) == 1 + + +def test_register_does_not_clear_for_duplicate(manager: AuditManager) -> None: + """Re-registering an already-present sink is a no-op (no counter reset).""" + sink = _NoopSink(name="test-sink") + manager.register_sink(sink) + + # Simulate the existing sink having accumulated some failures. + manager._sink_failures["test-sink"] = 3 + + # A second register call with the same name should NOT clear those + # failures — the duplicate-check fires before the reset. + duplicate = _NoopSink(name="test-sink") + manager.register_sink(duplicate) + + assert manager._sink_failures["test-sink"] == 3 + + +def test_unregister_then_register_starts_fresh(manager: AuditManager) -> None: + """The full lifecycle: register → trip → unregister → register again.""" + sink = _NoopSink(name="test-sink") + manager.register_sink(sink) + manager._sink_failures["test-sink"] = manager._SINK_FAILURE_THRESHOLD + manager._tripped_sinks.add("test-sink") + + manager.unregister_sink("test-sink") + # Unregister already clears. + assert "test-sink" not in manager._tripped_sinks + + new_sink = _NoopSink(name="test-sink") + manager.register_sink(new_sink) + assert manager._sink_failures.get("test-sink", 0) == 0 + assert "test-sink" not in manager._tripped_sinks + + manager.emit(_event()) + assert len(new_sink.events) == 1 diff --git a/tests/test_commitment_concern.py b/tests/test_commitment_concern.py new file mode 100644 index 0000000..a46149b --- /dev/null +++ b/tests/test_commitment_concern.py @@ -0,0 +1,205 @@ +"""Tests for the commitment_concern check (A.10.4). + +The check now uses OR semantics: a verb match, an amount match, or a +deadline match is each sufficient when its enabling flag is on. With +both flags false the rule matches verb-only. + +The verb pattern also covers proposal / SOW style commitment markers +("Cost: $X", "fixed scope", "Deliverables", "Timeline", "I propose") +so formal-business commitments without first-person verbs still fire. + +Amount detection requires a currency marker adjacent to the number to +prevent URL fragments (forum-post IDs, image dimensions, etc.) from +false-positiving. +""" + +from __future__ import annotations + +import pytest + +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator + +# --------------------------------------------------------------------------- +# The proposal-style sample that originally slipped through the rule. +# Contains: "Cost: $780 (fixed for the above scope)", "Deliverables", +# "Timeline: 4 days total", "I propose", a forum URL with a 6-digit ID. +# Triple-quoted so we keep the line breaks the model produced. +# --------------------------------------------------------------------------- +SAMPLE_PROPOSAL = """To address your concerns, I reviewed the official UiPath site you referenced and relevant resources on uipath.com to inform a fast stabilization plan. Notable findings include: a community CI/CD sample for UiPath projects (https://forum.uipath.com/t/announcement-ci-cd-pipeline-sample-implementation-s-for-uipath-projects-alpha/667851). + +Here's how I propose we turn your software around quickly: + +Plan +- Triage (logs + reproduce) +- Quick stabilization + +Deliverables +- Defect triage report + +Timeline: 4 days total +- Day 1: Triage + reproduction + +Cost: $780 (fixed for the above scope) +""" + + +@pytest.mark.parametrize( + "text", + [ + "Cost: $780 (fixed for the above scope)", + "Deliverables: a, b, c", + "Timeline: 4 days total for the whole engagement", + "I propose we turn this around in a week", + "We will refund the difference", + "I'll deliver the report by Friday", + "the warranty covers parts only", + "fixed price of one hundred dollars", + ], +) +def test_verb_match_alone_fires(text: str) -> None: + """Each verb-style commitment marker fires on its own (verb-only mode).""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": False, "require_deadline": False} + ) + is True + ) + + +def test_full_proposal_sample_fires() -> None: + """The originally-missed proposal output now fires.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + SAMPLE_PROPOSAL, + {"require_amount": False, "require_deadline": False}, + ) + is True + ) + + +@pytest.mark.parametrize( + "text", + [ + "$780", + "We charge USD 1,200 per seat", + "The fee is 500 EUR", + ], +) +def test_amount_alone_fires_when_require_amount_true(text: str) -> None: + """Currency-anchored amount alone fires under OR semantics.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": False} + ) + is True + ) + + +@pytest.mark.parametrize( + "text", + [ + "Task is 75% complete.", + "We maintain 99.9% uptime.", + "Battery at 50%.", + "Score: 12%.", + ], +) +def test_bare_percentage_does_not_fire(text: str) -> None: + """Status-only percentages must not trigger commitment_concern. + + Regression for the prior ``\\d{1,3}\\s*%`` branch in the amount + regex, which fired on benign status / progress text. Real + percentage-bearing commitments ("we'll give a 20% discount") + still fire via the verb pattern. + """ + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": False} + ) + is False + ) + + +def test_percentage_with_verb_still_fires() -> None: + """A commitment verb co-occurring with a percentage still fires.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "We will refund 100% of the purchase price.", + {"require_amount": True, "require_deadline": False}, + ) + is True + ) + + +def test_amount_alone_does_not_fire_when_require_amount_false() -> None: + """Amount-only text is silent when require_amount=False and no verb.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "The list price is $780.", + {"require_amount": False, "require_deadline": False}, + ) + is False + ) + + +def test_deadline_alone_fires_when_require_deadline_true() -> None: + """Deadline phrase alone fires under OR semantics.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "Will be done within 5 days.", + {"require_amount": False, "require_deadline": True}, + ) + is True + ) + + +def test_url_fragment_digits_do_not_false_positive() -> None: + """A long URL with embedded digits is not a 'commitment'. + + Catches the prior price-parser misbehaviour where Price.fromstring() + picked up forum-post IDs (e.g. ``667851``) and conflated them with + unrelated currency symbols elsewhere in the text. + """ + text = ( + "See https://forum.example.com/t/topic/667851 for details — " + "no commitment language here." + ) + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": True} + ) + is False + ) + + +@pytest.mark.parametrize( + "text", + [ + "", + " ", + "Just chatting about the weather today.", + "The product is durable and well-made.", + ], +) +def test_no_signal_does_not_fire(text: str) -> None: + """Text without any commitment signal stays silent regardless of flags.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": True} + ) + is False + ) + + +def test_non_dict_params_treated_as_defaults() -> None: + """``params`` of the wrong type degrades to defaults rather than crashing.""" + assert ( + GovernanceEvaluator._check_commitment_concern("we will refund", None) + is True + ) + assert ( + GovernanceEvaluator._check_commitment_concern( + "no verbs here", "garbage" + ) + is False + ) diff --git a/tests/test_delegation_guard.py b/tests/test_delegation_guard.py new file mode 100644 index 0000000..a1ba432 --- /dev/null +++ b/tests/test_delegation_guard.py @@ -0,0 +1,320 @@ +"""Tests for the async-aware delegation depth guard. + +The guard wraps an agent's ``invoke`` and ``ainvoke`` so a single +ContextVar tracks delegation depth across both sync and async call +chains. The async wrapper must itself be a coroutine — wrapping with a +sync function would return an un-awaited coroutine and silently bypass +the depth check. +""" + +from __future__ import annotations + +import asyncio +import os +from types import SimpleNamespace + +import pytest +from uipath.core.governance.exceptions import GovernanceBlockException + +from uipath.runtime.governance.delegation_guard import ( + install_delegation_guard, + uninstall_delegation_guard, +) + +# --------------------------------------------------------------------------- +# Helpers — minimal agent shapes the guard might encounter in the wild. +# --------------------------------------------------------------------------- + + +def _make_sync_agent() -> SimpleNamespace: + agent = SimpleNamespace() + agent.invoke = lambda payload, **_: {"sync": payload} + return agent + + +def _make_async_agent() -> SimpleNamespace: + agent = SimpleNamespace() + + async def _ainvoke(payload, **_): + return {"async": payload} + + agent.ainvoke = _ainvoke + return agent + + +def _make_dual_agent() -> SimpleNamespace: + """Agent with both sync invoke and async ainvoke (LangGraph React shape).""" + agent = _make_sync_agent() + + async def _ainvoke(payload, **_): + return {"async": payload} + + agent.ainvoke = _ainvoke + return agent + + +# --------------------------------------------------------------------------- +# Sync path — preserves the original behaviour the guard always had. +# --------------------------------------------------------------------------- + + +def test_sync_invoke_passes_through_under_limit() -> None: + agent = _make_sync_agent() + install_delegation_guard(agent, max_depth=3) + assert agent.invoke({"x": 1}) == {"sync": {"x": 1}} + + +def test_sync_invoke_raises_when_depth_exceeded() -> None: + """Recursive sync invokes blow the limit.""" + agent = SimpleNamespace() + calls = {"n": 0} + + def _invoke(_payload, **_): + calls["n"] += 1 + # Recurse into ourselves through the guarded attribute. + return agent.invoke({}) + + agent.invoke = _invoke + install_delegation_guard(agent, max_depth=3) + + with pytest.raises(GovernanceBlockException): + agent.invoke({}) + # Depth check fires inside the wrapper before the original runs, so + # we got exactly max_depth=3 successful entries plus one rejection. + assert calls["n"] == 3 + + +# --------------------------------------------------------------------------- +# Async path — the new shape this change unlocks. +# --------------------------------------------------------------------------- + + +def test_async_wrapper_is_a_coroutine_function() -> None: + """The wrapped ainvoke must itself be awaitable. + + Regression test for the original bug: a sync wrapper around an async + method returned an un-awaited coroutine and silently bypassed the + depth check entirely. + """ + agent = _make_async_agent() + install_delegation_guard(agent, max_depth=3) + assert asyncio.iscoroutinefunction(agent.ainvoke) + + +def test_async_invoke_passes_through_under_limit() -> None: + agent = _make_async_agent() + install_delegation_guard(agent, max_depth=3) + result = asyncio.run(agent.ainvoke({"x": 1})) + assert result == {"async": {"x": 1}} + + +def test_async_invoke_raises_when_depth_exceeded() -> None: + agent = SimpleNamespace() + calls = {"n": 0} + + async def _ainvoke(_payload, **_): + calls["n"] += 1 + return await agent.ainvoke({}) + + agent.ainvoke = _ainvoke + install_delegation_guard(agent, max_depth=3) + + with pytest.raises(GovernanceBlockException): + asyncio.run(agent.ainvoke({})) + assert calls["n"] == 3 + + +def test_sync_and_async_share_one_depth_counter() -> None: + """A coroutine that falls through to sync ``invoke`` increments the same counter.""" + agent = _make_dual_agent() + calls = {"n": 0} + + def _invoke(_payload, **_): + calls["n"] += 1 + # Sync self-recursion through the same guarded attribute. + return agent.invoke({}) + + async def _ainvoke(_payload, **_): + calls["n"] += 1 + # Cross-mode: async entry falls through to the sync path. + return agent.invoke({}) + + agent.invoke = _invoke + agent.ainvoke = _ainvoke + install_delegation_guard(agent, max_depth=2) + + with pytest.raises(GovernanceBlockException): + asyncio.run(agent.ainvoke({})) + # ainvoke (depth=1) → invoke (depth=2) → invoke (depth=3, blocked). + # The guard rejects the third call before _invoke runs, so calls=2. + assert calls["n"] == 2 + + +# --------------------------------------------------------------------------- +# Lifecycle — install / uninstall semantics. +# --------------------------------------------------------------------------- + + +def test_install_is_idempotent() -> None: + agent = _make_sync_agent() + install_delegation_guard(agent, max_depth=5) + wrapped_once = agent.invoke + install_delegation_guard(agent, max_depth=5) + assert agent.invoke is wrapped_once, "second install must not re-wrap" + + +def test_uninstall_restores_originals_for_both_methods() -> None: + agent = _make_dual_agent() + original_invoke = agent.invoke + original_ainvoke = agent.ainvoke + install_delegation_guard(agent, max_depth=5) + assert agent.invoke is not original_invoke + assert agent.ainvoke is not original_ainvoke + + uninstall_delegation_guard(agent) + assert agent.invoke is original_invoke + assert agent.ainvoke is original_ainvoke + assert not getattr(agent, "_delegation_wrapped", False) + + +def test_uninstall_safe_on_unguarded_agent() -> None: + agent = _make_sync_agent() + # Should not raise; should leave agent unchanged. + uninstall_delegation_guard(agent) + assert callable(agent.invoke) + + +# --------------------------------------------------------------------------- +# Edge cases. +# --------------------------------------------------------------------------- + + +def test_agent_without_invoke_methods_is_noop() -> None: + """Agents without any invokable method must not crash the install.""" + agent = SimpleNamespace(unrelated="value") + install_delegation_guard(agent, max_depth=5) + assert not getattr(agent, "_delegation_wrapped", False) + + +def test_env_var_max_depth_override(monkeypatch: pytest.MonkeyPatch) -> None: + """``UIPATH_GOVERNANCE_MAX_DELEGATION_DEPTH`` overrides the default.""" + monkeypatch.setenv("UIPATH_GOVERNANCE_MAX_DELEGATION_DEPTH", "1") + agent = SimpleNamespace() + calls = {"n": 0} + + def _invoke(_payload, **_): + calls["n"] += 1 + return agent.invoke({}) + + agent.invoke = _invoke + install_delegation_guard(agent) # picks up env + + with pytest.raises(GovernanceBlockException): + agent.invoke({}) + assert calls["n"] == 1, "max_depth=1 should allow exactly one call" + + +def test_invalid_env_var_falls_back_to_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("UIPATH_GOVERNANCE_MAX_DELEGATION_DEPTH", "not-a-number") + agent = _make_sync_agent() + # Should not raise on install — falls back silently to the default. + install_delegation_guard(agent) + assert os.environ.get("UIPATH_GOVERNANCE_MAX_DELEGATION_DEPTH") == "not-a-number" + assert callable(agent.invoke) + + +# --------------------------------------------------------------------------- +# Leak / scaling — pins the shared-ContextVar design. +# --------------------------------------------------------------------------- + + +def test_install_does_not_allocate_per_agent_contextvars() -> None: + """N installs must not grow the module's ContextVar registry by N. + + The old implementation allocated a ``ContextVar`` per agent. Since + ContextVar instances are interned by the interpreter and never GC'd, + that was an unbounded leak. The current design holds a single + module-level ContextVar of ``dict[id(agent), int]``. + """ + from uipath.runtime.governance import delegation_guard as dg + + # Snapshot the single shared ContextVar. + shared_var = dg._DELEGATION_DEPTHS + + for _ in range(100): + agent = _make_sync_agent() + install_delegation_guard(agent, max_depth=3) + uninstall_delegation_guard(agent) + + # The module-level ContextVar is unchanged — same instance, no new + # ContextVars were allocated. + assert dg._DELEGATION_DEPTHS is shared_var + + +def test_two_agents_have_independent_depth_counters() -> None: + """Exhausting one agent's depth limit doesn't leak into another agent. + + Both agents share the single module-level ContextVar but the dict + inside isolates them via ``id(agent)``. + """ + from uipath.runtime.governance import delegation_guard as dg + + agent_a = SimpleNamespace() + calls_a = {"n": 0} + + def _invoke_a(_payload, **_): + calls_a["n"] += 1 + return agent_a.invoke({}) # self-recursion until limit + + agent_a.invoke = _invoke_a + + agent_b = _make_sync_agent() + + install_delegation_guard(agent_a, max_depth=2) + install_delegation_guard(agent_b, max_depth=2) + + # Drive agent_a to its limit. + with pytest.raises(GovernanceBlockException): + agent_a.invoke({}) + assert calls_a["n"] == 2 + + # agent_b is a fresh chain in the same context. Its depth counter + # is keyed by id(agent_b), so agent_a's exhausted state doesn't + # affect it. Without the per-agent keying, agent_b would inherit + # whatever depth was last set in this context. + assert agent_b.invoke({"x": 1}) == {"sync": {"x": 1}} + + # After both calls, the ContextVar should be back to its initial + # state — either unset (LookupError) or holding an empty dict. The + # set/reset pairs each guarded call cleaned up after itself. + try: + depths = dg._DELEGATION_DEPTHS.get() + except LookupError: + depths = {} + assert depths.get(id(agent_a), 0) == 0 + assert depths.get(id(agent_b), 0) == 0 + + +def test_uninstall_clears_agent_depth_entry() -> None: + """After uninstall, the agent's id is no longer in the depths dict. + + Prevents ``id(agent)`` reuse — Python recycles ids after GC — from + mis-attributing a future agent's count to this one. + """ + from uipath.runtime.governance import delegation_guard as dg + + agent = _make_sync_agent() + install_delegation_guard(agent, max_depth=5) + # Enter the guard once so the agent gets a depth entry. + agent.invoke({}) + # invoke completed -> token reset -> entry should be back to 0 or + # absent. We re-enter manually to plant a non-zero entry. + agent_key = id(agent) + dg._DELEGATION_DEPTHS.set({agent_key: 3}) + assert dg._DELEGATION_DEPTHS.get().get(agent_key) == 3 + + uninstall_delegation_guard(agent) + # Uninstall pops the entry from the current context. + assert agent_key not in dg._DELEGATION_DEPTHS.get() diff --git a/tests/test_dispose_isolation.py b/tests/test_dispose_isolation.py new file mode 100644 index 0000000..c0b87ad --- /dev/null +++ b/tests/test_dispose_isolation.py @@ -0,0 +1,146 @@ +"""Tests for step-isolated ``GovernanceRuntime.dispose()`` cleanup. + +A single failing step in dispose() must not strand the remaining steps. +``self._delegate.dispose()`` always runs last and is the only step whose +exception propagates. +""" + +from __future__ import annotations + +import asyncio + +import pytest +from uipath.core.feature_flags import FeatureFlags + +from uipath.runtime.governance import wrapper as wrapper_mod +from uipath.runtime.governance.wrapper import GovernanceRuntime, _current_model_name +from uipath.runtime.wrapper import GOVERNANCE_FEATURE_FLAG + + +@pytest.fixture(autouse=True) +def _enable_governance(): + """These tests exercise the post-FF-gate dispose path.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + yield + FeatureFlags.reset_flags() + + +class _Holder: + """Mutable attribute holder whose setattr can be configured to raise.""" + + def __init__(self) -> None: + self._raise_on_setattr = False + self._setattr_count = 0 + # Pre-create the attr so the test isn't tripped by the agent + # restore path being the "first" set. + object.__setattr__(self, "agent", None) + + def __setattr__(self, name: str, value) -> None: # type: ignore[no-untyped-def] + if name in {"_raise_on_setattr", "_setattr_count"}: + object.__setattr__(self, name, value) + return + object.__setattr__(self, "_setattr_count", self._setattr_count + 1) + if self._raise_on_setattr: + raise RuntimeError("restore failed") + object.__setattr__(self, name, value) + + +class _Delegate: + """Minimal delegate with an instrumented dispose().""" + + def __init__(self, *, raises: bool = False) -> None: + self._dispose_count = 0 + self._raises = raises + + async def dispose(self) -> None: + self._dispose_count += 1 + if self._raises: + raise RuntimeError("delegate dispose failed") + + +def _make_runtime( + *, + restore_raises: bool = False, + uninstall_raises: bool = False, + delegate_dispose_raises: bool = False, + monkeypatch: pytest.MonkeyPatch | None = None, +) -> tuple[GovernanceRuntime, _Holder, _Delegate, dict[str, int]]: + """Build a runtime whose three dispose steps each have a tunable failure.""" + counters = {"uninstall": 0} + delegate = _Delegate(raises=delegate_dispose_raises) + holder = _Holder() + holder._raise_on_setattr = restore_raises + + runtime = GovernanceRuntime(delegate=delegate, context=None, runtime_id="rid-1") + + # Inject the original-agent scaffolding so dispose tries to restore. + runtime._original_agent = object() + runtime._agent_attr_name = "agent" + runtime._agent_holder = holder + # Bind a fresh token so dispose has something to reset. + runtime._model_name_token = _current_model_name.set("model-x") + + def _fake_uninstall(_agent) -> None: + counters["uninstall"] += 1 + if uninstall_raises: + raise RuntimeError("uninstall failed") + + assert monkeypatch is not None + monkeypatch.setattr(wrapper_mod, "uninstall_delegation_guard", _fake_uninstall) + + return runtime, holder, delegate, counters + + +def test_dispose_runs_all_steps_when_each_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime, holder, delegate, counters = _make_runtime(monkeypatch=monkeypatch) + asyncio.run(runtime.dispose()) + assert holder._setattr_count == 1, "agent restore should have run once" + assert counters["uninstall"] == 1 + assert delegate._dispose_count == 1 + assert runtime._model_name_token is None, "token must be cleared after dispose" + + +def test_dispose_continues_when_restore_step_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Restore failure must not skip uninstall, var reset, or delegate dispose.""" + runtime, holder, delegate, counters = _make_runtime( + restore_raises=True, monkeypatch=monkeypatch, + ) + asyncio.run(runtime.dispose()) + assert holder._setattr_count == 1, "restore was attempted" + assert counters["uninstall"] == 1, "uninstall must still run" + assert delegate._dispose_count == 1, "delegate dispose must still run" + assert runtime._model_name_token is None + + +def test_dispose_continues_when_uninstall_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Uninstall failure must not skip var reset or delegate dispose.""" + runtime, holder, delegate, counters = _make_runtime( + uninstall_raises=True, monkeypatch=monkeypatch, + ) + asyncio.run(runtime.dispose()) + assert holder._setattr_count == 1 + assert counters["uninstall"] == 1 + assert delegate._dispose_count == 1 + assert runtime._model_name_token is None + + +def test_dispose_propagates_delegate_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Delegate dispose failure surfaces to the caller — that's the contract.""" + runtime, holder, delegate, counters = _make_runtime( + delegate_dispose_raises=True, monkeypatch=monkeypatch, + ) + with pytest.raises(RuntimeError, match="delegate dispose failed"): + asyncio.run(runtime.dispose()) + # All preceding governance steps must still have run. + assert holder._setattr_count == 1 + assert counters["uninstall"] == 1 + assert delegate._dispose_count == 1 + assert runtime._model_name_token is None diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py new file mode 100644 index 0000000..8ff394a --- /dev/null +++ b/tests/test_enforcement_mode_default.py @@ -0,0 +1,87 @@ +"""Tests for the default enforcement-mode resolution. + +The default is :attr:`EnforcementMode.DISABLED` — until the policy +loader successfully fetches a backend response and calls +``set_enforcement_mode`` with the server-supplied value, governance +short-circuits cheaply with no per-call audit overhead. + +Resolution order (per :func:`get_enforcement_mode`): +1. Previously-cached programmatic value (set via ``set_enforcement_mode``). +2. ``UIPATH_GOVERNANCE_MODE`` env var. +3. Default ``DISABLED``. +""" + +from __future__ import annotations + +import pytest + +from uipath.runtime.governance.config import ( + EnforcementMode, + get_enforcement_mode, + reset_enforcement_mode, + set_enforcement_mode, +) + + +@pytest.fixture(autouse=True) +def _isolate_mode(monkeypatch: pytest.MonkeyPatch): + """Each test starts from a clean module-state slate.""" + monkeypatch.delenv("UIPATH_GOVERNANCE_MODE", raising=False) + reset_enforcement_mode() + yield + reset_enforcement_mode() + + +def test_default_mode_is_disabled() -> None: + """No programmatic mode + no env var → DISABLED. + + Replaces the prior AUDIT default. Empty-policy / failed-fetch / + pre-fetch tenants pay zero audit overhead until the backend + explicitly enables governance on the next policy fetch. + """ + assert get_enforcement_mode() is EnforcementMode.DISABLED + + +def test_env_var_audit_wins_over_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Developer override via env var still works.""" + monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "audit") + reset_enforcement_mode() # clear cached default + assert get_enforcement_mode() is EnforcementMode.AUDIT + + +def test_env_var_enforce_wins_over_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "enforce") + reset_enforcement_mode() + assert get_enforcement_mode() is EnforcementMode.ENFORCE + + +def test_invalid_env_var_falls_back_to_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "garbage-value") + reset_enforcement_mode() + assert get_enforcement_mode() is EnforcementMode.DISABLED + + +def test_programmatic_set_wins_over_env_and_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The policy loader's ``set_enforcement_mode`` call is canonical.""" + monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "audit") + set_enforcement_mode(EnforcementMode.ENFORCE) + assert get_enforcement_mode() is EnforcementMode.ENFORCE + + +def test_reset_returns_to_default() -> None: + """``reset_enforcement_mode`` clears the cache so the default re-applies.""" + set_enforcement_mode(EnforcementMode.ENFORCE) + assert get_enforcement_mode() is EnforcementMode.ENFORCE + reset_enforcement_mode() + assert get_enforcement_mode() is EnforcementMode.DISABLED + + +def test_disabled_mode_is_cached_after_first_read() -> None: + """First call computes; subsequent calls hit the cache.""" + assert get_enforcement_mode() is EnforcementMode.DISABLED + # A second call returns the same instance — the cache survives. + assert get_enforcement_mode() is EnforcementMode.DISABLED diff --git a/tests/test_evaluator.py b/tests/test_evaluator.py new file mode 100644 index 0000000..d57e2de --- /dev/null +++ b/tests/test_evaluator.py @@ -0,0 +1,401 @@ +"""Tests for the audit + enforcement behavior of GovernanceEvaluator. + +The evaluator owns three responsibilities that used to be scattered +across wrapper.py and adapter callbacks: + +1. DISABLED enforcement mode short-circuits — no rules evaluated, no + audit events emitted, no exceptions raised. +2. AUDIT mode evaluates rules and emits audit events, but transforms + matched DENY actions into AUDIT so execution continues. +3. ENFORCE mode evaluates, emits audit, and raises + :class:`GovernanceBlockException` when a DENY rule matches. + +Plus a fail-safe contract: a misbehaving audit sink must not stop +evaluation from completing or propagate as an exception. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest +from uipath.core.governance.exceptions import GovernanceBlockException +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.audit import ( + AuditEvent, + AuditSink, + EventType, + get_audit_manager, + reset_audit_manager, +) +from uipath.runtime.governance.config import ( + EnforcementMode, + reset_enforcement_mode, + set_enforcement_mode, +) +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, +) + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +class _CapturingSink(AuditSink): + """Audit sink that records every event for assertions.""" + + def __init__(self) -> None: + self.events: list[AuditEvent] = [] + + @property + def name(self) -> str: + return "capturing" + + def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +def _deny_rule_on_input_contains(needle: str) -> Rule: + """Build a rule that DENIES when agent_input contains ``needle``.""" + return Rule( + rule_id="TEST-01", + name="Test deny on input", + clause="A.1.1", + hook=LifecycleHook.BEFORE_AGENT, + action=Action.DENY, + checks=[ + Check( + conditions=[ + Condition( + operator="contains", + field="agent_input", + value=needle, + ) + ], + action=Action.DENY, + message=f"Input must not contain {needle!r}", + ) + ], + ) + + +def _build_index_with(rule: Rule) -> PolicyIndex: + """Wrap a single rule in a one-pack PolicyIndex.""" + idx = PolicyIndex() + idx.add_pack( + PolicyPack( + name="test_pack", + version="1.0", + description="test", + rules=[rule], + ) + ) + return idx + + +def _ctx(agent_input: str) -> CheckContext: + return CheckContext( + hook=LifecycleHook.BEFORE_AGENT, + agent_name="test-agent", + runtime_id="run-1", + trace_id="trace-1", + agent_input=agent_input, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def capturing_audit(): + """Replace the global audit manager with a fresh one wired to a capturing sink. + + Yields the sink so tests can inspect emitted events. Restores the + global manager on teardown. + """ + reset_audit_manager() + manager = get_audit_manager() + # Default sinks (traces / console) are noisy here — drop them. + for existing_name in list(manager.list_sinks()): + manager.unregister_sink(existing_name) + sink = _CapturingSink() + manager.register_sink(sink) + # Force synchronous emission so assertions don't race the worker thread. + manager._async_mode = False + yield sink + reset_audit_manager() + + +@pytest.fixture(autouse=True) +def _reset_enforcement_mode(): + """Each test gets a clean enforcement-mode slate.""" + reset_enforcement_mode() + yield + reset_enforcement_mode() + + +# --------------------------------------------------------------------------- +# DISABLED mode +# --------------------------------------------------------------------------- + + +def test_disabled_mode_short_circuits_with_empty_record(capturing_audit): + """DISABLED returns an empty AuditRecord and emits nothing.""" + set_enforcement_mode(EnforcementMode.DISABLED) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("secret")) + ) + + audit = evaluator.evaluate(_ctx("definitely contains secret")) + + assert audit.evaluations == [] + assert audit.final_action == Action.ALLOW + assert audit.metadata["enforcement_mode"] == "disabled" + assert capturing_audit.events == [] + + +def test_disabled_mode_does_not_raise_on_deny_match(capturing_audit): + """Even when a DENY rule WOULD match, DISABLED never raises.""" + set_enforcement_mode(EnforcementMode.DISABLED) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("blocked")) + ) + + # Must not raise. + evaluator.evaluate(_ctx("this is blocked")) + + +# --------------------------------------------------------------------------- +# AUDIT mode +# --------------------------------------------------------------------------- + + +def test_audit_mode_transforms_deny_to_audit(capturing_audit): + """AUDIT mode evaluates rules but never returns a DENY final_action.""" + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("secret")) + ) + + audit = evaluator.evaluate(_ctx("contains secret data")) + + assert len(audit.evaluations) == 1 + assert audit.evaluations[0].matched is True + assert audit.evaluations[0].action == Action.DENY # raw rule action preserved + assert audit.final_action == Action.AUDIT # mode-adjusted + assert audit.metadata["audit_mode_would_deny"] is True + + +def test_audit_mode_does_not_raise_on_deny_match(capturing_audit): + """AUDIT mode never raises GovernanceBlockException, even on a DENY hit.""" + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("blocked")) + ) + + evaluator.evaluate(_ctx("this is blocked")) # must not raise + + +def test_audit_mode_emits_per_rule_and_summary_events(capturing_audit): + """One rule_evaluation event per rule + one hook_summary per evaluate().""" + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("secret")) + ) + + evaluator.evaluate(_ctx("contains secret")) + + rule_events = [ + e for e in capturing_audit.events if e.event_type == EventType.RULE_EVALUATION + ] + summary_events = [ + e for e in capturing_audit.events if e.event_type == EventType.HOOK_END + ] + assert len(rule_events) == 1 + assert rule_events[0].hook == "BEFORE_AGENT" + assert rule_events[0].data["rule_id"] == "TEST-01" + assert rule_events[0].data["matched"] is True + assert rule_events[0].data["action"] == "deny" + + assert len(summary_events) == 1 + assert summary_events[0].data["matched_rules"] == 1 + assert summary_events[0].data["final_action"] == "audit" + assert summary_events[0].data["enforcement_mode"] == "audit" + + +def test_audit_mode_unmatched_rule_logged_as_allow(capturing_audit): + """Unmatched rules still emit a rule_evaluation event with action='allow'.""" + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("secret")) + ) + + evaluator.evaluate(_ctx("benign user query")) + + rule_events = [ + e for e in capturing_audit.events if e.event_type == EventType.RULE_EVALUATION + ] + assert len(rule_events) == 1 + assert rule_events[0].data["matched"] is False + assert rule_events[0].data["action"] == "allow" + + +# --------------------------------------------------------------------------- +# ENFORCE mode +# --------------------------------------------------------------------------- + + +def test_enforce_mode_raises_on_deny_match(capturing_audit): + """ENFORCE mode raises GovernanceBlockException when a DENY rule matches.""" + set_enforcement_mode(EnforcementMode.ENFORCE) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("blocked")) + ) + + with pytest.raises(GovernanceBlockException) as exc_info: + evaluator.evaluate(_ctx("input is blocked")) + + exc = exc_info.value + assert exc.rule_id == "TEST-01" + assert exc.rule_name == "Test deny on input" + assert exc.audit_record is not None + assert exc.audit_record.final_action == Action.DENY + + +def test_enforce_mode_emits_audit_before_raising(capturing_audit): + """The audit trail must be emitted even when the call raises.""" + set_enforcement_mode(EnforcementMode.ENFORCE) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("blocked")) + ) + + with pytest.raises(GovernanceBlockException): + evaluator.evaluate(_ctx("contains blocked")) + + rule_events = [ + e for e in capturing_audit.events if e.event_type == EventType.RULE_EVALUATION + ] + summary_events = [ + e for e in capturing_audit.events if e.event_type == EventType.HOOK_END + ] + assert len(rule_events) == 1 + assert summary_events[0].data["final_action"] == "deny" + assert summary_events[0].data["enforcement_mode"] == "enforce" + + +def test_enforce_mode_returns_record_when_no_rule_matches(capturing_audit): + """No DENY hit → no raise; the AuditRecord is returned normally.""" + set_enforcement_mode(EnforcementMode.ENFORCE) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("blocked")) + ) + + audit = evaluator.evaluate(_ctx("benign query")) + + assert audit.final_action == Action.ALLOW + assert audit.evaluations[0].matched is False + + +# --------------------------------------------------------------------------- +# Sink-failure isolation +# --------------------------------------------------------------------------- + + +def test_sink_failure_does_not_propagate_or_block_evaluation(capturing_audit): + """A broken sink must not make evaluate() raise or lose its return value. + + The contract: AuditManager wraps each sink's emit() in try/except with + a per-sink failure counter (circuit-breaker), so an exception inside a + sink never propagates back to the evaluator. + """ + + class _BrokenSink(AuditSink): + @property + def name(self) -> str: + return "broken" + + def emit(self, event: AuditEvent) -> None: + raise RuntimeError("sink broke") + + manager = get_audit_manager() + manager.register_sink(_BrokenSink()) + + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("secret")) + ) + + # Must complete without raising even with a broken sink registered. + audit = evaluator.evaluate(_ctx("contains secret")) + + assert audit.final_action == Action.AUDIT + # The non-broken capturing sink still got its events. + assert any( + e.event_type == EventType.RULE_EVALUATION for e in capturing_audit.events + ) + + +def test_unavailable_audit_manager_is_swallowed(): + """If get_audit_manager() itself raises, _emit_audit must swallow it.""" + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator( + _build_index_with(_deny_rule_on_input_contains("secret")) + ) + + with patch( + "uipath.runtime.governance.native.evaluator.get_audit_manager", + side_effect=RuntimeError("manager unavailable"), + ): + # Must complete, return record, and not raise. + audit = evaluator.evaluate(_ctx("contains secret")) + + assert audit.final_action == Action.AUDIT + assert audit.evaluations[0].matched is True + + +# --------------------------------------------------------------------------- +# Protocol conformance smoke test +# --------------------------------------------------------------------------- + + +def test_governance_evaluator_satisfies_evaluator_protocol(): + """GovernanceEvaluator must be usable wherever EvaluatorProtocol is expected. + + Mirrors the pattern from test_detached_bridge_satisfies_debug_protocol — + an explicit assignment to the protocol-typed variable documents the + structural contract. + """ + from uipath.core.adapters import EvaluatorProtocol + + evaluator: EvaluatorProtocol = GovernanceEvaluator(PolicyIndex()) + assert isinstance(evaluator, EvaluatorProtocol) + + +def test_evaluator_protocol_methods_resolvable_on_concrete(): + """Every method the protocol declares must be callable on the concrete impl.""" + from uipath.core.adapters import EvaluatorProtocol + + evaluator: Any = GovernanceEvaluator(PolicyIndex()) + for method_name in ( + "evaluate_before_agent", + "evaluate_after_agent", + "evaluate_before_model", + "evaluate_after_model", + "evaluate_tool_call", + "evaluate_after_tool", + ): + assert callable(getattr(evaluator, method_name)) + # The variable annotation also asserts type compatibility at runtime + # because EvaluatorProtocol is @runtime_checkable. + assert isinstance(evaluator, EvaluatorProtocol) diff --git a/tests/test_guardrail_compensation.py b/tests/test_guardrail_compensation.py new file mode 100644 index 0000000..6ee968b --- /dev/null +++ b/tests/test_guardrail_compensation.py @@ -0,0 +1,771 @@ +"""Tests for compensating governance calls to /runtime/govern. + +The compensating call is fire-and-forget: the server runs the disabled +guardrail AND writes the audit trace itself, so we don't parse the +response. These tests cover: + +- payload + header composition, +- URL resolution off the shared backend base URL, +- error swallowing (no exception escapes, warning is logged), +- evaluator integration (a fired ``guardrail_fallback`` rule kicks off + the call on a background daemon thread). +""" + +from __future__ import annotations + +import json +import threading +import time +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.config import ( + EnforcementMode, + reset_enforcement_mode, + set_enforcement_mode, +) +from uipath.runtime.governance.native import guardrail_compensation +from uipath.runtime.governance.native.backend_client import ( + USER_AGENT, + governance_request_headers, +) +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator +from uipath.runtime.governance.native.guardrail_compensation import ( + disabled_guardrails, + request_governance, +) +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_response(status: int = 200) -> MagicMock: + """urlopen()-compatible context manager mock.""" + response = MagicMock() + response.status = status + response.read.return_value = b"" # body is not consumed by fire-and-forget + response.__enter__.return_value = response + response.__exit__.return_value = False + return response + + +def _rules(*validators: str, rule_id: str = "R1", rule_name: str = "n", pack: str = "p"): + """Build the per-rule metadata list the compensation API now takes.""" + return [ + { + "ruleId": rule_id, + "ruleName": rule_name, + "packName": pack, + "validator": v, + } + for v in validators + ] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_enforcement_mode(): + reset_enforcement_mode() + yield + reset_enforcement_mode() + + +@pytest.fixture +def _govern_env(monkeypatch): + """Provide the env vars that request_governance requires. + + The compensating call mirrors the policy fetch — it skips when + ``UIPATH_ORGANIZATION_ID`` / ``UIPATH_TENANT_ID`` / + ``UIPATH_ACCESS_TOKEN`` are missing (sending without a bearer + token would generate a guaranteed 401 per call). Tests that need + the network path to actually fire must opt into this fixture. + """ + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "appsdev") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-xyz") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "test-token") + yield + + +# --------------------------------------------------------------------------- +# Shared header helper (lives in backend_client; covered here because it's +# the wire shape both the compensation POST and the policy GET share) +# --------------------------------------------------------------------------- + + +def test_governance_request_headers_get_shape(monkeypatch): + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + headers = governance_request_headers() + assert headers == {"Accept": "application/json", "User-Agent": USER_AGENT} + + +def test_governance_request_headers_post_shape(monkeypatch): + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + headers = governance_request_headers(json_body=True) + assert headers == { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + } + + +def test_governance_request_headers_includes_authorization_when_token_set( + monkeypatch, +): + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "abc.def.ghi") + headers = governance_request_headers(json_body=True) + assert headers["Authorization"] == "Bearer abc.def.ghi" + + +def test_governance_request_headers_user_agent_is_browser_shaped(monkeypatch): + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + headers = governance_request_headers() + assert headers["User-Agent"].startswith("Mozilla/5.0") + assert "Chrome/" in headers["User-Agent"] + + +# --------------------------------------------------------------------------- +# request_governance — fire-and-forget contract +# --------------------------------------------------------------------------- + + +def test_request_governance_empty_types_short_circuits_without_call(): + with patch.object( + guardrail_compensation.urllib.request, "urlopen" + ) as mock_urlopen: + result = request_governance( + [], {}, "before_model", "t1", "2026-06-06T00:00:00Z", "agent", "rt" + ) + assert result is None + mock_urlopen.assert_not_called() + + +def test_request_governance_posts_expected_payload_and_returns_none( + monkeypatch, _govern_env +): + rules = [ + { + "ruleId": "R-PII", + "ruleName": "PII guardrail", + "packName": "AITL", + "validator": "pii_detection", + }, + { + "ruleId": "R-HARM", + "ruleName": "Harmful content", + "packName": "AITL", + "validator": "harmful_content", + }, + ] + # Job context is resolved from UiPathConfig/env at call time; pin it so + # the assertion is deterministic and exercises the new payload keys. + monkeypatch.setattr( + guardrail_compensation, + "resolve_job_context", + lambda: {"folderKey": "folder-1", "jobKey": "job-1"}, + ) + with patch.object( + guardrail_compensation.urllib.request, + "urlopen", + return_value=_mock_response(), + ) as mock_urlopen: + result = request_governance( + rules, + {"content": "hello"}, + "before_model", + "trace-1", + "2026-06-06T00:00:00Z", + "langchain", + "patch-langchain", + ) + + assert result is None # fire-and-forget + + request_arg = mock_urlopen.call_args.args[0] + assert request_arg.get_method() == "POST" + + sent = json.loads(request_arg.data.decode("utf-8")) + assert sent == { + # distinct validators drive the guardrail API call + "type": ["pii_detection", "harmful_content"], + # per-rule metadata drives one trace record per rule + "rules": rules, + "data": {"content": "hello"}, + "hook": "before_model", + "traceId": "trace-1", + "src_timestamp": "2026-06-06T00:00:00Z", + "agentName": "langchain", + "runtimeId": "patch-langchain", + "folderKey": "folder-1", + "jobKey": "job-1", + } + + +def test_request_governance_sends_shared_headers(_govern_env): + """Headers must come from the shared helper — UA + Accept + Content-Type + Auth.""" + with patch.object( + guardrail_compensation.urllib.request, + "urlopen", + return_value=_mock_response(), + ) as mock_urlopen: + request_governance( + _rules("x"), {}, "before_model", "t", "ts", "a", "r" + ) + + request_arg = mock_urlopen.call_args.args[0] + # urllib title-cases header keys on the Request object. + assert request_arg.get_header("Accept") == "application/json" + assert request_arg.get_header("Content-type") == "application/json" + assert request_arg.get_header("User-agent") == USER_AGENT + # Bearer is required (see ``test_request_governance_skipped_when_token_missing``). + assert request_arg.get_header("Authorization") == "Bearer test-token" + # Tenant header must travel on the compensating POST (same as the + # policy GET) — the agenticgovernance ingress validates it. + assert request_arg.get_header("X-uipath-internal-tenantid") == "tenant-xyz" + + +def test_request_governance_includes_bearer_token_when_set(monkeypatch, _govern_env): + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "the-token") + with patch.object( + guardrail_compensation.urllib.request, + "urlopen", + return_value=_mock_response(), + ) as mock_urlopen: + request_governance(_rules("x"), {}, "before_model", "t", "ts", "a", "r") + + request_arg = mock_urlopen.call_args.args[0] + assert request_arg.get_header("Authorization") == "Bearer the-token" + + +def test_request_governance_skipped_when_token_missing(monkeypatch): + """Missing bearer → skip cleanly instead of sending a guaranteed-401 request. + + Sending without a token would produce a 401 per compensation event + and pollute logs. Mirrors the org-id / tenant-id skip paths above. + """ + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "appsdev") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-xyz") + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + with patch.object( + guardrail_compensation.urllib.request, "urlopen" + ) as mock_urlopen: + request_governance(_rules("x"), {}, "before_model", "t", "ts", "a", "r") + assert not mock_urlopen.called, ( + "request_governance must NOT POST when bearer token is missing" + ) + + +def test_request_governance_skipped_when_org_id_missing(monkeypatch): + """Without an org id, we cannot build the URL — skip the call entirely.""" + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-xyz") + with patch.object( + guardrail_compensation.urllib.request, "urlopen" + ) as mock_urlopen: + request_governance(_rules("x"), {}, "before_model", "t", "ts", "a", "r") + mock_urlopen.assert_not_called() + + +def test_request_governance_skipped_when_tenant_id_missing(monkeypatch): + """Without a tenant id, the server's tenant header would be invalid.""" + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "appsdev") + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + with patch.object( + guardrail_compensation.urllib.request, "urlopen" + ) as mock_urlopen: + request_governance(_rules("x"), {}, "before_model", "t", "ts", "a", "r") + mock_urlopen.assert_not_called() + + +def test_request_governance_swallows_network_error(_govern_env): + """A network error must not propagate. (Log emission is logger-config + dependent and is verified manually — the test-isolation behavior of + pytest's caplog conflicts with the runtime's log interceptor.)""" + with patch.object( + guardrail_compensation.urllib.request, + "urlopen", + side_effect=OSError("connection refused"), + ): + result = request_governance( + _rules("pii_detection"), + {}, + "before_model", + "t", + "ts", + "langchain", + "patch-langchain", + ) + + assert result is None + + +def test_request_governance_swallows_unexpected_exception(_govern_env): + """Even a programmer-error inside urlopen must not propagate.""" + with patch.object( + guardrail_compensation.urllib.request, + "urlopen", + side_effect=RuntimeError("boom"), + ): + assert ( + request_governance(_rules("x"), {}, "before_model", "t", "ts", "a", "r") + is None + ) + + +def test_request_governance_does_not_read_response_body(_govern_env): + """Fire-and-forget: we must not consume the response body.""" + response = _mock_response() + with patch.object( + guardrail_compensation.urllib.request, "urlopen", return_value=response + ): + request_governance(_rules("x"), {}, "before_model", "t", "ts", "a", "r") + response.read.assert_not_called() + + +def test_request_governance_url_is_org_scoped(monkeypatch, _govern_env): + """URL must include the org segment and the agenticgovernance_ prefix. + + Mirrors the policy fetch URL shape — the agenticgovernance ingress + requires both segments; without them the request lands on a route + that doesn't exist (404 / wrong service). + """ + monkeypatch.delenv("UIPATH_GOVERNANCE_BACKEND_URL", raising=False) + monkeypatch.setenv("UIPATH_URL", "https://cloud.uipath.com/my-org/my-tenant") + with patch.object( + guardrail_compensation.urllib.request, + "urlopen", + return_value=_mock_response(), + ) as mock_urlopen: + request_governance(_rules("x"), {}, "before_model", "t", "ts", "a", "r") + + # org_id="appsdev" comes from the _govern_env fixture, not from UIPATH_URL + # (UiPathConfig.organization_id is honoured first — same as policy). + assert ( + mock_urlopen.call_args.args[0].full_url + == "https://cloud.uipath.com/appsdev/agenticgovernance_/api/v1/runtime/govern" + ) + + +# --------------------------------------------------------------------------- +# submit_compensation — bounded background pool +# --------------------------------------------------------------------------- + + +def test_submit_compensation_empty_types_short_circuits(): + """submit_compensation with no types is a no-op (no semaphore taken).""" + from uipath.runtime.governance.native.guardrail_compensation import ( + submit_compensation, + ) + + # Patch the executor to a MagicMock so we'd notice any spurious submit. + with patch.object(guardrail_compensation, "_pool") as mock_pool: + submit_compensation([], {}, "before_model", "t", "ts", "a", "r") + mock_pool.submit.assert_not_called() + + +def test_submit_compensation_routes_through_pool(): + """A non-empty types list submits a single task to the pool.""" + from uipath.runtime.governance.native.guardrail_compensation import ( + submit_compensation, + ) + + with patch.object(guardrail_compensation, "_pool") as mock_pool: + submit_compensation( + _rules("pii_detection"), + {"content": "x"}, + "before_model", + "trace-1", + "ts", + "agent", + "run", + ) + mock_pool.submit.assert_called_once() + + +def test_submit_compensation_drops_when_pool_saturated(monkeypatch): + """When the in-flight semaphore is exhausted, the call is dropped + logged.""" + from uipath.runtime.governance.native.guardrail_compensation import ( + submit_compensation, + ) + + # Force the semaphore into "exhausted" state. + drained = threading.BoundedSemaphore(1) + drained.acquire() # value is now 0; next acquire(blocking=False) returns False + monkeypatch.setattr(guardrail_compensation, "_inflight", drained) + + with patch.object(guardrail_compensation, "_pool") as mock_pool: + submit_compensation( + _rules("pii_detection"), + {}, + "before_model", + "trace-1", + "ts", + "agent", + "run", + ) + + mock_pool.submit.assert_not_called() + + +def test_submit_compensation_swallows_pool_shutdown_runtimeerror(monkeypatch): + """If the pool was shut down at process exit, submit must not raise.""" + from uipath.runtime.governance.native.guardrail_compensation import ( + submit_compensation, + ) + + # Fresh semaphore so we don't taint other tests. + monkeypatch.setattr( + guardrail_compensation, "_inflight", threading.BoundedSemaphore(4) + ) + + class _ShutdownPool: + def submit(self, fn, *args, **kwargs): # noqa: ARG002 + raise RuntimeError("cannot schedule new futures after shutdown") + + monkeypatch.setattr(guardrail_compensation, "_pool", _ShutdownPool()) + + # Must not raise. + submit_compensation( + _rules("x"), {}, "before_model", "t", "ts", "a", "r" + ) + + +# --------------------------------------------------------------------------- +# disabled_guardrails +# --------------------------------------------------------------------------- + + +def test_disabled_guardrails_extracts_validators_for_fired_rules(): + cond = SimpleNamespace( + operator="guardrail_fallback", + value={ + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": False, + }, + ) + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])]) + audit = SimpleNamespace( + evaluations=[ + SimpleNamespace(matched=True, rule_id="R1", rule_name="PII guardrail") + ] + ) + policy_index = SimpleNamespace( + get_rule=lambda rid: rule if rid == "R1" else None + ) + + assert disabled_guardrails(audit, policy_index) == [ + { + "ruleId": "R1", + "ruleName": "PII guardrail", + "packName": "", + "validator": "pii_detection", + } + ] + + +def test_disabled_guardrails_skips_unmatched_evaluations(): + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=False, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: None) + assert disabled_guardrails(audit, policy_index) == [] + + +def test_disabled_guardrails_skips_non_guardrail_conditions(): + cond = SimpleNamespace(operator="regex", value="some-pattern") + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])]) + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: rule) + assert disabled_guardrails(audit, policy_index) == [] + + +# --------------------------------------------------------------------------- +# Evaluator integration: a guardrail_fallback rule kicks off the compensation +# --------------------------------------------------------------------------- + + +def _guardrail_fallback_rule() -> Rule: + """A rule whose only check is a guardrail_fallback condition. + + Mirrors what ``_build_check`` produces for a YAML + ``type: guardrail_fallback`` entry with the guardrail mapped to + UiPath but disabled. + """ + return Rule( + rule_id="UIP-GR-01", + name="PII guardrail (UiPath-mapped, disabled)", + clause="UiPath-Mapped Guardrail", + hook=LifecycleHook.BEFORE_MODEL, + action=Action.AUDIT, + checks=[ + Check( + conditions=[ + Condition( + operator="guardrail_fallback", + field="", + value={ + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": False, + }, + ) + ], + action=Action.AUDIT, + message="PII guardrail disabled", + ) + ], + ) + + +def _build_index_with(rule: Rule) -> PolicyIndex: + idx = PolicyIndex() + idx.add_pack( + PolicyPack( + name="test_pack", + version="1.0", + description="test", + rules=[rule], + ) + ) + return idx + + +def test_evaluator_dispatches_compensation_for_fired_guardrail(): + """A matched guardrail_fallback rule must trigger request_governance.""" + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator(_build_index_with(_guardrail_fallback_rule())) + + called = threading.Event() + captured: dict[str, Any] = {} + + def _spy(**kwargs: Any) -> None: + captured.update(kwargs) + called.set() + + ctx = CheckContext( + hook=LifecycleHook.BEFORE_MODEL, + agent_name="agent-x", + runtime_id="run-1", + trace_id="trace-1", + model_input="contact jane@acme.com", + ) + + with patch( + "uipath.runtime.governance.native.evaluator.submit_compensation", _spy + ): + audit = evaluator.evaluate(ctx) + + assert called.wait(timeout=1.0), ( + "Expected request_governance to be called on a background thread" + ) + + assert audit.final_action == Action.AUDIT + assert audit.rules_matched == 1 + assert captured["rules"] == [ + { + "ruleId": "UIP-GR-01", + "ruleName": "PII guardrail (UiPath-mapped, disabled)", + "packName": "test_pack", + "validator": "pii_detection", + } + ] + assert captured["data"] == {"content": "contact jane@acme.com"} + assert captured["hook"] == "before_model" + assert captured["trace_id"] == "trace-1" + assert captured["agent_name"] == "agent-x" + assert captured["runtime_id"] == "run-1" + assert isinstance(captured["src_timestamp"], str) + assert "T" in captured["src_timestamp"] + + +def test_evaluator_does_not_dispatch_when_guardrail_is_enabled(): + rule = _guardrail_fallback_rule() + rule.checks[0].conditions[0].value["policy_enabled"] = True # type: ignore[index] + + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator(_build_index_with(rule)) + + called = threading.Event() + + def _spy(**kwargs: Any) -> None: + called.set() + + ctx = CheckContext( + hook=LifecycleHook.BEFORE_MODEL, + agent_name="agent-x", + runtime_id="run-1", + trace_id="trace-1", + model_input="hi", + ) + + with patch( + "uipath.runtime.governance.native.evaluator.submit_compensation", _spy + ): + audit = evaluator.evaluate(ctx) + time.sleep(0.05) + + assert not called.is_set() + assert audit.rules_matched == 0 + + +def test_evaluator_does_not_dispatch_when_not_mapped_to_uipath(): + rule = _guardrail_fallback_rule() + rule.checks[0].conditions[0].value["mapped_to_uipath"] = False # type: ignore[index] + rule.checks[0].conditions[0].value["policy_enabled"] = False # type: ignore[index] + + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator(_build_index_with(rule)) + + called = threading.Event() + + def _spy(**kwargs: Any) -> None: + called.set() + + ctx = CheckContext( + hook=LifecycleHook.BEFORE_MODEL, + agent_name="agent-x", + runtime_id="run-1", + trace_id="trace-1", + model_input="hi", + ) + + with patch( + "uipath.runtime.governance.native.evaluator.submit_compensation", _spy + ): + evaluator.evaluate(ctx) + time.sleep(0.05) + + assert not called.is_set() + + +def test_evaluator_compensation_dispatch_swallows_thread_errors(): + """If request_governance raises, the background thread must absorb it.""" + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator(_build_index_with(_guardrail_fallback_rule())) + + def _raising_spy(**kwargs: Any) -> None: + raise RuntimeError("network down") + + ctx = CheckContext( + hook=LifecycleHook.BEFORE_MODEL, + agent_name="agent-x", + runtime_id="run-1", + trace_id="trace-1", + model_input="hi", + ) + + with patch( + "uipath.runtime.governance.native.evaluator.submit_compensation", + _raising_spy, + ): + audit = evaluator.evaluate(ctx) + time.sleep(0.05) + + assert audit.final_action == Action.AUDIT + assert audit.rules_matched == 1 + + +def test_evaluator_does_not_emit_audit_trace_for_guardrail_fallback_rule(): + """Python must not emit a per-rule audit trace for ``guardrail_fallback``. + + The governance-server emits the trace in response to the + ``/runtime/govern`` POST; emitting one here too would produce a + duplicate. The rule still appears in the AuditRecord (so + ``disabled_guardrails`` can find it) and the compensation thread + still fires — only the per-rule ``rule_evaluation`` event is + suppressed, and the hook summary's counts exclude it. + """ + from uipath.runtime.governance.audit import ( + AuditEvent, + AuditSink, + EventType, + get_audit_manager, + reset_audit_manager, + ) + + class _CapturingSink(AuditSink): + def __init__(self) -> None: + self.events: list[AuditEvent] = [] + + @property + def name(self) -> str: + return "capturing" + + def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + reset_audit_manager() + try: + manager = get_audit_manager() + for existing in list(manager.list_sinks()): + manager.unregister_sink(existing) + sink = _CapturingSink() + manager.register_sink(sink) + manager._async_mode = False # synchronous emission for assertions + + set_enforcement_mode(EnforcementMode.AUDIT) + evaluator = GovernanceEvaluator( + _build_index_with(_guardrail_fallback_rule()) + ) + + ctx = CheckContext( + hook=LifecycleHook.BEFORE_MODEL, + agent_name="agent-x", + runtime_id="run-1", + trace_id="trace-1", + model_input="hi", + ) + + # Stub the network call so it doesn't actually post; we're + # asserting on the Python-emitted trace events, not on whether + # /runtime/govern was reached. + with patch( + "uipath.runtime.governance.native.evaluator.submit_compensation", + lambda **kwargs: None, + ): + audit = evaluator.evaluate(ctx) + time.sleep(0.05) # let the daemon thread land + + # The rule still matched and is in the audit record … + assert audit.rules_matched == 1 + assert any( + ev.matched and ev.rule_id == "UIP-GR-01" for ev in audit.evaluations + ) + + # … but NO rule_evaluation event for it was emitted by Python. + rule_events = [ + e for e in sink.events if e.event_type == EventType.RULE_EVALUATION + ] + assert not any( + e.data.get("rule_id") == "UIP-GR-01" for e in rule_events + ), "guardrail_fallback rule must not emit a Python-side audit trace" + + # The hook summary's counts must also exclude the fallback rule + # (so total_rules / matched_rules match what was actually emitted). + summaries = [ + e for e in sink.events if e.event_type == EventType.HOOK_END + ] + assert len(summaries) == 1 + assert summaries[0].data["total_rules"] == 0 + assert summaries[0].data["matched_rules"] == 0 + finally: + reset_audit_manager() diff --git a/tests/test_policy_agent_type.py b/tests/test_policy_agent_type.py new file mode 100644 index 0000000..4eb30f9 --- /dev/null +++ b/tests/test_policy_agent_type.py @@ -0,0 +1,99 @@ +"""Tests for the conversational-vs-autonomous agent-type selector. + +The governance wrapper records whether the hosted agent is conversational; +the policy fetch then appends an ``agentType`` query param so the server's +clause-resolver reads the matching container key (``*-in-flight-agents`` vs +``*-in-flight-conversational-agents``). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from uipath.runtime.governance.native import backend_client +from uipath.runtime.governance.native.backend_client import ( + agent_type_param, + set_agent_conversational, +) +from uipath.runtime.governance.native.policy_api_client import build_policy_url +from uipath.runtime.governance.wrapper import GovernanceRuntime + + +def _extract(delegate, context=None) -> bool: + """Call _extract_is_conversational without running __init__.""" + runtime = object.__new__(GovernanceRuntime) + return runtime._extract_is_conversational(delegate, context) + + +@pytest.fixture(autouse=True) +def _reset_selector(): + """Clear the process-level selector around each test.""" + set_agent_conversational(None) + yield + set_agent_conversational(None) + + +def test_agent_type_param_unset_is_none(): + assert agent_type_param() is None + + +def test_agent_type_param_conversational(): + set_agent_conversational(True) + assert agent_type_param() == "conversational" + + +def test_agent_type_param_autonomous(): + set_agent_conversational(False) + assert agent_type_param() == "autonomous" + + +def test_build_policy_url_omits_param_when_unset(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + url = build_policy_url("my-org") + assert url == "https://alpha.uipath.com/my-org/agenticgovernance_/api/v1/runtime/policy" + assert "agentType" not in url + + +def test_build_policy_url_appends_conversational(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + set_agent_conversational(True) + assert build_policy_url("my-org").endswith( + "/my-org/agenticgovernance_/api/v1/runtime/policy?agentType=conversational" + ) + + +def test_build_policy_url_appends_autonomous(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + set_agent_conversational(False) + assert build_policy_url("my-org").endswith("?agentType=autonomous") + + +# ── _extract_is_conversational ────────────────────────────────────────────── + + +def test_extract_conversational_from_agent_definition(): + delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + assert _extract(delegate) is True + + +def test_extract_autonomous_from_agent_definition(): + delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) + assert _extract(delegate) is False + + +def test_extract_unwraps_delegate_chain(): + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + outer = SimpleNamespace(_delegate=inner) # no _agent_definition on the outer + assert _extract(outer) is True + + +def test_extract_falls_back_to_context_conversation_id(): + delegate = SimpleNamespace() # nothing reachable + context = SimpleNamespace(conversation_id="conv-1") + assert _extract(delegate, context) is True + + +def test_extract_defaults_to_autonomous_when_unknown(): + assert _extract(SimpleNamespace(), SimpleNamespace()) is False \ No newline at end of file diff --git a/tests/test_registry.py b/tests/test_registry.py index 86eda5b..2aa7694 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,10 +1,15 @@ +import sys import tempfile +import types from pathlib import Path from typing import Any, AsyncGenerator, Optional, cast +from unittest.mock import MagicMock import pytest +from uipath.core.feature_flags import FeatureFlags from uipath.runtime import ( + GOVERNANCE_FEATURE_FLAG, UiPathExecuteOptions, UiPathRuntimeContext, UiPathRuntimeEvent, @@ -18,6 +23,7 @@ UiPathRuntimeStorageProtocol, UiPathStreamOptions, ) +from uipath.runtime.registry import UiPathWrappedRuntimeFactory class MockStorage(UiPathRuntimeStorageProtocol): @@ -236,7 +242,7 @@ def create_factory( UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") - factory = UiPathRuntimeFactoryRegistry.get(name="langgraph") + factory = UiPathRuntimeFactoryRegistry.get(name="langgraph", apply_wrappers=False) assert isinstance(factory, MockLangGraphFactory) assert factory.name == "langgraph" @@ -252,7 +258,9 @@ def create_factory( UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") context = UiPathRuntimeContext.with_defaults(entrypoint="test") - factory = UiPathRuntimeFactoryRegistry.get(name="langgraph", context=context) + factory = UiPathRuntimeFactoryRegistry.get( + name="langgraph", context=context, apply_wrappers=False + ) assert isinstance(factory, MockLangGraphFactory) assert factory.context == context @@ -284,7 +292,9 @@ def create_langgraph( Path(temp_dir, "langgraph.json").touch() - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir, apply_wrappers=False + ) assert isinstance(factory, MockLangGraphFactory) @@ -309,7 +319,9 @@ def create_llamaindex( Path(temp_dir, "llamaindex.json").touch() - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir, apply_wrappers=False + ) assert isinstance(factory, MockLlamaIndexFactory) @@ -334,7 +346,9 @@ def create_langgraph( Path(temp_dir, "uipath.json").touch() - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir, apply_wrappers=False + ) assert isinstance(factory, MockFunctionsFactory) @@ -357,7 +371,9 @@ def create_langgraph( ) UiPathRuntimeFactoryRegistry.set_default("functions") - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir, apply_wrappers=False + ) assert isinstance(factory, MockFunctionsFactory) @@ -399,7 +415,9 @@ def create_langgraph( Path(temp_dir, "uipath.json").touch() Path(temp_dir, "langgraph.json").touch() - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir, apply_wrappers=False + ) assert isinstance(factory, MockLangGraphFactory) @@ -430,7 +448,7 @@ def create_factory( UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") - factory = UiPathRuntimeFactoryRegistry.get(name="langgraph") + factory = UiPathRuntimeFactoryRegistry.get(name="langgraph", apply_wrappers=False) runtime = await factory.new_runtime("agent", "runtime-1") assert isinstance(runtime, MockRuntime) assert runtime.name == "langgraph-agent" @@ -450,3 +468,155 @@ def create_factory( all_factories["malicious"] = "hack.json" assert "malicious" not in UiPathRuntimeFactoryRegistry.get_all() + + +# --------------------------------------------------------------------------- +# Wrapping behaviour (apply_wrappers=True) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _reset_feature_flags(): + FeatureFlags.reset_flags() + yield + FeatureFlags.reset_flags() + + +@pytest.fixture +def fake_governance_module(monkeypatch): + """Install a stub ``uipath.runtime.governance.wrapper`` for the lazy import. + + ``apply_governance_wrapper`` imports ``governance_wrapper`` only when + the FF is on; this fixture lets us assert it was called without + triggering the real governance runtime (which would try to talk to + the policy backend). + """ + mock_wrapper = MagicMock(name="governance_wrapper") + module = types.ModuleType("uipath.runtime.governance.wrapper") + module.governance_wrapper = mock_wrapper + monkeypatch.setitem(sys.modules, "uipath.runtime.governance.wrapper", module) + return mock_wrapper + + +def test_get_returns_wrapped_factory_by_default(clean_registry): + """``get`` with default args wraps the registered factory.""" + + def create_factory( + context: UiPathRuntimeContext | None = None, + ) -> UiPathRuntimeFactoryProtocol: + return MockLangGraphFactory(context) + + UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") + + factory = UiPathRuntimeFactoryRegistry.get(name="langgraph") + + assert isinstance(factory, UiPathWrappedRuntimeFactory) + assert isinstance(factory.inner, MockLangGraphFactory) + + +def test_wrapped_factory_attribute_fallthrough(clean_registry): + """Non-protocol attributes on the delegate are reachable via ``__getattr__``.""" + + def create_factory( + context: UiPathRuntimeContext | None = None, + ) -> UiPathRuntimeFactoryProtocol: + return MockLangGraphFactory(context) + + UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") + + factory = UiPathRuntimeFactoryRegistry.get(name="langgraph") + + # `name` is not part of the protocol but is defined on the concrete factory. + assert factory.name == "langgraph" # type: ignore[attr-defined] + + +def test_wrapped_factory_delegates_protocol_methods(clean_registry): + """Protocol methods reach the underlying factory unchanged.""" + + def create_factory( + context: UiPathRuntimeContext | None = None, + ) -> UiPathRuntimeFactoryProtocol: + return MockLangGraphFactory(context) + + UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") + + factory = UiPathRuntimeFactoryRegistry.get(name="langgraph") + + assert factory.discover_entrypoints() == ["agent", "workflow"] + + +@pytest.mark.asyncio +async def test_wrapped_factory_returns_inner_runtime_when_flag_off( + clean_registry, _reset_feature_flags, monkeypatch +): + """With the governance FF off, ``new_runtime`` returns the bare runtime.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: False}) + monkeypatch.delenv("UIPATH_FEATURE_" + GOVERNANCE_FEATURE_FLAG, raising=False) + + def create_factory( + context: UiPathRuntimeContext | None = None, + ) -> UiPathRuntimeFactoryProtocol: + return MockLangGraphFactory(context) + + UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") + + factory = UiPathRuntimeFactoryRegistry.get(name="langgraph") + runtime = await factory.new_runtime("agent", "runtime-1") + + assert isinstance(runtime, MockRuntime) + assert runtime.name == "langgraph-agent" + + +@pytest.mark.asyncio +async def test_wrapped_factory_invokes_governance_when_flag_on( + clean_registry, _reset_feature_flags, fake_governance_module +): + """With the governance FF on, ``new_runtime`` routes through ``governance_wrapper``.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + + governed_sentinel = MagicMock(name="GovernedRuntime") + fake_governance_module.return_value = governed_sentinel + + captured: dict[str, Any] = {} + + def create_factory( + context: UiPathRuntimeContext | None = None, + ) -> UiPathRuntimeFactoryProtocol: + captured["context"] = context + return MockLangGraphFactory(context) + + UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") + + context = UiPathRuntimeContext.with_defaults(entrypoint="agent") + factory = UiPathRuntimeFactoryRegistry.get(name="langgraph", context=context) + + result = await factory.new_runtime("agent", "runtime-42") + + assert result is governed_sentinel + fake_governance_module.assert_called_once() + call_args = fake_governance_module.call_args + # signature: governance_wrapper(runtime, context, runtime_id) + assert isinstance(call_args.args[0], MockRuntime) + assert call_args.args[1] is context + assert call_args.args[2] == "runtime-42" + + +@pytest.mark.asyncio +async def test_wrapped_factory_swallows_governance_exception( + clean_registry, _reset_feature_flags, fake_governance_module +): + """Governance failures must never break runtime creation.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + fake_governance_module.side_effect = RuntimeError("policy load failed") + + def create_factory( + context: UiPathRuntimeContext | None = None, + ) -> UiPathRuntimeFactoryProtocol: + return MockLangGraphFactory(context) + + UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") + + factory = UiPathRuntimeFactoryRegistry.get(name="langgraph") + runtime = await factory.new_runtime("agent", "runtime-1") + + assert isinstance(runtime, MockRuntime) diff --git a/tests/test_text_extraction.py b/tests/test_text_extraction.py new file mode 100644 index 0000000..50a15df --- /dev/null +++ b/tests/test_text_extraction.py @@ -0,0 +1,301 @@ +"""Tests for ``_extract_governable_text`` content extraction. + +Replaces the old ``str(value)[:2000]`` path in ``_check_before_agent`` +and ``_check_after_agent``. Pulls clean text out of structured shapes +(dicts, list-of-blocks, pydantic models) instead of letting dict-repr +noise leak into the regex-scanned blob. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from uipath.runtime.governance.wrapper import ( + _GOVERNANCE_TEXT_CAP, + _extract_governable_text, +) + + +def test_plain_string_passes_through() -> None: + assert _extract_governable_text("hello world") == "hello world" + + +def test_none_returns_empty() -> None: + assert _extract_governable_text(None) == "" + + +def test_dict_with_content_key_extracts_content_first() -> None: + """The classic coded-agent output shape — content comes through clean.""" + out = _extract_governable_text( + {"content": "Estimated cost: $780", "_meta": {"id": "abc"}} + ) + assert out.startswith("Estimated cost: $780") + # No dict-syntax noise — the prior str(...) path produced ``{'content': '...'}``. + assert "{'content'" not in out + assert "'_meta'" not in out + + +def test_dict_priority_keys_lead() -> None: + """``content`` / ``text`` / etc. lead before remaining keys.""" + out = _extract_governable_text( + {"trailing_meta": "noise-meta", "content": "primary-text"} + ) + assert out.index("primary-text") < out.index("noise-meta") + + +def test_list_of_text_blocks_concatenates() -> None: + """Anthropic-style content blocks.""" + out = _extract_governable_text( + [ + {"type": "text", "text": "first part"}, + {"type": "image", "source": {"data": "..."}}, + {"type": "text", "text": "second part"}, + ] + ) + assert "first part" in out + assert "second part" in out + + +def test_openai_function_call_shape_extracts_arguments() -> None: + """``arguments`` field on OpenAI-style function-call blocks.""" + out = _extract_governable_text( + [ + { + "type": "function_call", + "name": "end_execution", + "arguments": '{"content":"Cost: $1,200"}', + "id": "fc_abc", + } + ] + ) + assert "Cost: $1,200" in out + + +def test_numeric_scalars_are_skipped() -> None: + """Numbers / booleans aren't governance text — they shouldn't pad the blob.""" + out = _extract_governable_text( + {"content": "hello", "count": 42, "ok": True, "rate": 3.14} + ) + assert out == "hello" + + +def test_pydantic_like_model_dump_is_walked() -> None: + """Anything with ``model_dump()`` is walked as its dict form.""" + + class Stub: + def model_dump(self) -> dict: + return {"content": "from pydantic"} + + assert _extract_governable_text(Stub()) == "from pydantic" + + +def test_dataclass_via_dict_method() -> None: + """Objects exposing a ``dict()`` callable also walk via that path.""" + + class Stub: + def dict(self) -> dict: + return {"content": "from dict"} + + assert _extract_governable_text(Stub()) == "from dict" + + +def test_plain_object_attribute_fallback() -> None: + """Public attributes on opaque objects feed the walker.""" + + @dataclass + class Result: + content: str + _private: str = "ignored" + + out = _extract_governable_text(Result(content="visible")) + assert "visible" in out + assert "ignored" not in out + + +def test_cycle_in_structure_does_not_recurse_forever() -> None: + a: dict = {"content": "outer"} + b: dict = {"loop": a} + a["loop"] = b + # Should return without recursing infinitely. + out = _extract_governable_text(a) + assert "outer" in out + + +def test_text_is_capped_at_budget() -> None: + """Long content is truncated so a runaway payload can't dominate scans.""" + big = "x" * (_GOVERNANCE_TEXT_CAP + 1000) + out = _extract_governable_text(big) + assert len(out) == _GOVERNANCE_TEXT_CAP + + +def test_nested_dict_content_extracted() -> None: + """LangGraph-style state with messages nested under a key.""" + out = _extract_governable_text( + { + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "Cost: $50"}, + ] + } + ) + assert "Cost: $50" in out + + +def test_unknown_block_type_with_no_text_returns_empty() -> None: + """Image-only block with no text payload contributes nothing.""" + out = _extract_governable_text( + [{"type": "image", "source": {"type": "base64", "data": "..."}}] + ) + # Could be empty or contain just the base64 data — but should NOT + # contain Python dict syntax characters that the old path emitted. + assert "{'type'" not in out + + +# --------------------------------------------------------------------------- +# Budget — 64K is the current cap (raised from 8K to fit multi-turn chat). +# --------------------------------------------------------------------------- + + +def test_budget_cap_is_64k() -> None: + """Documents the cap so a future drop won't go unnoticed.""" + assert _GOVERNANCE_TEXT_CAP == 64000 + + +# --------------------------------------------------------------------------- +# Reverse list iteration — latest entry gets the budget first. +# --------------------------------------------------------------------------- + + +def test_lists_are_walked_in_reverse() -> None: + """Latest list entry leads the extracted blob. + + Critical for chat history: the new user message lives at the end of + the messages list and must be visible even when prior turns would + otherwise fill the budget first. + """ + out = _extract_governable_text( + [{"text": "earliest"}, {"text": "middle"}, {"text": "latest"}] + ) + assert out.index("latest") < out.index("middle") < out.index("earliest") + + +def test_long_chat_history_keeps_latest_user_message() -> None: + """A long history must not push the latest message out of the budget. + + Regression for the prior 8K-cap + forward-walk combination, which + silently dropped the latest user message once the conversation + grew past ~7,800 chars of prior content. + """ + bulky_prior = "x" * 2000 + messages = [{"role": "user", "content": bulky_prior}] * 40 # ~80K chars + messages.append({"role": "user", "content": "Cost: $1,200 — latest"}) + + out = _extract_governable_text({"messages": messages}) + assert "Cost: $1,200 — latest" in out + + +# --------------------------------------------------------------------------- +# latest_only — BEFORE_AGENT in a conversational agent +# --------------------------------------------------------------------------- + + +def test_latest_only_extracts_just_the_last_list_item() -> None: + """``latest_only=True`` drops every list entry but the last one.""" + out = _extract_governable_text( + { + "messages": [ + {"role": "user", "content": "old message"}, + {"role": "assistant", "content": "old response"}, + {"role": "user", "content": "Cost: $1,200"}, + ] + }, + latest_only=True, + ) + assert "Cost: $1,200" in out + assert "old message" not in out + assert "old response" not in out + + +def test_latest_only_resets_inside_chosen_item() -> None: + """Multi-block content inside the latest message is still walked fully. + + ``latest_only`` reduces the OUTER list (chat history) to its last + entry, but multi-block content (text + tool_call + thinking) + inside that latest message must still be extracted in full — + otherwise we'd lose answer text that arrives in a non-final block. + """ + out = _extract_governable_text( + { + "messages": [ + {"role": "user", "content": "old"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "part A"}, + { + "type": "function_call", + "arguments": '{"answer":"part B"}', + }, + ], + }, + ] + }, + latest_only=True, + ) + assert "part A" in out + assert "part B" in out + assert "old" not in out + + +def test_latest_only_top_level_list() -> None: + """``latest_only`` applies when the input itself is a list.""" + out = _extract_governable_text( + [ + {"content": "history item 1"}, + {"content": "history item 2"}, + {"content": "latest input"}, + ], + latest_only=True, + ) + assert "latest input" in out + assert "history item 1" not in out + assert "history item 2" not in out + + +def test_latest_only_default_false_still_walks_all() -> None: + """Default behavior unchanged — AFTER_AGENT etc. still see everything.""" + out = _extract_governable_text( + { + "messages": [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ] + } + ) + assert "first" in out + assert "second" in out + + +def test_latest_only_empty_list_is_empty() -> None: + """Empty history → empty extraction.""" + assert _extract_governable_text({"messages": []}, latest_only=True) == "" + + +def test_messages_is_a_priority_content_key() -> None: + """``messages`` (plural) leads ahead of non-priority keys. + + Without ``messages`` in the priority list, an input that also + carries siblings like ``thread_id`` / ``metadata`` could siphon + budget before the actual chat history is walked. + """ + out = _extract_governable_text( + { + "thread_id": "abc-xyz", + "metadata": {"foo": "bar"}, + "messages": [{"role": "user", "content": "primary content"}], + } + ) + assert "primary content" in out + assert out.index("primary content") < ( + out.find("abc-xyz") if "abc-xyz" in out else len(out) + ) diff --git a/tests/test_traces_severity.py b/tests/test_traces_severity.py new file mode 100644 index 0000000..2cb5e42 --- /dev/null +++ b/tests/test_traces_severity.py @@ -0,0 +1,223 @@ +"""Tests for trace-span severity / status semantics. + +``TracesAuditSink`` emits an OpenTelemetry span for every governance +hook end and every rule evaluation. The contract: + +- Matched non-allow rules carry a free-form ``severity`` span attribute + (``"ERROR"`` or ``"WARNING"``). OTel ``StatusCode`` only has OK / ERROR + / UNSET, so a separate attribute is the only way to distinguish + "audit-mode advisory violation" from "actually blocked the agent". +- ``severity = "ERROR"`` and ``StatusCode.ERROR`` fire **only** when the + runtime actually blocked the agent — enforce mode AND the rule's + action is ``deny`` or ``escalate``. +- ``severity = "WARNING"`` and ``Status.UNSET`` for advisory violations + — audit mode (any non-allow action), or audit-action rules even in + enforce mode. The agent didn't fail; surfacing Status.ERROR would + falsely paint a successful run as a failure. +- Hook spans never set Status, regardless of enforcement mode or + final_action. They're summary containers; severity belongs on the + individual rule span that fired. +- ``allow`` actions and unmatched evaluations leave Status at UNSET and + do not emit a severity attribute. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from uipath.runtime.governance.audit.base import AuditEvent, EventType +from uipath.runtime.governance.audit.traces import TracesAuditSink +from uipath.runtime.governance.config import ( + EnforcementMode, + reset_enforcement_mode, + set_enforcement_mode, +) + + +@pytest.fixture +def captured_span(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Wire ``TracesAuditSink`` to a mock tracer and return the span mock.""" + span = MagicMock(name="span") + tracer = MagicMock(name="tracer") + tracer.start_as_current_span.return_value.__enter__.return_value = span + tracer.start_as_current_span.return_value.__exit__.return_value = False + monkeypatch.setattr(TracesAuditSink, "_get_tracer", lambda self: tracer) + return span + + +@pytest.fixture(autouse=True) +def _reset_mode() -> None: + """Each test selects its own enforcement mode explicitly.""" + reset_enforcement_mode() + yield + reset_enforcement_mode() + + +def _hook_event(final_action: str, mode: str = "audit") -> AuditEvent: + return AuditEvent( + event_type=EventType.HOOK_END, + agent_name="agent", + hook="after_model", + data={ + "total_rules": 1, + "matched_rules": 1 if final_action != "allow" else 0, + "final_action": final_action, + "enforcement_mode": mode, + }, + ) + + +def _rule_event(matched: bool, action: str) -> AuditEvent: + return AuditEvent( + event_type=EventType.RULE_EVALUATION, + agent_name="agent", + hook="after_model", + data={ + "rule_id": "A.10.4", + "rule_name": "commitment-language", + "pack_name": "iso42001", + "matched": matched, + "action": action, + "status": "MATCHED" if matched else "PASS", + "detail": "Customer-binding commitment detected.", + }, + ) + + +def _severity_attr_calls(span: MagicMock) -> dict[str, str]: + """Return a mapping of attribute name → value for set_attribute calls.""" + attrs: dict[str, str] = {} + for call in span.set_attribute.call_args_list: + key, value = call.args + attrs[key] = value + return attrs + + +# --------------------------------------------------------------------------- +# Hook span — never marked ERROR +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "final_action,mode", + [ + ("deny", "enforce"), + ("deny", "audit"), + ("audit", "audit"), + ("escalate", "audit"), + ("allow", "audit"), + ], +) +def test_hook_span_never_sets_error( + captured_span: MagicMock, final_action: str, mode: str +) -> None: + """Hook spans are summary containers — they never carry an ERROR Status.""" + sink = TracesAuditSink() + sink.emit(_hook_event(final_action=final_action, mode=mode)) + assert not captured_span.set_status.called, ( + f"Hook span should never set_status; called with " + f"final_action={final_action!r}, mode={mode!r}" + ) + + +# --------------------------------------------------------------------------- +# Rule span — enforce-mode actually-blocking violations +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("action", ["deny", "escalate"]) +def test_enforce_mode_blocking_violation_is_error( + captured_span: MagicMock, action: str +) -> None: + """Enforce mode + deny/escalate = real failure → severity=ERROR + Status.ERROR.""" + set_enforcement_mode(EnforcementMode.ENFORCE) + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action=action)) + + attrs = _severity_attr_calls(captured_span) + assert attrs.get("severity") == "ERROR" + assert attrs.get("governance.severity") == "ERROR" + + assert captured_span.set_status.called, ( + f"Status.ERROR must fire for enforce-mode {action} violation" + ) + status_code, message = captured_span.set_status.call_args.args + from opentelemetry.trace import StatusCode + + assert status_code is StatusCode.ERROR + assert "commitment-language" in message + assert action in message + + +# --------------------------------------------------------------------------- +# Rule span — advisory violations (audit mode, or audit-action rules) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("action", ["deny", "audit", "escalate"]) +def test_audit_mode_violation_is_warning( + captured_span: MagicMock, action: str +) -> None: + """Audit mode never blocks → severity=WARNING, Status.UNSET. + + Surfacing Status.ERROR for an audit-mode violation would falsely + mark the agent's run as failed when the runtime intentionally + let it through. + """ + set_enforcement_mode(EnforcementMode.AUDIT) + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action=action)) + + attrs = _severity_attr_calls(captured_span) + assert attrs.get("severity") == "WARNING" + assert attrs.get("governance.severity") == "WARNING" + + assert not captured_span.set_status.called, ( + f"Audit-mode {action} violation must NOT set Status.ERROR" + ) + + +def test_enforce_mode_audit_action_is_warning(captured_span: MagicMock) -> None: + """Enforce mode + action=audit is still advisory → severity=WARNING. + + An ``audit`` action means "log this match but don't block" even + when the policy is in enforce mode. The runtime doesn't block; + severity stays WARNING. + """ + set_enforcement_mode(EnforcementMode.ENFORCE) + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="audit")) + + attrs = _severity_attr_calls(captured_span) + assert attrs.get("severity") == "WARNING" + assert not captured_span.set_status.called + + +# --------------------------------------------------------------------------- +# Rule span — no violation, no severity attribute +# --------------------------------------------------------------------------- + + +def test_unmatched_rule_no_severity_no_error(captured_span: MagicMock) -> None: + """Unmatched evaluations are quiet: no severity attr, no Status.""" + set_enforcement_mode(EnforcementMode.ENFORCE) + sink = TracesAuditSink() + sink.emit(_rule_event(matched=False, action="deny")) + + attrs = _severity_attr_calls(captured_span) + assert "severity" not in attrs + assert "governance.severity" not in attrs + assert not captured_span.set_status.called + + +def test_matched_allow_action_no_severity(captured_span: MagicMock) -> None: + """A rule whose action is 'allow' is an explicit non-violation.""" + set_enforcement_mode(EnforcementMode.ENFORCE) + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="allow")) + + attrs = _severity_attr_calls(captured_span) + assert "severity" not in attrs + assert not captured_span.set_status.called diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py new file mode 100644 index 0000000..7e2cbab --- /dev/null +++ b/tests/test_wrapper.py @@ -0,0 +1,85 @@ +"""Tests for the governance feature-flag gate at the runtime boundary. + +The FF-resolution logic itself lives in +``uipath.core.governance.config.is_governance_enabled`` and is tested +there. These tests only exercise the runtime's thin shim: +``apply_governance_wrapper`` defers to the gate, lazy-imports core's +``governance_wrapper`` only when the gate passes, and never lets +governance failures bring down the agent run. +""" + +from __future__ import annotations + +import sys +import types +from unittest.mock import MagicMock + +import pytest +from uipath.core.feature_flags import FeatureFlags + +from uipath.runtime.wrapper import ( + GOVERNANCE_FEATURE_FLAG, + apply_governance_wrapper, +) + + +@pytest.fixture(autouse=True) +def _reset_feature_flags(): + FeatureFlags.reset_flags() + yield + FeatureFlags.reset_flags() + + +@pytest.fixture +def fake_governance_module(monkeypatch): + """Install a stub ``uipath.runtime.governance.wrapper`` for the lazy import. + + Each test gets its own MagicMock as ``governance_wrapper`` so we can + assert call behaviour. Cleared by monkeypatch on teardown. + """ + mock_wrapper = MagicMock(name="governance_wrapper") + module = types.ModuleType("uipath.runtime.governance.wrapper") + module.governance_wrapper = mock_wrapper + + monkeypatch.setitem(sys.modules, "uipath.runtime.governance.wrapper", module) + return mock_wrapper + + +async def test_apply_governance_wrapper_returns_inner_when_flag_off( + monkeypatch, fake_governance_module +): + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: False}) + monkeypatch.delenv("UIPATH_FEATURE_" + GOVERNANCE_FEATURE_FLAG, raising=False) + inner = MagicMock(name="InnerRuntime") + + result = await apply_governance_wrapper(inner, None, "runtime-1") + + assert result is inner + fake_governance_module.assert_not_called() + + +async def test_apply_governance_wrapper_invokes_governance_when_flag_on( + fake_governance_module, +): + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + inner = MagicMock(name="InnerRuntime") + governed = MagicMock(name="GovernedRuntime") + fake_governance_module.return_value = governed + + result = await apply_governance_wrapper(inner, None, "runtime-1") + + assert result is governed + fake_governance_module.assert_called_once_with(inner, None, "runtime-1") + + +async def test_apply_governance_wrapper_swallows_wrapper_exception( + fake_governance_module, +): + """If governance_wrapper raises, fail-safe: return inner unchanged.""" + FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + fake_governance_module.side_effect = RuntimeError("boom") + inner = MagicMock(name="InnerRuntime") + + result = await apply_governance_wrapper(inner, None, "runtime-1") + + assert result is inner diff --git a/uv.lock b/uv.lock index 8a9846e..8e1530b 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-27T14:28:49.412753Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -99,6 +99,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -821,6 +947,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -975,6 +1116,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -998,16 +1148,16 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.17" +version = "0.5.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/80/a626eb3136a6765e0af06c9d5080ac0843c2a72f17b7a2170f1f45da40dd/uipath_core-0.5.17.tar.gz", hash = "sha256:13565e1eba9f059a8221494dfb3239257ddf7f265fc7057199ffe03ed066300a", size = 119023, upload-time = "2026-05-28T21:34:10.903Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b1/d4e555a1a2ccf298195a5f2968e538b0cea8592b3e03f43fc12b178d6c69/uipath_core-0.5.18.tar.gz", hash = "sha256:63ebe8bdb818ca30a4bc9ab0ea8171315680691429931282939359ce039401ab", size = 131988, upload-time = "2026-06-08T14:04:49.688Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/cf/f4b481970621e2a9aec869302773fa2c7d346aef294a553429626369633f/uipath_core-0.5.17-py3-none-any.whl", hash = "sha256:6e088eec5130bc492ac176ab85d4924d7d4cb07ee290ed7e6a46984e9de8c12b", size = 44957, upload-time = "2026-05-28T21:34:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/1a820b33f7bff4565d7649772bc54c88480ac7e70f707097f7da37d05157/uipath_core-0.5.18-py3-none-any.whl", hash = "sha256:351d6faeecfc6a0acea93182e01526f39c04a77e09fa0444be5f4fb580463f5a", size = 54572, upload-time = "2026-06-08T14:04:48.22Z" }, ] [[package]] @@ -1015,7 +1165,10 @@ name = "uipath-runtime" version = "0.11.0" source = { editable = "." } dependencies = [ + { name = "chardet" }, + { name = "pyyaml" }, { name = "uipath-core" }, + { name = "vadersentiment" }, ] [package.dev-dependencies] @@ -1031,10 +1184,16 @@ dev = [ { name = "pytest-trio" }, { name = "ruff" }, { name = "rust-just" }, + { name = "types-pyyaml" }, ] [package.metadata] -requires-dist = [{ name = "uipath-core", specifier = ">=0.5.17,<0.6.0" }] +requires-dist = [ + { name = "chardet", specifier = ">=5.2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "uipath-core", specifier = ">=0.5.18,<0.6.0" }, + { name = "vadersentiment", specifier = ">=3.3.2" }, +] [package.metadata.requires-dev] dev = [ @@ -1049,6 +1208,28 @@ dev = [ { name = "pytest-trio", specifier = ">=0.8.0" }, { name = "ruff", specifier = ">=0.9.4" }, { name = "rust-just", specifier = ">=1.39.0" }, + { name = "types-pyyaml", specifier = ">=6.0" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "vadersentiment" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" }, ] [[package]]