diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index efcce771c..8e5b97d67 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.12.4" +version = "2.12.5" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", - "uipath-runtime>=0.11.5, <0.12.0", + "uipath-runtime>=0.11.7, <0.12.0", "uipath-platform>=0.1.89, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", @@ -24,6 +24,7 @@ dependencies = [ "mermaid-builder==0.0.3", "graphtty==0.1.8", "applicationinsights>=0.11.10", + "pyyaml>=6.0, <7.0", ] classifiers = [ "Intended Audience :: Developers", @@ -75,6 +76,7 @@ dev = [ "mkdocs-llmstxt>=0.5.0", "inflection>=0.5.1", "types-toml>=0.10.8", + "types-PyYAML>=6.0", "pytest-timeout>=2.4.0", ] diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index a39123326..b3dfb160e 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -57,17 +57,23 @@ def __init__(self) -> None: self._eval_run_info: dict[str, dict[str, Any]] = {} self._current_eval_set_run_id: str | None = None self._current_entrypoint: str | None = None + self._current_agent_type: str | None = None @staticmethod - def _get_agent_type(entrypoint: str) -> str: - """Determine agent type from entrypoint. - - Args: - entrypoint: The entrypoint path. - - Returns: - "LowCode" if entrypoint is "agent.json", "Coded" otherwise. + def _resolve_agent_type(agent_type: str | None, entrypoint: str | None) -> str: + """Emit the historical ``"LowCode"`` / ``"Coded"`` wire values. + + Application Insights dashboards filter on those two exact strings. + When the factory supplies a modern label (e.g. ``"uipath_lowcode"``, + ``"uipath_coded"``) we normalize; when it supplies nothing we fall + back to the pre-refactor entrypoint check (``agent.json`` ⇒ + low-code) so no in-flight consumer breaks. """ + if agent_type is not None: + if "lowcode" in agent_type.lower() or "low_code" in agent_type.lower(): + return "LowCode" + return "Coded" + # Fallback: pre-refactor entrypoint-based derivation. if entrypoint == "agent.json": return "LowCode" return "Coded" @@ -105,6 +111,7 @@ async def _on_eval_set_run_created(self, event: EvalSetRunCreatedEvent) -> None: "eval_set_id": event.eval_set_id, "eval_set_run_id": eval_set_run_id, "entrypoint": event.entrypoint, + "agent_type": event.agent_type, "no_of_evals": event.no_of_evals, "evaluator_count": len(event.evaluators), } @@ -112,6 +119,7 @@ async def _on_eval_set_run_created(self, event: EvalSetRunCreatedEvent) -> None: # Store for child events self._current_eval_set_run_id = eval_set_run_id self._current_entrypoint = event.entrypoint + self._current_agent_type = event.agent_type properties: dict[str, Any] = { "EvalSetId": event.eval_set_id, @@ -119,7 +127,9 @@ async def _on_eval_set_run_created(self, event: EvalSetRunCreatedEvent) -> None: "Entrypoint": event.entrypoint, "EvalCount": event.no_of_evals, "EvaluatorCount": len(event.evaluators), - "AgentType": self._get_agent_type(event.entrypoint), + "AgentType": self._resolve_agent_type( + event.agent_type, event.entrypoint + ), "Runtime": "URT", } @@ -156,7 +166,9 @@ async def _on_eval_run_created(self, event: EvalRunCreatedEvent) -> None: # Add entrypoint and agent type if self._current_entrypoint: properties["Entrypoint"] = self._current_entrypoint - properties["AgentType"] = self._get_agent_type(self._current_entrypoint) + properties["AgentType"] = self._resolve_agent_type( + self._current_agent_type, self._current_entrypoint + ) self._enrich_properties(properties) @@ -207,7 +219,9 @@ async def _on_eval_run_updated(self, event: EvalRunUpdatedEvent) -> None: if self._current_entrypoint: properties["Entrypoint"] = self._current_entrypoint - properties["AgentType"] = self._get_agent_type(self._current_entrypoint) + properties["AgentType"] = self._resolve_agent_type( + self._current_agent_type, self._current_entrypoint + ) if trace_id: properties["TraceId"] = trace_id @@ -271,7 +285,9 @@ async def _on_eval_set_run_updated(self, event: EvalSetRunUpdatedEvent) -> None: if set_info.get("entrypoint"): properties["Entrypoint"] = set_info["entrypoint"] - properties["AgentType"] = self._get_agent_type(set_info["entrypoint"]) + properties["AgentType"] = self._resolve_agent_type( + set_info.get("agent_type"), set_info["entrypoint"] + ) properties["Runtime"] = "URT" @@ -299,6 +315,7 @@ async def _on_eval_set_run_updated(self, event: EvalSetRunUpdatedEvent) -> None: self._current_eval_set_run_id = None self._current_entrypoint = None + self._current_agent_type = None except Exception as e: logger.debug(f"Error tracking eval set run updated: {e}") diff --git a/packages/uipath/src/uipath/_cli/_governance/__init__.py b/packages/uipath/src/uipath/_cli/_governance/__init__.py new file mode 100644 index 000000000..fc49b92a1 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_governance/__init__.py @@ -0,0 +1,16 @@ +"""CLI-side governance helpers. + +Host-only glue that turns provider responses into inputs the runtime +consumes. Owns the YAML → :class:`PolicyIndex` compiler (the runtime +layer stays format-agnostic and only accepts a compiled index). + +Public helpers: + +- :func:`build_policy_index_from_yaml` — parse a YAML policy pack (as + returned by :meth:`GovernancePolicyProvider.get_policy_async`) into + a :class:`uipath.runtime.governance.native.PolicyIndex`. +""" + +from .yaml_index import build_policy_index_from_yaml + +__all__ = ["build_policy_index_from_yaml"] diff --git a/packages/uipath/src/uipath/_cli/_governance/yaml_index.py b/packages/uipath/src/uipath/_cli/_governance/yaml_index.py new file mode 100644 index 000000000..4da02a276 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_governance/yaml_index.py @@ -0,0 +1,532 @@ +"""YAML → :class:`PolicyIndex` compiler. + +Lives CLI-side so the runtime layer never has to depend on ``pyyaml`` +or know about the wire policy format — the runtime consumes compiled +indexes only. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Any + +import yaml + +from uipath.core.governance.models import Action, LifecycleHook +from uipath.runtime.governance.native.models import ( + Check, + Condition, + Logic, + 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 :class:`PolicyIndex`. + + Unknown check types and malformed rules are skipped with a debug log + (partial packs preferred over failing the whole load); malformed + YAML at the document level raises :class:`yaml.YAMLError`. + """ + 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 + + +# --------------------------------------------------------------------------- +# Per-check-type condition builders +# +# Each returns ``(conditions, default_message)`` given the YAML entry for +# one check. The main :func:`_build_check` picks the right builder from +# :data:`_CHECK_BUILDERS` and layers action / logic / message resolution +# on top — keeping the dispatch flat instead of one giant if/elif chain. +# --------------------------------------------------------------------------- + + +def _build_regex_conditions(data: dict[str, Any]) -> tuple[list[Condition], str]: + scope = data.get("scope", ["human", "ai"]) + field = _field_for_scope(scope) + conditions = [ + Condition(operator="regex", field=field, value=pattern) + for pattern in (data.get("patterns", []) or []) + ] + return conditions, f"Pattern matched in {scope}" + + +def _build_budget_conditions(data: dict[str, Any]) -> tuple[list[Condition], str]: + return ( + _gt_conditions_from_keys( + data, + ( + ("max_tool_calls_per_session", "session_state.tool_calls"), + ("max_tool_calls_per_minute", "session_state.tool_calls_per_minute"), + ( + "max_consecutive_tool_calls", + "session_state.consecutive_tool_calls", + ), + ), + ), + "Tool budget exceeded", + ) + + +def _build_tool_allowlist_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + blocked_tools = data.get("blocked_tools", []) or [] + conditions = ( + [Condition(operator="in_list", field="tool_name", value=blocked_tools)] + if blocked_tools + else [] + ) + return conditions, "Tool not allowed" + + +def _build_parameter_validation_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + conditions = [ + Condition(operator="regex", field="tool_args", value=pattern) + for pattern in (data.get("additional_patterns", []) or []) + ] + return conditions, "Suspicious pattern in tool parameters" + + +def _build_rate_limit_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + return ( + _gt_conditions_from_keys( + data, + ( + ("max_llm_calls_per_session", "session_state.llm_calls"), + ("max_llm_calls_per_minute", "session_state.llm_calls_per_minute"), + ), + ), + "Rate limit exceeded", + ) + + +def _build_field_regex_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + conditions = _make_conditions(data.get("conditions", []) or []) + return conditions, str(data.get("message", "Field regex check failed")) + + +def _build_data_quality_score_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + field = data.get("field", "tool_result") + conditions: list[Condition] = [] + 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)), + }, + ) + ) + return conditions, str(data.get("message", "")) + + +def _build_incident_taxonomy_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + field = data.get("field", "model_output") + categories = data.get("categories") + value: dict[str, Any] = {} + if categories: + value["categories"] = list(categories) + conditions = [Condition(operator="incident_concern", field=field, value=value)] + return conditions, str(data.get("message", "")) + + +def _build_commitment_extractor_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + field = data.get("field", "model_output") + conditions = [ + Condition( + operator="commitment_concern", + field=field, + value={ + "require_amount": bool(data.get("require_amount", True)), + "require_deadline": bool(data.get("require_deadline", False)), + }, + ) + ] + return conditions, str(data.get("message", "")) + + +def _build_sentiment_concern_conditions( + data: dict[str, Any], +) -> tuple[list[Condition], str]: + field = data.get("field", "model_input") + threshold = float(data.get("threshold", -0.3)) + conditions = [ + Condition( + operator="vader_concern", + field=field, + value={"threshold": threshold}, + ) + ] + default_msg = f"Negative sentiment detected (VADER compound <= {threshold})" + return conditions, default_msg + + +def _gt_conditions_from_keys( + data: dict[str, Any], + keys_to_fields: tuple[tuple[str, str], ...], +) -> list[Condition]: + """Emit ``gt`` conditions for each YAML key present in ``data``. + + Shared by budget/rate_limit builders — they only differ in which + (YAML key → CheckContext field) pairs they scan. + """ + return [ + Condition(operator="gt", field=field, value=data[key]) + for key, field in keys_to_fields + if key in data + ] + + +# check_type → builder. ``guardrail_fallback`` is handled inline in +# :func:`_build_check` because it needs the rule-level flags. +_CHECK_BUILDERS: dict[str, Callable[[dict[str, Any]], tuple[list[Condition], str]]] = { + "regex": _build_regex_conditions, + "budget": _build_budget_conditions, + "tool_allowlist": _build_tool_allowlist_conditions, + "parameter_validation": _build_parameter_validation_conditions, + "rate_limit": _build_rate_limit_conditions, + "field_regex": _build_field_regex_conditions, + "data_quality_score": _build_data_quality_score_conditions, + "incident_taxonomy": _build_incident_taxonomy_conditions, + "commitment_extractor": _build_commitment_extractor_conditions, + "sentiment_concern": _build_sentiment_concern_conditions, +} + + +def _build_guardrail_fallback_conditions( + data: dict[str, Any], + *, + mapped_to_uipath: bool, + policy_enabled: bool, +) -> tuple[list[Condition], str]: + """Compensating-control condition. Depends on rule-level flags. + + ``validator`` names which guardrail check the compensating call + should run. The runtime's ``guardrail_fallback`` operator fires + only when the guardrail is mapped to UiPath but disabled. + """ + conditions = [ + Condition( + operator="guardrail_fallback", + field="", + value={ + "validator": str(data.get("validator", "")), + "mapped_to_uipath": mapped_to_uipath, + "policy_enabled": policy_enabled, + }, + ) + ] + default_msg = "Guardrail disabled — compensating check needed." + return conditions, default_msg + + +def _resolve_action(data: dict[str, Any], default_action: Action) -> Action: + """Resolve the check's action against ``_ACTION_MAP`` with a default fallback.""" + action_str = data.get("action") + if not action_str: + return default_action + return _ACTION_MAP.get(action_str, default_action) + + +def _resolve_logic( + data: dict[str, Any], + *, + has_explicit_conditions: bool, + check_type: str, + n_conditions: int, +) -> Logic: + """Resolve the check's ``logic`` field with the right default. + + Multi-pattern shorthand (``regex`` / ``parameter_validation`` + expanded from several patterns for one concept) defaults to ``any`` + — any pattern hitting is a match. An explicit ``conditions:`` list + defaults to ``all`` (all must hold) and must NOT inherit the + pattern-shorthand OR even though ``check_type`` falls back to + ``"regex"``. Explicit ``logic`` in the YAML always wins. + """ + if ( + not has_explicit_conditions + and check_type in ("parameter_validation", "regex") + and n_conditions > 1 + ): + default_logic = "any" + else: + default_logic = "all" + logic_str = str(data.get("logic", default_logic)).lower() + try: + return Logic(logic_str) + except ValueError: + return Logic.ALL + + +def _has_explicit_conditions(raw_conditions: Any) -> bool: + """A ``conditions:`` list is explicit when it holds dicts with ``operator:``.""" + return ( + isinstance(raw_conditions, list) + and bool(raw_conditions) + and isinstance(raw_conditions[0], dict) + and "operator" in raw_conditions[0] + ) + + +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. + + Delegates per-check-type condition-building to the small helpers + above (dispatched via :data:`_CHECK_BUILDERS`); the ``guardrail_fallback`` + branch is inline because it needs the rule-level + ``mapped_to_uipath`` / ``policy_enabled`` flags threaded in from + :func:`_build_rule`. Unknown check types are skipped. + """ + raw_conditions = data.get("conditions") + has_explicit_conditions = _has_explicit_conditions(raw_conditions) + check_type = data.get("type", "regex") + + if has_explicit_conditions: + assert isinstance(raw_conditions, list) # narrowed by _has_explicit_conditions + conditions = list(_make_conditions(raw_conditions)) + message = str(data.get("message", "")) + elif check_type == "guardrail_fallback": + conditions, message = _build_guardrail_fallback_conditions( + data, + mapped_to_uipath=mapped_to_uipath, + policy_enabled=policy_enabled, + ) + else: + builder = _CHECK_BUILDERS.get(check_type) + if builder is None: + logger.debug("Skipping check: unknown type %r", check_type) + return None + conditions, message = builder(data) + + if not conditions: + return None + + action = _resolve_action(data, default_action) + message = str(data.get("message", message)) + logic = _resolve_logic( + data, + has_explicit_conditions=has_explicit_conditions, + check_type=check_type, + n_conditions=len(conditions), + ) + 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/packages/uipath/src/uipath/_cli/_governance_bootstrap.py b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py new file mode 100644 index 000000000..3ceaf69c7 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py @@ -0,0 +1,189 @@ +"""Shared host-side governance bootstrap for ``uipath run`` / ``uipath debug``. + +Framework and agent-type labels are forwarded from +:class:`UiPathRuntimeFactorySettings` — each factory advertises its +own; the CLI never classifies the runtime. +""" + +from __future__ import annotations + +import atexit +import json +import logging +from collections.abc import Callable +from dataclasses import dataclass + +from uipath.core.governance import EnforcementMode, PolicyContext +from uipath.core.governance.config import is_governance_enabled +from uipath.platform import UiPath +from uipath.platform.common import UiPathConfig +from uipath.platform.governance import UiPathPlatformGovernanceProvider +from uipath.platform.governance._live_track_event_dispatcher import ( + LiveTrackEventDispatcher, +) +from uipath.runtime import UiPathRuntimeProtocol +from uipath.runtime.governance._audit.base import AuditManager +from uipath.runtime.governance._audit.metadata import GovernanceRuntimeMetadata +from uipath.runtime.governance.native import GovernanceEvaluator +from uipath.runtime.governance.native.guardrail_compensation import ( + GuardrailCompensator, +) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.runtime import UiPathGovernedRuntime + +from ._governance import build_policy_index_from_yaml +from ._utils._console import ConsoleLogger + +console = ConsoleLogger() +logger = logging.getLogger(__name__) + +__all__ = [ + "GovernanceBootstrap", + "read_is_conversational", + "resolve_governance", +] + + +@dataclass(frozen=True, slots=True) +class GovernanceBootstrap: + """Governance wiring for one CLI run. + + ``dispose`` is idempotent, never raises, and drains the track-event + dispatcher; call it from a ``finally``. An :mod:`atexit` fallback + covers the case where the caller misses it. + """ + + evaluator: GovernanceEvaluator + policy_index: PolicyIndex + enforcement_mode: EnforcementMode + dispose: Callable[[], None] + + def wrap_runtime( + self, + delegate: UiPathRuntimeProtocol, + *, + agent_name: str, + runtime_id: str, + ) -> UiPathGovernedRuntime: + """Wrap a delegate runtime with governance evaluation.""" + return UiPathGovernedRuntime( + delegate, + policy_index=self.policy_index, + enforcement_mode=self.enforcement_mode, + evaluator=self.evaluator, + agent_name=agent_name, + runtime_id=runtime_id, + ) + + +def read_is_conversational() -> bool | None: + """Read ``runtimeOptions.isConversational`` from the agent's uipath.json. + + Returns ``None`` when the file or field is missing — callers pass + that through to :class:`PolicyContext` unchanged. + """ + try: + with open(UiPathConfig.config_file_path) as f: + data = json.load(f) + runtime_options = data.get("runtimeOptions") or {} + value = runtime_options.get("isConversational") + return value if isinstance(value, bool) else None + except FileNotFoundError: + return None + except Exception as exc: + logger.debug("Failed to read isConversational from uipath.json: %s", exc) + return None + + +async def resolve_governance( + *, + agent_framework: str | None, + agent_type: str | None, +) -> GovernanceBootstrap | None: + """Fetch policy + build the governance stack, or ``None`` when disabled. + + ``agent_framework`` and ``agent_type`` are forwarded from + :class:`UiPathRuntimeFactorySettings` and stamped on every audit + event; ``None`` becomes ``"unknown"``. + """ + if not is_governance_enabled(): + return None + + context = PolicyContext(is_conversational=read_is_conversational()) + + try: + sdk = UiPath() + provider = UiPathPlatformGovernanceProvider(service=sdk.governance) + response = await provider.get_policy_async(context) + except Exception as exc: + console.warning( + f"Governance policy fetch failed - continuing without governance: {exc}" + ) + return None + + if response.mode is None or response.mode == EnforcementMode.DISABLED: + return None + if not response.policies: + return None + + try: + policy_index = build_policy_index_from_yaml(response.policies) + except Exception as exc: + console.warning( + f"Governance policy compilation failed - continuing without governance: {exc}" + ) + return None + + # The dispatcher below owns a background thread + atexit hook, so + # every failure path from here on must run ``dispose``. + track_event_dispatcher: LiveTrackEventDispatcher | None = None + + def dispose() -> None: + # Called from CLI ``finally`` — must never raise. + dispatcher = track_event_dispatcher + if dispatcher is None: + return + try: + atexit.unregister(dispatcher.shutdown) + except Exception: + logger.debug("atexit.unregister failed", exc_info=True) + try: + dispatcher.shutdown() + except Exception: + logger.debug("dispatcher shutdown failed", exc_info=True) + + try: + track_event_dispatcher = LiveTrackEventDispatcher(provider) + atexit.register(track_event_dispatcher.shutdown) + + compensator = GuardrailCompensator(provider) + audit_manager = AuditManager( + track_event=track_event_dispatcher.dispatch, + runtime_metadata=GovernanceRuntimeMetadata( + agent_type=agent_type or "unknown", + agent_framework=agent_framework or "unknown", + ), + ) + evaluator = GovernanceEvaluator( + policy_index, + enforcement_mode=response.mode, + audit_manager=audit_manager, + compensator=compensator, + ) + console.info( + f"Governance enabled (mode={response.mode.value}, " + f"packs={list(policy_index.pack_names)})" + ) + except Exception as exc: + dispose() + console.warning( + f"Governance setup failed - continuing without governance: {exc}" + ) + return None + + return GovernanceBootstrap( + evaluator=evaluator, + policy_index=policy_index, + enforcement_mode=response.mode, + dispose=dispose, + ) diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 1e2df4770..69e5d913f 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -1,6 +1,6 @@ import asyncio import logging -from typing import cast, get_args +from typing import Any, cast, get_args import click @@ -27,6 +27,7 @@ from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugRuntime from uipath.tracing import LiveTrackingSpanProcessor, LlmOpsHttpExporter +from ._governance_bootstrap import GovernanceBootstrap, resolve_governance from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -136,6 +137,8 @@ async def execute_debug_runtime(): ) with ExecutionSourceContext(ctx.execution_source), ctx: factory: UiPathRuntimeFactoryProtocol | None = None + governance_bootstrap: GovernanceBootstrap | None = None + live_tracking_processor: LiveTrackingSpanProcessor | None = None try: trigger_poll_interval: float = 5.0 @@ -147,14 +150,30 @@ async def execute_debug_runtime(): if factory_settings else None ) + agent_type = ( + factory_settings.agent_type if factory_settings else None + ) + agent_framework = ( + factory_settings.agent_framework + if factory_settings + else None + ) + governance_bootstrap = await resolve_governance( + agent_framework=agent_framework, + agent_type=agent_type, + ) + governance_runtime_id = ( + ctx.conversation_id or ctx.job_id or "default" + ) if ctx.job_id: if UiPathConfig.is_tracing_enabled: + live_tracking_processor = LiveTrackingSpanProcessor( + LlmOpsHttpExporter(), + settings=trace_settings, + ) trace_manager.add_span_processor( - LiveTrackingSpanProcessor( - LlmOpsHttpExporter(), - settings=trace_settings, - ) + live_tracking_processor ) trigger_poll_interval = ( 0.0 # Polling disabled for production jobs @@ -165,11 +184,24 @@ async def execute_debug_runtime(): debug_bridge: UiPathDebugProtocol = get_debug_bridge( ctx, attach=attach_mode ) + new_runtime_kwargs: dict[str, Any] = {} + if governance_bootstrap is not None: + new_runtime_kwargs["evaluator"] = ( + governance_bootstrap.evaluator + ) runtime = await factory.new_runtime( entrypoint, - ctx.conversation_id or ctx.job_id or "default", + governance_runtime_id, + **new_runtime_kwargs, ) + if governance_bootstrap is not None: + runtime = governance_bootstrap.wrap_runtime( + runtime, + agent_name=entrypoint, + runtime_id=governance_runtime_id, + ) + delegate = runtime if ctx.conversation_id and ctx.exchange_id: chat_bridge: UiPathChatProtocol = get_chat_bridge( @@ -227,6 +259,13 @@ async def execute_debug_runtime(): await execute_debug_runtime() finally: + # Drain runtime-scoped sinks before the factory + # shuts down — the factory may own transports they + # use. (The inner runtime already disposed above.) + if live_tracking_processor is not None: + live_tracking_processor.shutdown() + if governance_bootstrap is not None: + governance_bootstrap.dispose() if factory: await factory.dispose() diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 9a8571d03..59cc294cf 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -1,4 +1,5 @@ import asyncio +from typing import Any import click from pydantic import ValidationError @@ -34,6 +35,7 @@ ) from ._errors import EntrypointDiscoveryException +from ._governance_bootstrap import GovernanceBootstrap, resolve_governance from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -218,6 +220,8 @@ async def execute() -> None: runtime: UiPathRuntimeProtocol | None = None chat_runtime: UiPathRuntimeProtocol | None = None factory: UiPathRuntimeFactoryProtocol | None = None + governance_bootstrap: GovernanceBootstrap | None = None + live_tracking_processor: LiveTrackingSpanProcessor | None = None try: factory = UiPathRuntimeFactoryRegistry.get(context=ctx) @@ -235,10 +239,42 @@ async def execute() -> None: if factory_settings else None ) + agent_type = ( + factory_settings.agent_type + if factory_settings + else None + ) + agent_framework = ( + factory_settings.agent_framework + if factory_settings + else None + ) + governance_bootstrap = await resolve_governance( + agent_framework=agent_framework, + agent_type=agent_type, + ) + governance_runtime_id = ( + ctx.conversation_id or ctx.job_id or "default" + ) + new_runtime_kwargs: dict[str, Any] = {} + if governance_bootstrap is not None: + new_runtime_kwargs["evaluator"] = ( + governance_bootstrap.evaluator + ) + base_runtime = await factory.new_runtime( resolved_entrypoint, - ctx.conversation_id or ctx.job_id or "default", + governance_runtime_id, + **new_runtime_kwargs, ) + + if governance_bootstrap is not None: + base_runtime = governance_bootstrap.wrap_runtime( + base_runtime, + agent_name=resolved_entrypoint, + runtime_id=governance_runtime_id, + ) + runtime = base_runtime if simulation_config: @@ -259,11 +295,12 @@ async def execute() -> None: if ctx.job_id: if UiPathConfig.is_tracing_enabled: + live_tracking_processor = LiveTrackingSpanProcessor( + LlmOpsHttpExporter(), + settings=trace_settings, + ) trace_manager.add_span_processor( - LiveTrackingSpanProcessor( - LlmOpsHttpExporter(), - settings=trace_settings, - ) + live_tracking_processor ) if ctx.conversation_id and ctx.exchange_id: @@ -286,6 +323,10 @@ async def execute() -> None: await runtime.dispose() if base_runtime is not None: await base_runtime.dispose() + if live_tracking_processor is not None: + live_tracking_processor.shutdown() + if governance_bootstrap is not None: + governance_bootstrap.dispose() if factory: await factory.dispose() diff --git a/packages/uipath/src/uipath/eval/runtime/events.py b/packages/uipath/src/uipath/eval/runtime/events.py index 589f82ba7..2a46469e4 100644 --- a/packages/uipath/src/uipath/eval/runtime/events.py +++ b/packages/uipath/src/uipath/eval/runtime/events.py @@ -29,6 +29,11 @@ class EvalSetRunCreatedEvent(BaseModel): eval_set_id: str eval_set_run_id: str | None = None no_of_evals: int + # Coarse agent-type label the runtime factory declared via + # ``UiPathRuntimeFactorySettings.agent_type``. Consumers stamp this + # onto telemetry verbatim -- the CLI does not classify entrypoints. + # ``None`` when the factory has no opinion. + agent_type: str | None = None # skip validation to avoid abstract class instantiation evaluators: SkipValidation[list[GenericBaseEvaluator[Any, Any, Any]]] diff --git a/packages/uipath/src/uipath/eval/runtime/runtime.py b/packages/uipath/src/uipath/eval/runtime/runtime.py index 0c4653023..124eedf47 100644 --- a/packages/uipath/src/uipath/eval/runtime/runtime.py +++ b/packages/uipath/src/uipath/eval/runtime/runtime.py @@ -286,6 +286,9 @@ async def initiate_evaluation( f"Please run with a single evaluation using --eval-ids to specify one evaluation." ) + factory_settings = await self.factory.get_settings() + agent_type = factory_settings.agent_type if factory_settings else None + await self.event_bus.publish( EvaluationEvents.CREATE_EVAL_SET_RUN, EvalSetRunCreatedEvent( @@ -294,6 +297,7 @@ async def initiate_evaluation( eval_set_run_id=self.context.eval_set_run_id, eval_set_id=self.context.evaluation_set.id, no_of_evals=len(self.context.evaluation_set.evaluations), + agent_type=agent_type, evaluators=self.context.evaluators, ), ) diff --git a/packages/uipath/src/uipath/functions/factory.py b/packages/uipath/src/uipath/functions/factory.py index 4700ab726..3ef7f5b37 100644 --- a/packages/uipath/src/uipath/functions/factory.py +++ b/packages/uipath/src/uipath/functions/factory.py @@ -17,6 +17,14 @@ logger = logging.getLogger(__name__) +# Wire labels this factory advertises via +# :class:`UiPathRuntimeFactorySettings`. The runtime does not enumerate +# valid values -- each factory owns its own vocabulary and hosts +# forward them verbatim to telemetry / audit consumers. +_AGENT_TYPE_CODED = "uipath_coded" +# Functions runtime is plain Python — no third-party agent framework. +_AGENT_FRAMEWORK = "python" + class UiPathFunctionsRuntimeFactory: """Factory for discovering and creating function-based runtimes.""" @@ -59,8 +67,16 @@ async def get_storage(self) -> UiPathRuntimeStorageProtocol | None: return None async def get_settings(self) -> UiPathRuntimeFactorySettings | None: - """Get factory settings for coded functions.""" - return None + """Get factory settings for coded functions. + + Advertises this factory's ``agent_type`` and ``agent_framework`` + wire labels so hosts (governance audit, App Insights telemetry) + can stamp them onto events without any host-side classification. + """ + return UiPathRuntimeFactorySettings( + agent_type=_AGENT_TYPE_CODED, + agent_framework=_AGENT_FRAMEWORK, + ) async def new_runtime( self, entrypoint: str, runtime_id: str, **kwargs diff --git a/packages/uipath/tests/cli/_governance/__init__.py b/packages/uipath/tests/cli/_governance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/uipath/tests/cli/_governance/test_yaml_index.py b/packages/uipath/tests/cli/_governance/test_yaml_index.py new file mode 100644 index 000000000..6785a15a8 --- /dev/null +++ b/packages/uipath/tests/cli/_governance/test_yaml_index.py @@ -0,0 +1,775 @@ +"""Tests for ``build_policy_index_from_yaml``.""" + +from __future__ import annotations + +import pytest + +from uipath._cli._governance.yaml_index import ( + build_policy_index_from_yaml, +) +from uipath.core.governance.models import Action, LifecycleHook +from uipath.runtime.governance.native.models import Severity + + +def _single_rule(yaml_text: str): + """Compile YAML and return the single rule; fail if not exactly one.""" + idx = build_policy_index_from_yaml(yaml_text) + rules = idx.all_rules + assert len(rules) == 1, f"expected 1 rule, got {len(rules)}" + return rules[0] + + +def test_empty_yaml_returns_empty_index() -> None: + idx = build_policy_index_from_yaml("") + assert idx.total_rules == 0 + assert idx.pack_names == [] + + +def test_pack_without_rules_is_omitted() -> None: + """Packs with no parseable rules are dropped — never registered.""" + idx = build_policy_index_from_yaml( + """ + standard: empty-pack + version: "1.0" + rules: [] + """ + ) + assert idx.total_rules == 0 + assert "empty-pack" not in idx.pack_names + + +def test_pack_missing_name_is_skipped() -> None: + idx = build_policy_index_from_yaml( + """ + version: "1.0" + rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["foo"] + """ + ) + assert idx.total_rules == 0 + + +def test_pack_uses_standard_or_name_field() -> None: + """Either ``standard:`` or ``name:`` works as the pack identifier.""" + a = build_policy_index_from_yaml( + """ + standard: iso42001 + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + b = build_policy_index_from_yaml( + """ + name: iso42001 + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert "iso42001" in a.pack_names + assert "iso42001" in b.pack_names + + +def test_multi_document_yaml_concatenates_packs() -> None: + # YAML doc separators must be at column 0; dedent inline. + yaml_text = ( + "standard: pack-a\n" + "rules:\n" + " - id: a-r1\n" + " hook: before_model\n" + ' checks: [{type: regex, patterns: ["a"]}]\n' + "---\n" + "standard: pack-b\n" + "rules:\n" + " - id: b-r1\n" + " hook: after_model\n" + ' checks: [{type: regex, patterns: ["b"]}]\n' + ) + idx = build_policy_index_from_yaml(yaml_text) + assert set(idx.pack_names) == {"pack-a", "pack-b"} + assert idx.total_rules == 2 + + +def test_non_dict_top_level_documents_are_ignored() -> None: + """A YAML doc that's a string / list at top level is skipped silently.""" + yaml_text = ( + "just_a_string\n" + "---\n" + "standard: real-pack\n" + "rules:\n" + " - id: r\n" + " hook: before_model\n" + ' checks: [{type: regex, patterns: ["x"]}]\n' + ) + idx = build_policy_index_from_yaml(yaml_text) + assert idx.pack_names == ["real-pack"] + + +def test_unknown_hook_skips_rule() -> None: + """A rule referencing an unknown hook is dropped, the rest survive.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: bad + hook: invented_hook + checks: [{type: regex, patterns: ["x"]}] + - id: good + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + rule_ids = [r.rule_id for r in idx.all_rules] + assert "bad" not in rule_ids + assert "good" in rule_ids + + +def test_non_dict_rule_entry_ignored() -> None: + """Rules entries that aren't dicts (lists, scalars) are skipped.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - "this is a string, not a rule" + - id: good + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert [r.rule_id for r in idx.all_rules] == ["good"] + + +def test_action_resolution_inherits_pack_default() -> None: + """When the rule omits action, the pack's default_action is used.""" + rule = _single_rule( + """ + standard: p + default_action: log + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.action == Action.AUDIT # log -> AUDIT per _ACTION_MAP + + +def test_action_resolution_unknown_falls_back_to_default() -> None: + """Unknown action string falls back to the pack default.""" + rule = _single_rule( + """ + standard: p + default_action: deny + rules: + - id: r + hook: before_model + action: bogus + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.action == Action.DENY + + +def test_severity_resolution_explicit() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + severity: critical + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.CRITICAL + + +def test_severity_default_high_for_deny_action() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + action: deny + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.HIGH + + +def test_severity_default_medium_for_non_deny_action() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + action: log + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.MEDIUM + + +def test_unknown_severity_falls_back_to_high() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + severity: ridiculous + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.HIGH + + +def test_disabled_flag_propagates() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + enabled: false + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.enabled is False + + +def test_rule_without_id_gets_index_based_id() -> None: + """When ``id:`` is missing, a positional fallback ``RULE-N`` is used.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert idx.all_rules[0].rule_id == "RULE-0" + + +def test_rule_with_zero_parsed_checks_is_skipped() -> None: + """A rule whose declared checks all fail to parse is dropped. + + Without this guard, a rule with no checks ``always matches`` in the + evaluator and would fire on every request. + """ + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: junk + hook: before_model + checks: + - type: totally_unknown_check_type + """ + ) + assert idx.total_rules == 0 + + +@pytest.mark.parametrize( + "hook_name,expected", + [ + ("before_agent", LifecycleHook.BEFORE_AGENT), + ("after_agent", LifecycleHook.AFTER_AGENT), + ("before_model", LifecycleHook.BEFORE_MODEL), + ("after_model", LifecycleHook.AFTER_MODEL), + ("tool_call", LifecycleHook.TOOL_CALL), + ("wrap_tool_call", LifecycleHook.TOOL_CALL), # alias + ("after_tool", LifecycleHook.AFTER_TOOL), + ], +) +def test_hook_resolution(hook_name: str, expected: LifecycleHook) -> None: + rule = _single_rule( + f""" + standard: p + rules: + - id: r + hook: {hook_name} + checks: [{{type: regex, patterns: ["x"]}}] + """ + ) + assert rule.hook == expected + + +def test_regex_check_multi_pattern_defaults_to_any_logic() -> None: + """Multiple regex patterns default to OR (any) — common case for ASI rules.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["pwn", "ignore_previous"] + """ + ) + assert rule.checks[0].logic == "any" + assert len(rule.checks[0].conditions) == 2 + + +def test_regex_check_single_pattern_defaults_to_all_logic() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["pwn"] + """ + ) + assert rule.checks[0].logic == "all" + + +def test_regex_check_explicit_logic_wins() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["a", "b"] + logic: all + """ + ) + assert rule.checks[0].logic == "all" + + +@pytest.mark.parametrize( + "scope,expected_field", + [ + (["human"], "model_input"), + (["system"], "model_input"), + (["ai"], "model_output"), + ("ai", "model_output"), # string form + (["tool_result"], "tool_result"), + (["unknown_thing"], "model_input"), # fallback + ], +) +def test_regex_scope_maps_to_field(scope, expected_field: str) -> None: + rule = _single_rule( + f""" + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["x"] + scope: {scope!r} + """ + ) + assert rule.checks[0].conditions[0].field == expected_field + + +def test_budget_check_max_per_session() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: budget + max_tool_calls_per_session: 5 + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "gt" + assert cond.field == "session_state.tool_calls" + assert cond.value == 5 + + +def test_budget_check_multiple_thresholds() -> None: + """All three budget knobs become independent conditions.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: budget + max_tool_calls_per_session: 10 + max_tool_calls_per_minute: 5 + max_consecutive_tool_calls: 3 + """ + ) + assert len(rule.checks[0].conditions) == 3 + + +def test_tool_allowlist_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: tool_allowlist + blocked_tools: ["delete_file", "shell"] + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "in_list" + assert cond.field == "tool_name" + assert cond.value == ["delete_file", "shell"] + + +def test_tool_allowlist_empty_blocked_list_skipped() -> None: + """Empty ``blocked_tools`` means there's nothing to enforce — drop the rule.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: tool_allowlist + blocked_tools: [] + """ + ) + assert idx.total_rules == 0 + + +def test_parameter_validation_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: parameter_validation + additional_patterns: ["rm -rf", "/etc/passwd"] + """ + ) + check = rule.checks[0] + assert len(check.conditions) == 2 + assert all(c.field == "tool_args" for c in check.conditions) + # Multi-pattern parameter_validation defaults to OR logic + assert check.logic == "any" + + +def test_rate_limit_check_session_and_minute() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: rate_limit + max_llm_calls_per_session: 20 + max_llm_calls_per_minute: 5 + """ + ) + fields = {c.field for c in rule.checks[0].conditions} + assert fields == { + "session_state.llm_calls", + "session_state.llm_calls_per_minute", + } + + +def test_field_regex_check_threads_through_conditions() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: field_regex + conditions: + - operator: regex + field: model_output + value: "(?i)password" + message: "leaked password" + """ + ) + check = rule.checks[0] + assert check.message == "leaked password" + assert check.conditions[0].operator == "regex" + + +def test_data_quality_score_both_encoding_and_entropy() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_tool + checks: + - type: data_quality_score + field: tool_result + min_confidence: 0.8 + entropy_min: 2.0 + entropy_max: 6.0 + """ + ) + ops = {c.operator for c in rule.checks[0].conditions} + assert ops == {"encoding_concern", "entropy_concern"} + + +def test_data_quality_score_check_encoding_disabled() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_tool + checks: + - type: data_quality_score + check_encoding: false + check_entropy: true + """ + ) + ops = [c.operator for c in rule.checks[0].conditions] + assert "encoding_concern" not in ops + assert "entropy_concern" in ops + + +def test_incident_taxonomy_with_categories() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: incident_taxonomy + field: model_output + categories: [safety_refusal, tool_failure] + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "incident_concern" + assert cond.value == {"categories": ["safety_refusal", "tool_failure"]} + + +def test_incident_taxonomy_without_categories_uses_empty_dict() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: incident_taxonomy + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.value == {} + + +def test_commitment_extractor_default_flags() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: commitment_extractor + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "commitment_concern" + assert cond.value == {"require_amount": True, "require_deadline": False} + + +def test_commitment_extractor_custom_flags() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: commitment_extractor + require_amount: false + require_deadline: true + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.value == {"require_amount": False, "require_deadline": True} + + +def test_sentiment_concern_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: sentiment_concern + threshold: -0.5 + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "vader_concern" + assert cond.value == {"threshold": -0.5} + + +def test_guardrail_fallback_inherits_rule_flags() -> None: + """Rule-level ``mapped_to_uipath`` / ``policy_enabled`` thread into the condition.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + mapped_to_uipath: true + policy_enabled: false + checks: + - type: guardrail_fallback + validator: pii_detection + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "guardrail_fallback" + assert cond.value == { + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": False, + } + + +def test_guardrail_fallback_default_flags_are_unmapped_and_enabled() -> None: + """When the rule omits the flags, the fallback never fires (disabled-only contract).""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: guardrail_fallback + validator: pii_detection + """ + ) + cond = rule.checks[0].conditions[0] + # ``guardrail_fallback`` operator fires only when mapped=True AND + # enabled=False; defaults of False / True ensure it stays silent. + assert cond.value["mapped_to_uipath"] is False + assert cond.value["policy_enabled"] is True + + +def test_explicit_conditions_win_over_check_type() -> None: + """Explicit ``conditions:`` short-circuits the per-type templating.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex # ignored, conditions wins + conditions: + - operator: contains + field: model_input + value: "secret" + message: "no secrets" + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "contains" # not "regex" + assert cond.value == "secret" + assert rule.checks[0].message == "no secrets" + + +def test_explicit_conditions_negate_flag_propagates() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - conditions: + - operator: contains + field: model_input + value: "allowed" + negate: true + """ + ) + assert rule.checks[0].conditions[0].negate is True + + +def test_non_dict_condition_in_explicit_list_is_skipped() -> None: + """A condition entry that isn't a dict is silently dropped. + + The first dict-with-``operator`` entry is what trips the + "explicit conditions" branch in ``_build_check``; out-of-order + scalar entries appear after the leading dict. + """ + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - conditions: + - operator: contains + field: model_input + value: "x" + - "not a dict" + """ + ) + assert len(rule.checks[0].conditions) == 1 + + +def test_unknown_check_type_skipped() -> None: + """Unknown check types are dropped without taking down sibling checks.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: future_check_type + - type: regex + patterns: ["x"] + """ + ) + rule = idx.all_rules[0] + # Only the regex check survived. + assert len(rule.checks) == 1 + assert rule.checks[0].conditions[0].operator == "regex" + + +def test_non_dict_check_entry_skipped() -> None: + """Checks list entries that aren't dicts are silently ignored.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - "scalar instead of mapping" + - type: regex + patterns: ["x"] + """ + ) + assert len(rule.checks) == 1 diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index 468180bf7..c0e57490c 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -96,6 +96,7 @@ def _create_eval_set_run_created_event( entrypoint: str = "agent.py", no_of_evals: int = 5, evaluators: list[Any] | None = None, + agent_type: str | None = None, ) -> EvalSetRunCreatedEvent: """Helper to create EvalSetRunCreatedEvent.""" return EvalSetRunCreatedEvent( @@ -104,9 +105,60 @@ def _create_eval_set_run_created_event( eval_set_run_id=eval_set_run_id, entrypoint=entrypoint, no_of_evals=no_of_evals, + agent_type=agent_type, evaluators=evaluators or [], ) + @pytest.mark.asyncio + @patch("uipath._cli._evals._telemetry.track_event") + async def test_agent_type_normalized_to_wire_lowcode(self, mock_track_event): + """Modern factory labels containing ``lowcode`` map to ``"LowCode"`` + so Application Insights dashboards that filter on the historical + wire value keep working after the factory-supplied refactor. + """ + subscriber = EvalTelemetrySubscriber() + event = self._create_eval_set_run_created_event(agent_type="uipath_lowcode") + + await subscriber._on_eval_set_run_created(event) + + properties = mock_track_event.call_args[0][1] + assert properties["AgentType"] == "LowCode" + + @pytest.mark.asyncio + @patch("uipath._cli._evals._telemetry.track_event") + async def test_agent_type_normalized_to_wire_coded(self, mock_track_event): + """Modern factory labels that aren't low-code map to ``"Coded"``.""" + subscriber = EvalTelemetrySubscriber() + event = self._create_eval_set_run_created_event(agent_type="uipath_coded") + + await subscriber._on_eval_set_run_created(event) + + properties = mock_track_event.call_args[0][1] + assert properties["AgentType"] == "Coded" + + @pytest.mark.asyncio + @patch("uipath._cli._evals._telemetry.track_event") + async def test_agent_type_falls_back_to_entrypoint_when_missing( + self, mock_track_event + ): + """Pre-refactor callers didn't set ``agent_type``; keep the + ``agent.json`` ⇒ ``"LowCode"`` derivation so no in-flight consumer + breaks. Anything else falls back to ``"Coded"``. + """ + subscriber = EvalTelemetrySubscriber() + + low_code_event = self._create_eval_set_run_created_event( + agent_type=None, entrypoint="agent.json" + ) + await subscriber._on_eval_set_run_created(low_code_event) + assert mock_track_event.call_args[0][1]["AgentType"] == "LowCode" + + coded_event = self._create_eval_set_run_created_event( + agent_type=None, entrypoint="agent.py" + ) + await subscriber._on_eval_set_run_created(coded_event) + assert mock_track_event.call_args[0][1]["AgentType"] == "Coded" + @pytest.mark.asyncio @patch("uipath._cli._evals._telemetry.track_event") async def test_on_eval_set_run_created_tracks_event(self, mock_track_event): diff --git a/packages/uipath/tests/cli/test_governance_bootstrap.py b/packages/uipath/tests/cli/test_governance_bootstrap.py new file mode 100644 index 000000000..6c12850f0 --- /dev/null +++ b/packages/uipath/tests/cli/test_governance_bootstrap.py @@ -0,0 +1,867 @@ +"""Tests replace runtime-governance types via ``monkeypatch.setattr`` on +the bootstrap module's namespace (not via ``sys.modules``) — the +bootstrap imports them at top level. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from uipath._cli._governance_bootstrap import ( + GovernanceBootstrap, + read_is_conversational, + resolve_governance, +) +from uipath.core.governance import EnforcementMode +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.runtime import UiPathGovernedRuntime + + +@pytest.fixture +def cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Change into an isolated cwd for detection tests. + + ``UiPathConfig.config_file_path`` is a property that reads a + relative path (``uipath.json`` by default), so ``open(...)`` on it + resolves against the current working directory. Chdir-ing into + ``tmp_path`` is sufficient to redirect all reads without touching + the property itself. + """ + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def uipath_config_path(cwd: Path) -> Path: + """Path to the ``uipath.json`` inside the isolated cwd. + + Tests use this to write config content; the bootstrap under test + reads via the ``UiPathConfig.config_file_path`` property, which + resolves to the same file via cwd relativity. + """ + return cwd / "uipath.json" + + +class TestReadIsConversational: + def test_returns_none_when_file_missing( + self, cwd: Path, uipath_config_path: Path + ) -> None: + # File was never created. + assert read_is_conversational() is None + + def test_returns_true_when_conversational_flag_true( + self, cwd: Path, uipath_config_path: Path + ) -> None: + uipath_config_path.write_text( + json.dumps({"runtimeOptions": {"isConversational": True}}) + ) + assert read_is_conversational() is True + + def test_returns_false_when_conversational_flag_false( + self, cwd: Path, uipath_config_path: Path + ) -> None: + uipath_config_path.write_text( + json.dumps({"runtimeOptions": {"isConversational": False}}) + ) + assert read_is_conversational() is False + + def test_returns_none_when_field_missing( + self, cwd: Path, uipath_config_path: Path + ) -> None: + uipath_config_path.write_text(json.dumps({"runtimeOptions": {}})) + assert read_is_conversational() is None + + def test_returns_none_when_runtime_options_missing( + self, cwd: Path, uipath_config_path: Path + ) -> None: + uipath_config_path.write_text(json.dumps({})) + assert read_is_conversational() is None + + def test_returns_none_when_field_not_bool( + self, cwd: Path, uipath_config_path: Path + ) -> None: + uipath_config_path.write_text( + json.dumps({"runtimeOptions": {"isConversational": "yes"}}) + ) + assert read_is_conversational() is None + + def test_returns_none_when_json_malformed( + self, cwd: Path, uipath_config_path: Path + ) -> None: + """Non-FileNotFoundError exceptions (JSON parse error here) are + caught at the broad ``except Exception``, logged at debug, and + return ``None``. The debug log line executes unconditionally, + so no ``caplog`` assertion is needed to cover it — and adding + one is fragile under full-suite ordering because pytest's log + capture can interact with earlier tests' logging config. + """ + uipath_config_path.write_text("not-valid-json") + assert read_is_conversational() is None + + +def _install_fake_runtime_governance( + monkeypatch: pytest.MonkeyPatch, + *, + audit_manager_cls: type, + metadata_cls: type, + evaluator_cls: type, + compensator_cls: type, +) -> None: + """Replace the runtime-governance names bound in the bootstrap module.""" + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.AuditManager", + audit_manager_cls, + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.GovernanceRuntimeMetadata", + metadata_cls, + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.GovernanceEvaluator", + evaluator_cls, + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.GuardrailCompensator", + compensator_cls, + ) + + +class _FakeAuditManager: + """Records the constructor kwargs so tests can inspect them.""" + + def __init__(self, *, track_event: Any, runtime_metadata: Any) -> None: + self.track_event = track_event + self.runtime_metadata = runtime_metadata + + +class _FakeMetadata: + def __init__(self, *, agent_type: str | None, agent_framework: str) -> None: + self.agent_type = agent_type + self.agent_framework = agent_framework + + +class _FakeCompensator: + def __init__(self, provider: Any) -> None: + self.provider = provider + + +class _FakeEvaluator: + def __init__( + self, + policy_index: Any, + *, + enforcement_mode: Any, + audit_manager: Any, + compensator: Any, + ) -> None: + self.policy_index = policy_index + self.enforcement_mode = enforcement_mode + self.audit_manager = audit_manager + self.compensator = compensator + + +def _fake_policy_response(*, mode: EnforcementMode | None, policies: str) -> MagicMock: + resp = MagicMock() + resp.mode = mode + resp.policies = policies + return resp + + +def _stub_provider( + monkeypatch: pytest.MonkeyPatch, *, response_or_exc: Any +) -> MagicMock: + """Replace the ``UiPath()`` + provider construction with a stub whose + ``get_policy_async`` returns ``response_or_exc`` (or raises if it's + an exception instance). + """ + provider = MagicMock() + if isinstance(response_or_exc, BaseException): + provider.get_policy_async = AsyncMock(side_effect=response_or_exc) + else: + provider.get_policy_async = AsyncMock(return_value=response_or_exc) + provider.track_event_async = AsyncMock() + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.UiPath", + lambda: MagicMock(governance=MagicMock()), + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.UiPathPlatformGovernanceProvider", + lambda service: provider, + ) + return provider + + +class TestResolveGovernance: + async def test_returns_none_when_feature_flag_disabled( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: False, + ) + assert ( + await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + is None + ) + + async def test_returns_none_when_policy_fetch_fails( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider(monkeypatch, response_or_exc=RuntimeError("backend unreachable")) + assert ( + await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + is None + ) + + async def test_returns_none_when_mode_is_disabled( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.DISABLED, policies="rules:" + ), + ) + assert ( + await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + is None + ) + + async def test_returns_none_when_mode_is_none( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response(mode=None, policies="rules:"), + ) + assert ( + await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + is None + ) + + async def test_returns_none_when_policies_empty( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="" + ), + ) + assert ( + await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + is None + ) + + async def test_returns_none_when_policy_compilation_fails( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """Malformed YAML must be caught by the compile-time try/except so + governance skips cleanly rather than propagating a ``YAMLError`` + out of ``resolve_governance``. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + # Unclosed flow mapping — ``yaml.safe_load_all`` raises + # ``ScannerError`` (a subclass of ``YAMLError``), which + # ``resolve_governance``'s try/except converts to + # ``None``. + mode=EnforcementMode.ENFORCE, + policies="foo: {unclosed", + ), + ) + assert ( + await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + is None + ) + + async def test_success_returns_bootstrap_with_dispose_contract( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """Happy path -- named fields populated and dispose unregisters + atexit + shuts the dispatcher down. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: [a, b]" + ), + ) + + # Capture what the code hands to atexit so we can verify + # register/unregister without relying on atexit internals. + registered: list[Any] = [] + + def _fake_register(func: Any, *_a: Any, **_kw: Any) -> Any: + registered.append(func) + return func + + def _fake_unregister(func: Any) -> None: + if func in registered: + registered.remove(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _fake_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", _fake_unregister + ) + + result = await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + assert result is not None + + assert isinstance(result.evaluator, _FakeEvaluator) + assert isinstance(result.policy_index, PolicyIndex) + assert result.policy_index.total_rules == 0 + assert result.enforcement_mode == EnforcementMode.ENFORCE + assert isinstance(result.evaluator.audit_manager, _FakeAuditManager) + assert isinstance(result.evaluator.compensator, _FakeCompensator) + assert callable(result.evaluator.audit_manager.track_event) + + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_type == "uipath_coded" + ) + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_framework + == "langgraph" + ) + + assert len(registered) == 1 + result.dispose() + assert not registered, "atexit hook was not unregistered on dispose" + result.dispose() # idempotent + + async def test_agent_type_forwarded_verbatim_to_metadata( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """The ``agent_type`` argument is forwarded verbatim to + :class:`GovernanceRuntimeMetadata` -- the CLI does not classify + the project; the factory does via + :attr:`UiPathRuntimeFactorySettings.agent_type`. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: [a, b]" + ), + ) + + result = await resolve_governance( + agent_framework="lowcode", agent_type="uipath_lowcode" + ) + assert result is not None + try: + assert isinstance(result.evaluator, _FakeEvaluator) + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_type + == "uipath_lowcode" + ) + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_framework + == "lowcode" + ) + finally: + result.dispose() + + async def test_agent_framework_none_passes_through_as_unknown( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """A factory with no ``agent_framework`` opinion emits + ``"unknown"`` -- symmetric with the ``agent_type`` fallback.""" + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + result = await resolve_governance( + agent_framework=None, agent_type="uipath_coded" + ) + assert result is not None + try: + assert isinstance(result.evaluator, _FakeEvaluator) + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_framework + == "unknown" + ) + finally: + result.dispose() + + async def test_agent_type_none_passes_through_to_metadata( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """A factory with no ``agent_type`` opinion yields ``None`` on + the metadata -- the backend decides how to interpret the gap.""" + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + result = await resolve_governance(agent_framework="langgraph", agent_type=None) + assert result is not None + try: + assert isinstance(result.evaluator, _FakeEvaluator) + # Metadata field is strict ``str`` with default ``"unknown"`` + # -- the bootstrap forwards that when the factory has no + # opinion, so the CLI never has to invent a value. + assert ( + result.evaluator.audit_manager.runtime_metadata.agent_type == "unknown" + ) + finally: + result.dispose() + + async def test_wrap_runtime_produces_governed_runtime_with_bootstrap_fields( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """``GovernanceBootstrap.wrap_runtime`` must construct a + :class:`UiPathGovernedRuntime` populated from the bootstrap's + own ``evaluator`` / ``policy_index`` / ``enforcement_mode`` plus + the caller's ``agent_name`` / ``runtime_id``. This is the code + path CLI callers replaced their manual construction with. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + result = await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + assert isinstance(result, GovernanceBootstrap) + try: + delegate = MagicMock() # stand-in for the real runtime + wrapped = result.wrap_runtime( + delegate, + agent_name="my-agent", + runtime_id="run-123", + ) + assert isinstance(wrapped, UiPathGovernedRuntime) + assert wrapped._delegate is delegate + assert wrapped._policy_index is result.policy_index + assert wrapped._enforcement_mode is result.enforcement_mode + assert wrapped._evaluator is result.evaluator + assert wrapped._agent_name == "my-agent" + assert wrapped._runtime_id == "run-123" + finally: + result.dispose() + + async def test_returns_none_when_dispatcher_init_fails( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """A dispatcher constructor blow-up must be swallowed — governance + is optional and a failing bootstrap must not crash the CLI. No + ``atexit`` hook should leak. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + def _boom(_provider: Any) -> Any: + raise RuntimeError("dispatcher init exploded") + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.LiveTrackEventDispatcher", + _boom, + ) + + registered: list[Any] = [] + + def _fake_register(func: Any, *_a: Any, **_kw: Any) -> Any: + registered.append(func) + return func + + def _fake_unregister(func: Any) -> None: + if func in registered: + registered.remove(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _fake_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", _fake_unregister + ) + + result = await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + assert result is None + assert not registered, "atexit hook leaked when dispatcher init failed" + + async def test_returns_none_and_cleans_up_when_evaluator_setup_fails( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """If a component built AFTER the dispatcher (e.g., the evaluator) + raises, ``resolve_governance`` must unregister the ``atexit`` hook + AND shut the dispatcher down before returning ``None``. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + + class _ExplodingEvaluator: + def __init__(self, *_a: Any, **_kw: Any) -> None: + raise RuntimeError("evaluator init exploded") + + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_ExplodingEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + shutdown_calls: list[int] = [] + + class _FakeDispatcher: + def __init__(self, _provider: Any) -> None: + self.dispatch = MagicMock() + + def shutdown(self) -> None: + shutdown_calls.append(1) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.LiveTrackEventDispatcher", + _FakeDispatcher, + ) + + registered: list[Any] = [] + + def _fake_register(func: Any, *_a: Any, **_kw: Any) -> Any: + registered.append(func) + return func + + def _fake_unregister(func: Any) -> None: + if func in registered: + registered.remove(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _fake_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", _fake_unregister + ) + + result = await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + assert result is None + assert not registered, "atexit hook not unregistered after evaluator failure" + assert shutdown_calls == [1], "dispatcher not shut down after evaluator failure" + + async def test_dispose_swallows_dispatcher_shutdown_errors( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """``dispose`` runs from CLI ``finally`` blocks — it must never + raise, or it will mask the primary exception. A shutdown that + blows up should be logged at debug and swallowed. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + class _ExplodingDispatcher: + def __init__(self, _provider: Any) -> None: + self.dispatch = MagicMock() + + def shutdown(self) -> None: + raise RuntimeError("shutdown exploded") + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.LiveTrackEventDispatcher", + _ExplodingDispatcher, + ) + # atexit stubs so the exploding shutdown is never left registered + # against the real atexit — otherwise it would fire at pytest exit. + registered: list[Any] = [] + + def _fake_register(func: Any, *_a: Any, **_kw: Any) -> Any: + registered.append(func) + return func + + def _fake_unregister(func: Any) -> None: + if func in registered: + registered.remove(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _fake_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", _fake_unregister + ) + + result = await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + assert result is not None + # Must not raise even though the dispatcher's shutdown does. + result.dispose() + # And must remain idempotent-safe. + result.dispose() + + async def test_dispose_atexit_hook_matches_dispatcher_shutdown( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + uipath_config_path: Path, + ) -> None: + """The atexit hook that ``dispose`` unregisters must be the same + callable that ``atexit.register`` received — otherwise unregister + is a silent no-op and the dispatcher lingers. + """ + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + registered_arg: list[Any] = [] + unregistered_arg: list[Any] = [] + + def _capture_register(func: Any) -> Any: + registered_arg.append(func) + return func + + def _capture_unregister(func: Any) -> None: + unregistered_arg.append(func) + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.register", _capture_register + ) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.atexit.unregister", + _capture_unregister, + ) + + result = await resolve_governance( + agent_framework="langgraph", agent_type="uipath_coded" + ) + assert result is not None + + result.dispose() + + assert len(registered_arg) == 1 + assert len(unregistered_arg) == 1 + # Same bound method → same underlying dispatcher shutdown. + assert registered_arg[0] == unregistered_arg[0] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 87ca9e781..67786c3a8 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -351,6 +351,43 @@ 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.4" @@ -2520,6 +2557,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[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 = "types-toml" version = "0.10.8.20240310" @@ -2552,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.4" +version = "2.12.5" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2568,6 +2614,7 @@ dependencies = [ { name = "pysignalr" }, { name = "python-dotenv" }, { name = "python-socketio" }, + { name = "pyyaml" }, { name = "rich" }, { name = "tenacity" }, { name = "truststore" }, @@ -2601,6 +2648,7 @@ dev = [ { name = "rust-just" }, { name = "termynal" }, { name = "tomli-w" }, + { name = "types-pyyaml" }, { name = "types-toml" }, { name = "virtualenv" }, ] @@ -2620,12 +2668,13 @@ requires-dist = [ { name = "pysignalr", specifier = "==1.3.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-socketio", specifier = ">=5.15.0,<6.0.0" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, { name = "rich", specifier = ">=14.2.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", editable = "../uipath-core" }, { name = "uipath-platform", editable = "../uipath-platform" }, - { name = "uipath-runtime", specifier = ">=0.11.5,<0.12.0" }, + { name = "uipath-runtime", specifier = ">=0.11.7,<0.12.0" }, ] [package.metadata.requires-dev] @@ -2653,6 +2702,7 @@ dev = [ { name = "rust-just", specifier = ">=1.39.0" }, { name = "termynal", specifier = ">=0.13.1" }, { name = "tomli-w", specifier = ">=1.2.0" }, + { name = "types-pyyaml", specifier = ">=6.0" }, { name = "types-toml", specifier = ">=0.10.8" }, { name = "virtualenv", specifier = ">=20.36.1" }, ] @@ -2729,14 +2779,16 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.11.5" +version = "0.11.7" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "chardet" }, { name = "uipath-core" }, + { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/f1/28ca53eee0176a5f2a4985f82a1d184fe01c21d84043557723e2c22e242f/uipath_runtime-0.11.5.tar.gz", hash = "sha256:a1f04c4199875ab72055082edd30c3546fd3ed80d1d70ed2c042b4b4047f8935", size = 152819, upload-time = "2026-06-29T16:08:00.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/59/a491091f7e9ee3de8741b7658745214706e2adb01b89bf114f0f2dabff53/uipath_runtime-0.11.7.tar.gz", hash = "sha256:8f4b58c444aef31465e66a470cd58300eb9d440bd67e6f6f1ba204bee704680f", size = 231824, upload-time = "2026-07-02T12:53:29.28Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/58/bd6c42a11b4eb7fd98f8a5b1b7e19e3b15d301c6894154320312086ff5c9/uipath_runtime-0.11.5-py3-none-any.whl", hash = "sha256:e665e10e3beaeba3dae48e354927604fba460568f73694c74e76d018a18d44af", size = 49864, upload-time = "2026-06-29T16:07:58.773Z" }, + { url = "https://files.pythonhosted.org/packages/58/e5/4bcb8902fc945e1abd560b2adae06d3ac336c58dd036a0ca23bf9cb67ea4/uipath_runtime-0.11.7-py3-none-any.whl", hash = "sha256:18a4632a481eef101cd6f026f2938854c80a8fb24fd749dfe02fe62015eb84e2", size = 91126, upload-time = "2026-07-02T12:53:27.549Z" }, ] [[package]] @@ -2748,6 +2800,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[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]] name = "virtualenv" version = "20.36.1"