From 76e5a9677da4744712787c83b896a800ec1a9f6d Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Fri, 12 Jun 2026 16:27:58 +0530 Subject: [PATCH 1/7] feat(governance): policy backend client, YAML compiler, loader Co-Authored-By: Claude Opus 4.8 --- .../governance/native/_yaml_to_index.py | 459 ++++++++++ .../governance/native/backend_client.py | 383 +++++++++ .../runtime/governance/native/loader.py | 340 ++++++++ .../governance/native/policy_api_client.py | 227 +++++ tests/test_loader.py | 379 +++++++++ tests/test_policy_agent_type.py | 99 +++ tests/test_policy_api_client.py | 258 ++++++ tests/test_yaml_to_index.py | 795 ++++++++++++++++++ 8 files changed, 2940 insertions(+) create mode 100644 src/uipath/runtime/governance/native/_yaml_to_index.py create mode 100644 src/uipath/runtime/governance/native/backend_client.py create mode 100644 src/uipath/runtime/governance/native/loader.py create mode 100644 src/uipath/runtime/governance/native/policy_api_client.py create mode 100644 tests/test_loader.py create mode 100644 tests/test_policy_agent_type.py create mode 100644 tests/test_policy_api_client.py create mode 100644 tests/test_yaml_to_index.py diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py new file mode 100644 index 0000000..2deb463 --- /dev/null +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -0,0 +1,459 @@ +"""Runtime YAML → PolicyIndex parser. + +Mirrors the shape produced by ``packs/compile_packs.py`` but builds the +PolicyIndex directly from parsed YAML data rather than generating Python +source. Used by :mod:`uipath.runtime.governance.native.loader` when policies are fetched +from the governance backend at startup. + +Accepts either a single YAML document (one pack) or a multi-document +stream (``---``-separated packs). Unknown check types and malformed +rules are skipped with a warning — partial packs are preferred over +failing the whole load. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import yaml +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.native.models import ( + Check, + Condition, + PolicyIndex, + PolicyPack, + Rule, + Severity, +) + +logger = logging.getLogger(__name__) + + +_HOOK_MAP: dict[str, LifecycleHook] = { + "before_agent": LifecycleHook.BEFORE_AGENT, + "after_agent": LifecycleHook.AFTER_AGENT, + "before_model": LifecycleHook.BEFORE_MODEL, + "after_model": LifecycleHook.AFTER_MODEL, + "wrap_tool_call": LifecycleHook.TOOL_CALL, + "tool_call": LifecycleHook.TOOL_CALL, + "after_tool": LifecycleHook.AFTER_TOOL, +} + +_ACTION_MAP: dict[str, Action] = { + "block": Action.DENY, + "deny": Action.DENY, + "log": Action.AUDIT, + "audit": Action.AUDIT, + "allow": Action.ALLOW, + "require_approval": Action.ESCALATE, + "escalate": Action.ESCALATE, +} + +_SEVERITY_MAP: dict[str, Severity] = { + "low": Severity.LOW, + "medium": Severity.MEDIUM, + "high": Severity.HIGH, + "critical": Severity.CRITICAL, +} + + +def build_policy_index_from_yaml(yaml_text: str) -> PolicyIndex: + """Parse YAML policy packs into a PolicyIndex. + + Args: + yaml_text: YAML body, either a single document or ``---``-separated + multi-document stream. Each document is one pack. + + Returns: + PolicyIndex with all successfully parsed packs added. Empty when + the input has no parseable packs. + + Raises: + yaml.YAMLError: If the YAML itself is malformed. Callers are + expected to fall back to the compiled index on this error. + """ + index = PolicyIndex() + documents = list(yaml.safe_load_all(yaml_text)) + + for doc in documents: + if not isinstance(doc, dict): + continue + pack = _build_pack(doc) + if pack is not None and pack.rules: + index.add_pack(pack) + + logger.debug( + "Built PolicyIndex from YAML: packs=%s, rules=%d", + index.pack_names, + index.total_rules, + ) + return index + + +def _build_pack(data: dict[str, Any]) -> PolicyPack | None: + """Build a PolicyPack from one YAML document.""" + name = data.get("standard") or data.get("name") + if not name: + logger.warning("Skipping pack: missing 'standard'/'name' field") + return None + + default_action_str = data.get("default_action", "block") + default_action = _ACTION_MAP.get(default_action_str, Action.DENY) + + rules: list[Rule] = [] + for i, rule_data in enumerate(data.get("rules", []) or []): + if not isinstance(rule_data, dict): + continue + rule = _build_rule(rule_data, default_action, i) + if rule is not None: + rules.append(rule) + + return PolicyPack( + name=str(name), + version=str(data.get("version", "1.0.0")), + description=str(data.get("description", "")), + rules=rules, + ) + + +def _build_rule( + data: dict[str, Any], default_action: Action, index: int +) -> Rule | None: + """Build a single Rule from a YAML rule entry.""" + hook = _HOOK_MAP.get(data.get("hook", "before_model")) + if hook is None: + logger.warning( + "Skipping rule %s: unknown hook %r", data.get("id"), data.get("hook") + ) + return None + + action_str = data.get("action") + action = ( + _ACTION_MAP.get(action_str, default_action) if action_str else default_action + ) + + default_sev = "high" if action == Action.DENY else "medium" + severity = _SEVERITY_MAP.get(data.get("severity", default_sev), Severity.HIGH) + + checks = _build_checks( + data.get("checks", []) or [], + action, + mapped_to_uipath=bool(data.get("mapped_to_uipath", False)), + policy_enabled=bool(data.get("policy_enabled", True)), + ) + + # If checks were declared but none could be parsed (e.g. all unknown + # types), skip the rule. A rule with zero checks "always matches" in + # the evaluator, so keeping it would make it fire on every request. + declared = data.get("checks", []) or [] + if declared and not checks: + logger.warning( + "Skipping rule %s: none of its %d declared check(s) could be parsed", + data.get("id"), + len(declared), + ) + return None + + return Rule( + rule_id=str(data.get("id", f"RULE-{index}")), + name=str(data.get("name", data.get("id", f"RULE-{index}"))), + clause=str(data.get("clause", data.get("owasp_ref", ""))), + hook=hook, + action=action, + severity=severity, + checks=checks, + enabled=bool(data.get("enabled", True)), + description=str(data.get("description", "")), + ) + + +def _build_checks( + checks_data: list[dict[str, Any]], + default_action: Action, + *, + mapped_to_uipath: bool = False, + policy_enabled: bool = True, +) -> list[Check]: + """Build the checks list for a rule. + + ``mapped_to_uipath`` / ``policy_enabled`` are rule-level flags read + by ``guardrail_fallback`` checks so the per-check condition can + decide whether to fire the compensating governance call. + """ + checks: list[Check] = [] + for check_data in checks_data: + if not isinstance(check_data, dict): + continue + check = _build_check( + check_data, + default_action, + mapped_to_uipath=mapped_to_uipath, + policy_enabled=policy_enabled, + ) + if check is not None: + checks.append(check) + return checks + + +def _build_check( + data: dict[str, Any], + default_action: Action, + *, + mapped_to_uipath: bool = False, + policy_enabled: bool = True, +) -> Check | None: + """Build one Check from a YAML check entry. + + Supports the same check types as ``compile_packs.py``: explicit + conditions, regex, budget, tool_allowlist, parameter_validation, + rate_limit, field_regex, sentiment_concern, data_quality_score, + incident_taxonomy, commitment_extractor, plus ``guardrail_fallback`` + (reads the rule-level ``mapped_to_uipath`` / ``policy_enabled`` flags + threaded in from ``_build_rule``). + """ + conditions: list[Condition] = [] + message = "" + + raw_conditions = data.get("conditions") + has_explicit_conditions = ( + isinstance(raw_conditions, list) + and raw_conditions + and isinstance(raw_conditions[0], dict) + and "operator" in raw_conditions[0] + ) + + check_type = data.get("type", "regex") + + if has_explicit_conditions: + assert isinstance(raw_conditions, list) # narrowed by has_explicit_conditions + conditions.extend(_make_conditions(raw_conditions)) + message = str(data.get("message", "")) + + elif check_type == "regex": + patterns = data.get("patterns", []) or [] + scope = data.get("scope", ["human", "ai"]) + field = _field_for_scope(scope) + for pattern in patterns: + conditions.append(Condition(operator="regex", field=field, value=pattern)) + message = f"Pattern matched in {scope}" + + elif check_type == "budget": + if "max_tool_calls_per_session" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.tool_calls", + value=data["max_tool_calls_per_session"], + ) + ) + if "max_tool_calls_per_minute" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.tool_calls_per_minute", + value=data["max_tool_calls_per_minute"], + ) + ) + if "max_consecutive_tool_calls" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.consecutive_tool_calls", + value=data["max_consecutive_tool_calls"], + ) + ) + message = "Tool budget exceeded" + + elif check_type == "tool_allowlist": + blocked_tools = data.get("blocked_tools", []) or [] + if blocked_tools: + conditions.append( + Condition(operator="in_list", field="tool_name", value=blocked_tools) + ) + message = "Tool not allowed" + + elif check_type == "parameter_validation": + for pattern in data.get("additional_patterns", []) or []: + conditions.append( + Condition(operator="regex", field="tool_args", value=pattern) + ) + message = "Suspicious pattern in tool parameters" + + elif check_type == "rate_limit": + if "max_llm_calls_per_session" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.llm_calls", + value=data["max_llm_calls_per_session"], + ) + ) + if "max_llm_calls_per_minute" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.llm_calls_per_minute", + value=data["max_llm_calls_per_minute"], + ) + ) + message = "Rate limit exceeded" + + elif check_type == "field_regex": + conditions.extend(_make_conditions(data.get("conditions", []) or [])) + message = str(data.get("message", "Field regex check failed")) + + elif check_type == "data_quality_score": + field = data.get("field", "tool_result") + if data.get("check_encoding", True): + conditions.append( + Condition( + operator="encoding_concern", + field=field, + value={ + "min_confidence": float(data.get("min_confidence", 0.5)), + "max_replacement_ratio": float( + data.get("max_replacement_ratio", 0.05) + ), + "min_corruption_events": int( + data.get("min_corruption_events", 2) + ), + }, + ) + ) + if data.get("check_entropy", True): + conditions.append( + Condition( + operator="entropy_concern", + field=field, + value={ + "min": float(data.get("entropy_min", 1.5)), + "max": float(data.get("entropy_max", 7.5)), + }, + ) + ) + message = str( + data.get("message", "A.7.4: Data quality signal (encoding or entropy)") + ) + + elif check_type == "incident_taxonomy": + field = data.get("field", "model_output") + categories = data.get("categories") + value: dict[str, Any] = {} + if categories: + value["categories"] = list(categories) + conditions.append( + Condition(operator="incident_concern", field=field, value=value) + ) + message = str(data.get("message", "A.8.4: Incident signal detected")) + + elif check_type == "commitment_extractor": + field = data.get("field", "model_output") + conditions.append( + Condition( + operator="commitment_concern", + field=field, + value={ + "require_amount": bool(data.get("require_amount", True)), + "require_deadline": bool(data.get("require_deadline", False)), + }, + ) + ) + message = str( + data.get("message", "A.10.4: Customer commitment language detected") + ) + + elif check_type == "sentiment_concern": + field = data.get("field", "model_input") + threshold = float(data.get("threshold", -0.3)) + conditions.append( + Condition( + operator="vader_concern", + field=field, + value={"threshold": threshold}, + ) + ) + message = str( + data.get( + "message", + f"Negative sentiment detected (VADER compound <= {threshold})", + ) + ) + + elif check_type == "guardrail_fallback": + # Centralized guardrail compensating control. The on/off state + # lives at the RULE level (mapped_to_uipath / policy_enabled), + # threaded in from ``_build_rule``; ``validator`` names which + # guardrail check the server should run on behalf of the agent. + # The condition matches only when the guardrail is mapped to + # UiPath but disabled — see the ``guardrail_fallback`` operator + # in :class:`GovernanceEvaluator`. + conditions.append( + Condition( + operator="guardrail_fallback", + field="", + value={ + "validator": str(data.get("validator", "")), + "mapped_to_uipath": mapped_to_uipath, + "policy_enabled": policy_enabled, + }, + ) + ) + message = str( + data.get("message", "Guardrail disabled — compensating check needed.") + ) + + else: + logger.debug("Skipping check: unknown type %r", check_type) + return None + + if not conditions: + return None + + action_str = data.get("action") + action = ( + _ACTION_MAP.get(action_str, default_action) if action_str else default_action + ) + + message = str(data.get("message", message)) + + # Multi-pattern regex/parameter_validation defaults to OR semantics + # (any pattern indicates a hit); explicit `logic` in YAML wins. + if check_type in ("parameter_validation", "regex") and len(conditions) > 1: + default_logic = "any" + else: + default_logic = "all" + logic = str(data.get("logic", default_logic)) + + return Check(conditions=conditions, action=action, message=message, logic=logic) + + +def _make_conditions(raw: list[dict[str, Any]]) -> list[Condition]: + """Translate a list of YAML condition dicts into Condition objects.""" + out: list[Condition] = [] + for cond in raw: + if not isinstance(cond, dict): + continue + out.append( + Condition( + operator=str(cond.get("operator", "regex")), + field=str(cond.get("field", "model_input")), + value=cond.get("value", ""), + negate=bool(cond.get("negate", False)), + ) + ) + return out + + +def _field_for_scope(scope: list[str] | str) -> str: + """Map a YAML `scope` value to the CheckContext field it targets.""" + if isinstance(scope, str): + scope = [scope] + if "system" in scope or "human" in scope: + return "model_input" + if "ai" in scope: + return "model_output" + if "tool_result" in scope: + return "tool_result" + return "model_input" diff --git a/src/uipath/runtime/governance/native/backend_client.py b/src/uipath/runtime/governance/native/backend_client.py new file mode 100644 index 0000000..8269ea7 --- /dev/null +++ b/src/uipath/runtime/governance/native/backend_client.py @@ -0,0 +1,383 @@ +"""Governance backend client. + +Hosts the shared infrastructure used by every governance-backend call: + +- :func:`get_backend_base_url` — resolves the cloud host (with the + org/tenant path segments stripped) so each endpoint builder can + append its own scoped path. +- :func:`governance_request_headers` — composes the headers shared by + the policy fetch and the ``/runtime/govern`` compensating POST + (Accept, User-Agent, optional Content-Type, optional Bearer auth). +- :func:`build_governance_url` — composes an org-scoped URL against + the ``agenticgovernance_`` ingress. +- :func:`resolve_organization_id` / :func:`resolve_tenant_id` — read + the active org/tenant from ``UiPathConfig`` with an env-var fallback + for installations that don't have ``uipath-platform``. +- :func:`safe_call` — fail-open helper that catches every non-block + exception so governance hooks never crash an agent run. +- Module-level constants — request timeout, service path prefix, + compensation pool size — all the tunables an operator might care + about. Defined once here so the policy fetch, the compensating + ``/runtime/govern`` call, and the loader share one definition. + +The endpoint clients live next door: + +- :mod:`uipath.runtime.governance.native.policy_api_client` — policy fetch +- :mod:`uipath.runtime.governance.native.guardrail_compensation` — /runtime/govern +""" + +from __future__ import annotations + +import logging +import os +from functools import lru_cache +from typing import Callable +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# ---------------------------------------------------------------------------- +# Env-var names (consumed by the helpers below + diagnostic messages) +# ---------------------------------------------------------------------------- + +# Explicit dev/test override — used verbatim, no path-stripping. +ENV_BACKEND_BASE_URL = "UIPATH_GOVERNANCE_BACKEND_URL" +# The canonical platform URL env var (also backs ``UiPathConfig.base_url``). +ENV_PLATFORM_BASE_URL = "UIPATH_URL" +# Bearer token; missing means the policy fetch and compensating call are +# skipped (and that fact is logged) rather than producing 401s on every call. +ENV_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" +# Org / tenant scoping for the agenticgovernance_ ingress. +ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" +ENV_TENANT_ID = "UIPATH_TENANT_ID" +# Job-execution context forwarded in the /runtime/govern payload so the +# server can populate the LLMOps trace record (Doc-2 audit structure). +# Each falls back to the named env var when uipath-platform isn't present. +ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" +ENV_JOB_KEY = "UIPATH_JOB_KEY" +ENV_PROCESS_KEY = "UIPATH_PROCESS_UUID" +ENV_REFERENCE_ID = "UIPATH_AGENT_ID" +ENV_AGENT_VERSION = "UIPATH_PROCESS_VERSION" + +# ---------------------------------------------------------------------------- +# Endpoint shape — all governance calls hit the org-scoped agenticgovernance_ +# service. Centralised so adding a third endpoint is "one new path constant" +# instead of "a new path template that someone forgets to keep in sync." +# ---------------------------------------------------------------------------- + +GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" +POLICY_API_PATH = "api/v1/runtime/policy" +GOVERN_API_PATH = "api/v1/runtime/govern" +TENANT_HEADER = "x-uipath-internal-tenantid" +# Query param on the policy fetch that selects the agent-type view of the +# policy: the server's clause-resolver reads the matching container key +# (``*-in-flight-conversational-agents`` vs ``*-in-flight-agents``). It's a +# representation selector (it changes the returned policy), so it travels as a +# query param — cache-correct and part of resource identification — not a +# header. Values: "conversational" | "autonomous". +AGENT_TYPE_PARAM = "agentType" +AGENT_TYPE_CONVERSATIONAL = "conversational" +AGENT_TYPE_AUTONOMOUS = "autonomous" + +# Default base URL when no override and no UiPathConfig / UIPATH_URL value is +# available. Used only on developer machines doing fully-offline work; real +# deployments always have UIPATH_URL injected by the host. +_DEFAULT_BACKEND_BASE_URL = "https://alpha.uipath.com" + +# ---------------------------------------------------------------------------- +# Tunables — one place so an ops change is one edit. The values that bound +# how long a single agent run can spend on governance traffic. +# ---------------------------------------------------------------------------- + +# Per-request timeout for any governance backend HTTP call (policy fetch, +# /runtime/govern compensating POST). Same value used everywhere so an agent +# can't accidentally end up with a "long" timeout on one call and "short" on +# another. +BACKEND_REQUEST_TIMEOUT_SECONDS = 10.0 + +# Bound on concurrent /runtime/govern requests in flight. A misbehaving +# agent that fires `before_model` 100 times in a session with three matched +# fallback rules each would otherwise spawn 100 daemon threads; this pool +# caps the concurrency. Saturated submissions are logged and dropped — the +# server still receives traces from the requests that did land. +COMPENSATION_MAX_WORKERS = 4 + +# Browser-shaped User-Agent. Required because the alpha/production +# governance ingress runs a WAF whose default scanner rule set blocks +# ``Python-urllib/``. Identifying as a real browser keeps the +# request from being rejected before any auth/tenant logic runs. +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/148.0.0.0 Safari/537.36" +) + + +# ---------------------------------------------------------------------------- +# Headers +# ---------------------------------------------------------------------------- + + +def governance_request_headers(*, json_body: bool = False) -> dict[str, str]: + """Return the common HTTP headers for governance backend requests. + + Centralises the headers shared between the policy fetch and the + compensating ``/runtime/govern`` POST so the UA and auth shape are + declared once. + + Args: + json_body: When ``True`` (POST/PATCH/etc. with a JSON payload), + adds ``Content-Type: application/json``. GETs leave it off + so origin servers that 415 on unexpected Content-Type stay + happy. + + Returns: + A new dict with: + + - ``Accept: application/json`` + - ``User-Agent`` (the browser-shaped string above) + - ``Content-Type: application/json`` when ``json_body=True`` + - ``Authorization: Bearer `` when the env + var is set; omitted otherwise (caller decides whether the + missing token is fatal). + + Endpoint-specific headers (e.g. ``x-uipath-internal-tenantid``) are + added by the caller after this helper returns. + """ + headers: dict[str, str] = { + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + if json_body: + headers["Content-Type"] = "application/json" + token = os.environ.get(ENV_ACCESS_TOKEN) + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +# ---------------------------------------------------------------------------- +# URL composition +# ---------------------------------------------------------------------------- + + +def _strip_to_origin(raw_url: str) -> str: + """Return ``scheme://host[:port]`` for ``raw_url``, dropping any path. + + Platform URLs are commonly ``https://cloud.uipath.com//``; + the governance endpoints construct their own + ``/{org}/agenticgovernance_/...`` suffix, so the org/tenant segments + in the base must be stripped to avoid a duplicated org path. + """ + parsed = urlparse(raw_url) + if not parsed.scheme or not parsed.netloc: + # Not a parseable absolute URL — leave it to the caller. + return raw_url.rstrip("/") + return f"{parsed.scheme}://{parsed.netloc}" + + +def get_backend_base_url() -> str: + """Resolve the governance backend base URL on each call. + + Resolution order (first hit wins): + + 1. ``UIPATH_GOVERNANCE_BACKEND_URL`` — explicit dev/test override, + used verbatim. + 2. ``UiPathConfig.base_url`` from ``uipath-platform`` — the + canonical platform URL. Org/tenant path segments are stripped + so the caller can append its own org-scoped path. + 3. ``UIPATH_URL`` env var — same as (2) but works when + ``uipath-platform`` is not installed. + 4. ``https://alpha.uipath.com`` — last-resort default for offline + development; real deployments always have ``UIPATH_URL`` set. + + Reading on each call (not at import) lets the runtime entrypoint + configure the env vars after this module is already loaded. + """ + explicit_override = os.environ.get(ENV_BACKEND_BASE_URL) + if explicit_override: + return explicit_override.rstrip("/") + + # Lazy import — uipath-platform is optional; falls through to the + # env-var path when only uipath-core / uipath-runtime are installed. + platform_url: str | None = None + try: + from uipath.platform.common import UiPathConfig + + platform_url = UiPathConfig.base_url + except (ImportError, AttributeError): + pass + + raw = platform_url or os.environ.get(ENV_PLATFORM_BASE_URL) + if raw: + return _strip_to_origin(raw) + + return _DEFAULT_BACKEND_BASE_URL + + +def build_governance_url(org_id: str, path: str) -> str: + """Compose an org-scoped governance backend URL. + + Final shape: ``{backend_base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}``. + + Args: + org_id: Active organization id; the URL is meaningless without it. + path: API suffix WITHOUT the org/service prefix + (e.g. :data:`POLICY_API_PATH` or :data:`GOVERN_API_PATH`). + """ + base = get_backend_base_url() + return f"{base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}" + + +# ---------------------------------------------------------------------------- +# Org / tenant resolution +# ---------------------------------------------------------------------------- + + +def _resolve_uipath_config_field(attr: str, env_var: str) -> str | None: + """Read a single ``UiPathConfig`` attribute with an env-var fallback. + + Lazy-imports ``UiPathConfig`` so ``uipath-runtime`` doesn't require + ``uipath-platform`` at install time. When the platform package is + missing (``ImportError``) or the attribute isn't yet exposed + (``AttributeError``), falls back to reading the named env var. + """ + try: + from uipath.platform.common import UiPathConfig + + return getattr(UiPathConfig, attr, None) or os.environ.get(env_var) + except ImportError: + return os.environ.get(env_var) + + +# ---------------------------------------------------------------------------- +# Agent-type selector (conversational vs autonomous) +# +# Set once by the governance wrapper at runtime init (before the background +# policy prefetch is kicked off) and read by the policy fetch when composing +# the request URL. A process-level holder — not a ContextVar — because the +# prefetch runs on a separate thread that wouldn't inherit a ContextVar, and a +# coded-agent process hosts a single agent so the value is stable per process. +# ---------------------------------------------------------------------------- + +_agent_is_conversational: bool | None = None + + +def set_agent_conversational(value: bool | None) -> None: + """Record whether the hosted agent is conversational. + + ``None`` clears the selector (used by tests / direct callers); the policy + fetch then omits the param and the server applies its default. + """ + global _agent_is_conversational + _agent_is_conversational = value + + +def agent_type_param() -> str | None: + """Return the ``agentType`` query value, or ``None`` when unknown. + + ``"conversational"`` / ``"autonomous"`` map to the server's + conversational-vs-autonomous container keys; ``None`` (selector never set) + omits the param so the server's default applies. + """ + if _agent_is_conversational is None: + return None + return AGENT_TYPE_CONVERSATIONAL if _agent_is_conversational else AGENT_TYPE_AUTONOMOUS + + +def resolve_organization_id() -> str | None: + """Return the current organization id from ``UiPathConfig`` / env. + + Returns ``None`` when neither source yields a value — callers skip + the backend interaction (no URL can be built without an org id) + and the agent runs with no policies / no compensation. + """ + return _resolve_uipath_config_field("organization_id", ENV_ORGANIZATION_ID) + + +def resolve_tenant_id() -> str | None: + """Return the current tenant id from ``UiPathConfig`` / env. + + Returns ``None`` when neither source yields a value — callers skip + the backend interaction since the ``x-uipath-internal-tenantid`` + header would be missing. + """ + return _resolve_uipath_config_field("tenant_id", ENV_TENANT_ID) + + +@lru_cache(maxsize=1) +def _resolved_job_context() -> tuple[tuple[str, str], ...]: + """Resolve and freeze the job context once per process. + + Returned as a tuple of ``(key, value)`` pairs so the cached value is + immutable — callers materialize a fresh dict each call. Tests that + mutate env vars can invalidate via ``resolve_job_context.cache_clear()``. + """ + candidates = { + "folderKey": _resolve_uipath_config_field("folder_key", ENV_FOLDER_KEY), + "jobKey": _resolve_uipath_config_field("job_key", ENV_JOB_KEY), + "processKey": _resolve_uipath_config_field("process_uuid", ENV_PROCESS_KEY), + "referenceId": _resolve_uipath_config_field("agent_id", ENV_REFERENCE_ID), + "agentVersion": _resolve_uipath_config_field( + "process_version", ENV_AGENT_VERSION + ), + } + return tuple((k, v) for k, v in candidates.items() if v) + + +def resolve_job_context() -> dict[str, str]: + """Return the agent's job-execution context for the govern payload. + + Each field is read from ``UiPathConfig`` (env-var fallback) and only + included when it resolves to a truthy value, so the server receives + exactly the keys the agent actually knows. Cached per-process — the + underlying values are immutable for the agent's lifetime. The server + maps these onto the LLMOps trace record: + + - ``folderKey`` → ``FolderKey`` / ``uipath.folder_key`` + - ``jobKey`` → ``JobKey`` / ``uipath.job_key`` + - ``processKey`` → ``ProcessKey`` + - ``referenceId`` → ``ReferenceId`` (typically the agent id) + - ``agentVersion`` → ``AgentVersion`` + """ + return dict(_resolved_job_context()) + + +resolve_job_context.cache_clear = _resolved_job_context.cache_clear # type: ignore[attr-defined] + + +# ---------------------------------------------------------------------------- +# Generic safe-call helper. Used by callers that want "log and continue" on +# any unexpected failure path without spelling out the same try/except every +# time. The intentional GovernanceBlockException ALWAYS propagates — only +# this exception type carries policy intent; anything else is a bug. +# ---------------------------------------------------------------------------- + + +def safe_call( + fn: Callable[..., None], + *args: object, + what: str, + **kwargs: object, +) -> None: + """Call ``fn(*args, **kwargs)`` and swallow any non-block exception. + + ``GovernanceBlockException`` propagates (intentional policy block); + everything else is logged at WARNING with the ``what`` label and + swallowed so the agent can continue. Designed for fire-and-forget + governance paths that should never fail an agent run. + + Args: + fn: Callable to invoke. + what: Short label used in the log line on failure + (e.g. ``"BEFORE_AGENT governance check"``). + """ + # Lazy import to avoid pulling uipath-core into module load. + from uipath.core.governance.exceptions import GovernanceBlockException + + try: + fn(*args, **kwargs) + except GovernanceBlockException: + raise + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning("%s failed (continuing): %s", what, exc) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py new file mode 100644 index 0000000..e2fd138 --- /dev/null +++ b/src/uipath/runtime/governance/native/loader.py @@ -0,0 +1,340 @@ +"""Policy pack loader. + +Resolves the active PolicyIndex at startup. Policies are fetched +exclusively from the governance backend (``api/v1/policy``); there is +no local compiled fallback. When the backend is unavailable, the +access token is unset, or the fetch times out, the loader returns an +empty PolicyIndex and the agent runs without any rules. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from collections import Counter + +import yaml +from uipath.core.governance.config import is_governance_enabled + +from uipath.runtime.governance.config import EnforcementMode, set_enforcement_mode +from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml +from uipath.runtime.governance.native.backend_client import ENV_ACCESS_TOKEN +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.native.policy_api_client import ( + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + POLICY_API_TIMEOUT_SECONDS, + fetch_policy_response, + resolve_organization_id, + resolve_tenant_id, +) + +logger = logging.getLogger(__name__) + +# Pack name aliases for backward compatibility +PACK_ALIASES: dict[str, str] = { + "owasp": "owasp_agentic", + "hipaa": "hipaa_runtime", + "soc2": "soc2_runtime", + "nist": "nist_ai_rmf_runtime", + "eu_ai": "eu_ai_act_runtime", + "iso": "iso42001_runtime", +} + + +# Module-level cache +_policy_index: PolicyIndex | None = None + +# Background-prefetch coordination. ``_prefetch_event`` is set once the +# background load_policy_index() call finishes (success OR failure); +# callers of ``get_policy_index()`` wait on it. ``_prefetch_lock`` +# protects the start-once semantics so concurrent ``prefetch`` calls +# don't kick off duplicate threads. +_prefetch_event: threading.Event | None = None +_prefetch_lock = threading.Lock() + +# Default wait when ``get_policy_index()`` blocks on an in-flight +# prefetch. Matched to the policy-API HTTP timeout so a stuck backend +# bounds the total time spent waiting at first hook fire to +# ~POLICY_API_TIMEOUT_SECONDS. If the wait expires we return an empty +# PolicyIndex — the agent runs without any policies rather than +# blocking further or retrying. +_PREFETCH_WAIT_SECONDS = POLICY_API_TIMEOUT_SECONDS + + +def prefetch_policy_index() -> None: + """Kick off a background load of the policy index. + + Non-blocking. Designed to be called as early as possible (at + ``GovernanceRuntime.__init__``) so the HTTP call to the governance + backend overlaps with the rest of agent setup. The result lands in + the same module cache that ``get_policy_index()`` reads from; + ``get_policy_index()`` waits on this prefetch when it's in flight. + + Idempotent: subsequent calls while the first is running are no-ops, + and calls after completion are no-ops. Skipped entirely when the + governance feature flag is OFF so no network call is made. + """ + global _prefetch_event + + if not is_governance_enabled(): + return + + with _prefetch_lock: + if _policy_index is not None: + return # already loaded + if _prefetch_event is not None: + return # already in flight + event = threading.Event() + _prefetch_event = event + + def _worker() -> None: + global _policy_index + try: + loaded = load_policy_index() + except Exception as exc: # noqa: BLE001 - logged; first hook will retry sync + logger.warning("Policy prefetch failed: %s", exc) + else: + with _prefetch_lock: + _policy_index = loaded + finally: + event.set() + + threading.Thread( + target=_worker, + name="governance-policy-prefetch", + daemon=True, + ).start() + + +def get_policy_index() -> PolicyIndex: + """Get the cached policy index, loading if necessary. + + Resolution order on first call: + 1. If the governance feature flag is OFF, return an empty + PolicyIndex (cached). No network call. + 2. If a prefetch (see :func:`prefetch_policy_index`) is in flight, + wait for it to complete (bounded by ``_PREFETCH_WAIT_SECONDS``). + 3. Governance backend at ``api/v1/policy`` (one HTTP GET, cached). + 4. Empty PolicyIndex when the backend is unavailable or times out. + + Result is cached for the process lifetime; per-hook evaluation never + touches the network. Call :func:`clear_policy_cache` to force a + refetch (mainly for tests). + """ + global _policy_index + + if _policy_index is not None: + return _policy_index + + if not is_governance_enabled(): + logger.info( + "Governance feature flag is OFF; returning empty PolicyIndex. " + "No rules will fire. Set EnablePythonGovernanceChecker=True to enable." + ) + _policy_index = PolicyIndex() + return _policy_index + + event = _prefetch_event + if event is not None: + completed = event.wait(timeout=_PREFETCH_WAIT_SECONDS) + if completed and _policy_index is not None: + return _policy_index + if not completed: + logger.warning( + "Policy prefetch did not complete in %.1fs; " + "agent will run without any policies", + _PREFETCH_WAIT_SECONDS, + ) + else: + # Distinguish from the timeout path so production triage + # can tell "prefetch hung" from "prefetch returned empty" + # (auth failure, server error, parse failure). + logger.warning( + "Policy prefetch completed but produced no PolicyIndex " + "(see prior WARN for the root cause); agent will run " + "without any policies" + ) + _policy_index = PolicyIndex() + return _policy_index + + # No prefetch was started (direct callers / tests). Sync load — bounded + # by the HTTP timeout in the API client. + _policy_index = load_policy_index() + return _policy_index + + +def load_policy_index(pack_name: str | None = None) -> PolicyIndex: + """Load the active PolicyIndex from the governance backend. + + Args: + pack_name: Ignored. Pack selection is controlled entirely by the + backend. + + Returns: + PolicyIndex parsed from the backend response. Empty PolicyIndex + when the backend is unavailable, the token is unset, the YAML + is malformed, or the response yields zero rules. + """ + start = time.perf_counter() + + api_index = _load_from_api() + if api_index is not None: + _log_index_summary(api_index) + logger.info( + "Policy index ready: source=backend, total_ms=%.1f", + (time.perf_counter() - start) * 1000, + ) + return api_index + + reason = _empty_index_reason() + logger.info( + "Policy index ready: source=empty (%s), total_ms=%.1f", + reason, + (time.perf_counter() - start) * 1000, + ) + return PolicyIndex() + + +def _empty_index_reason() -> str: + """Diagnose why the policy fetch produced nothing.""" + if not resolve_organization_id(): + return ( + f"UiPathConfig.organization_id unavailable — set {ENV_ORGANIZATION_ID} " + "or install uipath-platform; backend API not contacted" + ) + if not resolve_tenant_id(): + return ( + f"UiPathConfig.tenant_id unavailable — set {ENV_TENANT_ID} " + "or install uipath-platform; backend API not contacted" + ) + if not os.environ.get(ENV_ACCESS_TOKEN): + return f"{ENV_ACCESS_TOKEN} unset — backend API not contacted" + return "backend returned no policies (timeout / error / empty body)" + + +def _apply_enforcement_mode(mode_str: str | None) -> None: + """Map a backend-supplied mode string onto :class:`EnforcementMode`. + + Unknown values log a warning and leave the existing mode untouched. + """ + if not mode_str: + return + try: + mode = EnforcementMode(mode_str.lower()) + except ValueError: + logger.warning( + "Backend returned unknown enforcement mode %r; keeping current mode", + mode_str, + ) + return + set_enforcement_mode(mode) + logger.info("Enforcement mode set from backend: %s", mode.value) + + +def _load_from_api() -> PolicyIndex | None: + """Fetch and parse the policy index from the governance backend. + + Applies the backend-supplied enforcement mode as a side effect. + Returns ``None`` when the backend skips/errors, when the YAML is + malformed, or when the resulting index has no rules — caller returns + an empty PolicyIndex in those cases. + """ + start = time.perf_counter() + response = fetch_policy_response() + if response is None: + return None + + # Apply the platform-controlled enforcement mode before building the + # index, so anything that reads ``get_enforcement_mode()`` during + # index compilation already sees the right value. + _apply_enforcement_mode(response.mode) + + if not response.policy: + logger.warning( + "Policy fetch returned empty policy field; " + "agent will run without any policies" + ) + return None + + try: + index = build_policy_index_from_yaml(response.policy) + except yaml.YAMLError as exc: + logger.warning("Policy YAML from backend was malformed: %s", exc) + return None + except Exception as exc: # noqa: BLE001 - never let load break agent startup + logger.warning("Failed to build PolicyIndex from backend YAML: %s", exc) + return None + + if index.total_rules == 0: + logger.warning( + "Policy YAML from backend yielded zero rules; " + "agent will run without any policies" + ) + return None + + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info( + "Loaded policy index from backend: packs=%s, rules=%d, elapsed_ms=%.1f", + index.pack_names, + index.total_rules, + elapsed_ms, + ) + return index + + +def _backend_base_url() -> str: + """Return the backend base URL for logging; imported lazily to avoid cycles.""" + try: + from uipath.runtime.governance.native.backend_client import ( + get_backend_base_url, + ) + + return get_backend_base_url() + except Exception: # noqa: BLE001 + return "backend" + + +def _log_index_summary(index: PolicyIndex) -> None: + """Log summary of loaded policy index.""" + # Count rules by hook + hook_counts: Counter[str] = Counter() + for rule in index.all_rules: + hook_counts[rule.hook.value] += 1 + + logger.debug( + "Policy packs: %s, total rules: %d, by hook: %s", + index.pack_names, + index.total_rules, + dict(hook_counts), + ) + + +def get_available_packs() -> list[str]: + """Get list of pack names from the currently loaded policy index. + + Returns whatever the backend supplied on the most recent load. + Empty list if no index has been loaded yet or the backend yielded + no packs. + """ + if _policy_index is None: + return [] + return _policy_index.pack_names + + +def clear_policy_cache() -> None: + """Clear the cached policy index and any in-flight prefetch state. + + Next call to ``get_policy_index()`` will refetch from the backend. + """ + global _policy_index, _prefetch_event + with _prefetch_lock: + _policy_index = None + _prefetch_event = None + logger.debug("Policy index cache cleared") + + +# Backward compatibility alias +reset_policy_index = clear_policy_cache diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py new file mode 100644 index 0000000..325b4e0 --- /dev/null +++ b/src/uipath/runtime/governance/native/policy_api_client.py @@ -0,0 +1,227 @@ +"""Governance policy API client. + +Fetches the governance backend response so policies can be controlled +centrally without redeploying agents. Called once at process startup +from :mod:`uipath.runtime.governance.native.loader`; per-hook evaluation +stays in-process. + +Response shape (JSON):: + + { + "mode": "audit" | "enforce" | "disabled", + "policies": "" + } + +``mode`` is the platform-controlled enforcement mode for the tenant; +the loader applies it via +:func:`uipath.runtime.governance.config.set_enforcement_mode`. ``policies`` +is the YAML the evaluator compiles into a :class:`PolicyIndex`. + +Failure mode is fail-open: when the organization id is unknown, the +access token is missing, the backend errors, or the body can't be +parsed, the caller falls back to an empty PolicyIndex. The fetch is +single-shot (no retry by design — see :func:`_get_once`) so a slow +backend can't extend agent startup beyond +:data:`BACKEND_REQUEST_TIMEOUT_SECONDS`. Nothing in this module ever +raises to the caller. +""" + +from __future__ import annotations + +import json +import logging +import os +import urllib.error +import urllib.request +from dataclasses import dataclass +from urllib.parse import urlencode + +from uipath.runtime.governance.native.backend_client import ( + AGENT_TYPE_PARAM, + BACKEND_REQUEST_TIMEOUT_SECONDS, + ENV_ACCESS_TOKEN, + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + POLICY_API_PATH, + TENANT_HEADER, + agent_type_param, + build_governance_url, + governance_request_headers, + resolve_organization_id, + resolve_tenant_id, +) + +logger = logging.getLogger(__name__) + +# Re-exported alias kept for callers that imported the old name. +POLICY_API_TIMEOUT_SECONDS = BACKEND_REQUEST_TIMEOUT_SECONDS + + +@dataclass(frozen=True) +class PolicyResponse: + """Parsed governance backend response. + + Attributes: + mode: Enforcement mode string the backend returned + (``"audit"`` / ``"enforce"`` / ``"disabled"``), or ``None`` + when the backend omitted it. Loader applies this via + :func:`uipath.runtime.governance.config.set_enforcement_mode`. + policy: Policy pack YAML to compile into a ``PolicyIndex``. May + be an empty string if the backend returned no rules. + """ + + mode: str | None + policy: str + + +def build_policy_url(org_id: str) -> str: + """Build the policy endpoint URL for the given organization id. + + The tenant id is not part of the URL; it travels in the + ``x-uipath-internal-tenantid`` request header (see + :func:`fetch_policy_response`). + + When the hosted agent's type is known (see + :func:`uipath.runtime.governance.native.backend_client.set_agent_conversational`), + an ``agentType`` query param is appended so the server resolves the + conversational-vs-autonomous container key. Omitted when unknown — the + server then applies its default. + """ + url = build_governance_url(org_id, POLICY_API_PATH) + agent_type = agent_type_param() + if agent_type: + url = f"{url}?{urlencode({AGENT_TYPE_PARAM: agent_type})}" + return url + + +def fetch_policy_response() -> PolicyResponse | None: + """Fetch the governance backend's policy response. + + Single shot, no retry: a failed fetch (timeout / network error / + HTTP error / malformed body) returns ``None`` and the caller falls + back to an empty PolicyIndex. The agent must not spend time on a + second attempt — keeping governance off the critical path is more + important than maximising policy availability. + + Returns: + :class:`PolicyResponse` on success. ``None`` on any failure + path — caller falls back to an empty PolicyIndex. + + Never raises. + """ + try: + return _fetch_policy_response_inner() + except Exception as exc: # noqa: BLE001 - loader path must never raise + logger.warning("Policy fetch failed unexpectedly: %s", exc) + return None + + +def _fetch_policy_response_inner() -> PolicyResponse | None: + org_id = resolve_organization_id() + if not org_id: + logger.warning( + "Policy fetch skipped: UiPathConfig.organization_id is not " + "available (set %s in the environment, or ensure uipath-platform " + "is installed); governance will run with no policies. The " + "backend API was NOT contacted.", + ENV_ORGANIZATION_ID, + ) + return None + + tenant_id = resolve_tenant_id() + if not tenant_id: + logger.warning( + "Policy fetch skipped: UiPathConfig.tenant_id is not " + "available (set %s in the environment, or ensure uipath-platform " + "is installed); governance will run with no policies. The " + "backend API was NOT contacted.", + ENV_TENANT_ID, + ) + return None + + policy_url = build_policy_url(org_id) + + token = os.environ.get(ENV_ACCESS_TOKEN) + if not token: + logger.warning( + "Policy fetch skipped: %s is not set in the environment; " + "governance will run with no policies.", + ENV_ACCESS_TOKEN, + ) + return None + + # Policy fetch is a GET; ``json_body=False`` so ``Content-Type`` is + # omitted. Strict origin servers may 415 on unexpected Content-Type + # for GETs (see :func:`governance_request_headers` docstring). + headers = governance_request_headers(json_body=False) + headers[TENANT_HEADER] = tenant_id + logger.info("Policy fetch starting (org=%s, tenant=%s)", org_id, tenant_id) + + body = _get_once(policy_url, headers) + if body is None: + return None + return _parse_policy_body(body) + + +def _get_once(url: str, headers: dict[str, str]) -> bytes | None: + """GET ``url`` once. Returns body bytes, or ``None`` on any failure. + + No retry by design — see :func:`fetch_policy_response` for the + rationale. Every failure path logs a single WARNING and returns + ``None`` so the caller (the loader) falls back to an empty + PolicyIndex without delay. + """ + request = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen( # noqa: S310 - URL is built from config + request, timeout=BACKEND_REQUEST_TIMEOUT_SECONDS + ) as response: + return response.read() + except urllib.error.HTTPError as exc: + logger.warning("Policy fetch returned HTTP %d: %s", exc.code, exc) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + logger.warning("Policy fetch failed: %s", exc) + return None + + +def _parse_policy_body(body: bytes) -> PolicyResponse | None: + """Parse the JSON envelope into a :class:`PolicyResponse`.""" + if not body: + logger.warning("Policy fetch returned empty body") + return None + + try: + payload = json.loads(body.decode("utf-8")) + except UnicodeDecodeError as exc: + logger.warning("Policy fetch returned non-UTF8 body: %s", exc) + return None + except json.JSONDecodeError as exc: + logger.warning( + "Policy fetch returned malformed JSON " + "(server may have returned an HTML error page): %s", + exc, + ) + return None + + if not isinstance(payload, dict): + logger.warning( + "Policy fetch returned unexpected JSON shape (expected object, got %s)", + type(payload).__name__, + ) + return None + + raw_mode = payload.get("mode") + mode = raw_mode if isinstance(raw_mode, str) and raw_mode else None + + raw_policy = payload.get("policies", "") + if not isinstance(raw_policy, str): + logger.warning( + "Policy fetch returned non-string 'policies' field (got %s)", + type(raw_policy).__name__, + ) + return None + + logger.info( + "Policy fetch ok: mode=%s, policy_bytes=%d", mode, len(raw_policy) + ) + return PolicyResponse(mode=mode, policy=raw_policy) diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..1ccc15c --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,379 @@ +"""Tests for the policy loader module. + +Covers prefetch / get_policy_index / load_policy_index / _apply_enforcement_mode +plus the empty-index reason helper. +""" + +from __future__ import annotations + +import threading +import time +from unittest.mock import patch + +import pytest +import yaml + +from uipath.runtime.governance.config import ( + EnforcementMode, + get_enforcement_mode, + reset_enforcement_mode, +) +from uipath.runtime.governance.native import loader +from uipath.runtime.governance.native.loader import ( + _apply_enforcement_mode, + _empty_index_reason, + _load_from_api, + clear_policy_cache, + get_available_packs, + get_policy_index, + load_policy_index, + prefetch_policy_index, +) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.native.policy_api_client import PolicyResponse + +SIMPLE_POLICY_YAML = """ +standard: test-pack +version: "1.0" +rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["leak"] +""" + + +@pytest.fixture(autouse=True) +def _clean_loader_state(monkeypatch: pytest.MonkeyPatch): + """Each test starts with a fresh loader cache and a known env. + + Without this, tests leak the policy_index module global and + `_prefetch_event` into one another. + """ + clear_policy_cache() + reset_enforcement_mode() + # Enable the FF so the loader doesn't short-circuit immediately. + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + yield + clear_policy_cache() + reset_enforcement_mode() + FeatureFlags.reset_flags() + + +# --------------------------------------------------------------------------- +# _empty_index_reason +# --------------------------------------------------------------------------- + + +def test_empty_index_reason_missing_org_id(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + msg = _empty_index_reason() + assert "organization_id" in msg + + +def test_empty_index_reason_missing_tenant_id(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + msg = _empty_index_reason() + assert "tenant_id" in msg + + +def test_empty_index_reason_missing_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + msg = _empty_index_reason() + assert "UIPATH_ACCESS_TOKEN" in msg + + +def test_empty_index_reason_backend_returned_nothing() -> None: + """All env present → reason is 'backend returned no policies'.""" + msg = _empty_index_reason() + assert "backend returned no policies" in msg + + +# --------------------------------------------------------------------------- +# _apply_enforcement_mode +# --------------------------------------------------------------------------- + + +def test_apply_enforcement_mode_none_leaves_current() -> None: + """Calling with ``None`` is a no-op — the existing mode is preserved.""" + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.ENFORCE) + _apply_enforcement_mode(None) + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +def test_apply_enforcement_mode_empty_string_leaves_current() -> None: + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.AUDIT) + _apply_enforcement_mode("") + assert get_enforcement_mode() == EnforcementMode.AUDIT + + +@pytest.mark.parametrize( + "mode_str,expected", + [ + ("audit", EnforcementMode.AUDIT), + ("enforce", EnforcementMode.ENFORCE), + ("disabled", EnforcementMode.DISABLED), + ("AUDIT", EnforcementMode.AUDIT), # case-insensitive + ], +) +def test_apply_enforcement_mode_known_values( + mode_str: str, expected: EnforcementMode +) -> None: + _apply_enforcement_mode(mode_str) + assert get_enforcement_mode() == expected + + +def test_apply_enforcement_mode_unknown_value_keeps_current() -> None: + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.AUDIT) + _apply_enforcement_mode("not-a-real-mode") + # Mode is unchanged after the warning. + assert get_enforcement_mode() == EnforcementMode.AUDIT + + +# --------------------------------------------------------------------------- +# _load_from_api +# --------------------------------------------------------------------------- + + +def test_load_from_api_returns_none_when_fetch_returns_none() -> None: + with patch.object(loader, "fetch_policy_response", return_value=None): + assert _load_from_api() is None + + +def test_load_from_api_returns_none_when_policy_is_empty() -> None: + """A response with mode but empty policies field is treated as nothing.""" + response = PolicyResponse(mode="audit", policy="") + with patch.object(loader, "fetch_policy_response", return_value=response): + assert _load_from_api() is None + + +def test_load_from_api_applies_mode_then_parses() -> None: + """The mode is applied BEFORE the YAML is parsed, so downstream sees it.""" + response = PolicyResponse(mode="enforce", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + index = _load_from_api() + assert isinstance(index, PolicyIndex) + assert index.total_rules == 1 + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +def test_load_from_api_swallows_yaml_error() -> None: + """A malformed YAML body produces None, not an exception.""" + response = PolicyResponse(mode="audit", policy="key: : invalid: : yaml") + with patch.object(loader, "fetch_policy_response", return_value=response): + with patch.object( + loader, + "build_policy_index_from_yaml", + side_effect=yaml.YAMLError("bad yaml"), + ): + assert _load_from_api() is None + + +def test_load_from_api_swallows_unexpected_exception() -> None: + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + with patch.object( + loader, + "build_policy_index_from_yaml", + side_effect=RuntimeError("library bug"), + ): + assert _load_from_api() is None + + +def test_load_from_api_returns_none_when_zero_rules() -> None: + """YAML parses cleanly but yields no rules → treated as no-op.""" + empty_pack_yaml = "standard: empty\nrules: []\n" + response = PolicyResponse(mode="audit", policy=empty_pack_yaml) + with patch.object(loader, "fetch_policy_response", return_value=response): + assert _load_from_api() is None + + +# --------------------------------------------------------------------------- +# load_policy_index — public entry +# --------------------------------------------------------------------------- + + +def test_load_policy_index_success_path() -> None: + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + index = load_policy_index() + assert isinstance(index, PolicyIndex) + assert "test-pack" in index.pack_names + + +def test_load_policy_index_returns_empty_on_failure() -> None: + """When the API yields None, the loader returns an empty PolicyIndex.""" + with patch.object(loader, "fetch_policy_response", return_value=None): + index = load_policy_index() + assert isinstance(index, PolicyIndex) + assert index.total_rules == 0 + + +# --------------------------------------------------------------------------- +# get_policy_index — caching + FF gate +# --------------------------------------------------------------------------- + + +def test_get_policy_index_caches_after_first_call() -> None: + """A second call returns the cached index without re-fetching.""" + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object( + loader, "fetch_policy_response", return_value=response + ) as mock_fetch: + a = get_policy_index() + b = get_policy_index() + assert a is b + assert mock_fetch.call_count == 1 + + +def test_get_policy_index_short_circuits_when_ff_off() -> None: + """FF off → return an empty index without contacting the backend.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + with patch.object(loader, "fetch_policy_response") as mock_fetch: + index = get_policy_index() + assert index.total_rules == 0 + assert not mock_fetch.called + + +def test_get_policy_index_sync_load_when_no_prefetch() -> None: + """Without a prefetch in flight, get_policy_index synchronously loads.""" + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + index = get_policy_index() + assert index.total_rules == 1 + + +# --------------------------------------------------------------------------- +# Prefetch — idempotency + completion + timeout +# --------------------------------------------------------------------------- + + +def test_prefetch_is_idempotent() -> None: + """Second call while first is in flight is a no-op (no second thread).""" + block = threading.Event() + + def _slow_fetch(): + block.wait(timeout=2.0) + return None + + with patch.object(loader, "fetch_policy_response", side_effect=_slow_fetch): + prefetch_policy_index() + first_event = loader._prefetch_event + prefetch_policy_index() + assert loader._prefetch_event is first_event + # Let the worker finish so the autouse fixture's clear runs cleanly. + block.set() + if first_event is not None: + first_event.wait(timeout=2.0) + + +def test_prefetch_skipped_when_ff_off() -> None: + """FF off → no prefetch thread started.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + with patch.object(loader, "fetch_policy_response") as mock_fetch: + prefetch_policy_index() + assert not mock_fetch.called + assert loader._prefetch_event is None + + +def test_prefetch_no_op_when_index_already_loaded() -> None: + """If the index is already cached, prefetch is a no-op.""" + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + get_policy_index() # populate the cache + with patch.object(loader, "fetch_policy_response") as mock_fetch: + prefetch_policy_index() + assert not mock_fetch.called + + +def test_get_policy_index_waits_for_prefetch_then_returns() -> None: + """When a prefetch is in flight, get_policy_index waits for completion.""" + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + started = threading.Event() + release = threading.Event() + + def _fetch(): + started.set() + release.wait(timeout=2.0) + return response + + with patch.object(loader, "fetch_policy_response", side_effect=_fetch): + prefetch_policy_index() + assert started.wait(timeout=2.0) + # Release the worker in a side thread so get_policy_index's wait + # actually overlaps with the slow fetch. + threading.Thread( + target=lambda: (time.sleep(0.05), release.set()), daemon=True + ).start() + index = get_policy_index() + assert index.total_rules == 1 + + +def test_get_policy_index_logs_when_prefetch_completes_with_empty_index( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The 'completed but produced no PolicyIndex' branch fires on auth/parse fail. + + Capturing via a logger mock instead of caplog because some + test-isolation paths (other tests installing log interceptors) + can prevent records from reaching caplog's root-attached handler. + """ + event = threading.Event() + event.set() # prefetch already completed + monkeypatch.setattr(loader, "_prefetch_event", event) + # _policy_index stays None — simulating "prefetch completed but produced nothing" + with patch.object(loader.logger, "warning") as mock_warning: + index = get_policy_index() + assert index.total_rules == 0 + assert any( + "completed but produced no PolicyIndex" in str(call.args[0]) + for call in mock_warning.call_args_list + ) + + +# --------------------------------------------------------------------------- +# get_available_packs / clear_policy_cache +# --------------------------------------------------------------------------- + + +def test_get_available_packs_before_load_returns_empty() -> None: + assert get_available_packs() == [] + + +def test_get_available_packs_after_load() -> None: + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + get_policy_index() + assert "test-pack" in get_available_packs() + + +def test_clear_policy_cache_forces_refetch() -> None: + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object( + loader, "fetch_policy_response", return_value=response + ) as mock_fetch: + get_policy_index() + clear_policy_cache() + get_policy_index() + assert mock_fetch.call_count == 2 + + +def test_reset_policy_index_alias_for_clear() -> None: + """``reset_policy_index`` is the legacy alias for ``clear_policy_cache``.""" + assert loader.reset_policy_index is loader.clear_policy_cache diff --git a/tests/test_policy_agent_type.py b/tests/test_policy_agent_type.py new file mode 100644 index 0000000..4eb30f9 --- /dev/null +++ b/tests/test_policy_agent_type.py @@ -0,0 +1,99 @@ +"""Tests for the conversational-vs-autonomous agent-type selector. + +The governance wrapper records whether the hosted agent is conversational; +the policy fetch then appends an ``agentType`` query param so the server's +clause-resolver reads the matching container key (``*-in-flight-agents`` vs +``*-in-flight-conversational-agents``). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from uipath.runtime.governance.native import backend_client +from uipath.runtime.governance.native.backend_client import ( + agent_type_param, + set_agent_conversational, +) +from uipath.runtime.governance.native.policy_api_client import build_policy_url +from uipath.runtime.governance.wrapper import GovernanceRuntime + + +def _extract(delegate, context=None) -> bool: + """Call _extract_is_conversational without running __init__.""" + runtime = object.__new__(GovernanceRuntime) + return runtime._extract_is_conversational(delegate, context) + + +@pytest.fixture(autouse=True) +def _reset_selector(): + """Clear the process-level selector around each test.""" + set_agent_conversational(None) + yield + set_agent_conversational(None) + + +def test_agent_type_param_unset_is_none(): + assert agent_type_param() is None + + +def test_agent_type_param_conversational(): + set_agent_conversational(True) + assert agent_type_param() == "conversational" + + +def test_agent_type_param_autonomous(): + set_agent_conversational(False) + assert agent_type_param() == "autonomous" + + +def test_build_policy_url_omits_param_when_unset(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + url = build_policy_url("my-org") + assert url == "https://alpha.uipath.com/my-org/agenticgovernance_/api/v1/runtime/policy" + assert "agentType" not in url + + +def test_build_policy_url_appends_conversational(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + set_agent_conversational(True) + assert build_policy_url("my-org").endswith( + "/my-org/agenticgovernance_/api/v1/runtime/policy?agentType=conversational" + ) + + +def test_build_policy_url_appends_autonomous(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + set_agent_conversational(False) + assert build_policy_url("my-org").endswith("?agentType=autonomous") + + +# ── _extract_is_conversational ────────────────────────────────────────────── + + +def test_extract_conversational_from_agent_definition(): + delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + assert _extract(delegate) is True + + +def test_extract_autonomous_from_agent_definition(): + delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) + assert _extract(delegate) is False + + +def test_extract_unwraps_delegate_chain(): + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + outer = SimpleNamespace(_delegate=inner) # no _agent_definition on the outer + assert _extract(outer) is True + + +def test_extract_falls_back_to_context_conversation_id(): + delegate = SimpleNamespace() # nothing reachable + context = SimpleNamespace(conversation_id="conv-1") + assert _extract(delegate, context) is True + + +def test_extract_defaults_to_autonomous_when_unknown(): + assert _extract(SimpleNamespace(), SimpleNamespace()) is False \ No newline at end of file diff --git a/tests/test_policy_api_client.py b/tests/test_policy_api_client.py new file mode 100644 index 0000000..9ebcdb5 --- /dev/null +++ b/tests/test_policy_api_client.py @@ -0,0 +1,258 @@ +"""Tests for ``fetch_policy_response`` and the body parser. + +Covers the skip paths (missing org / tenant / token), HTTP failures +(HTTPError, URLError, TimeoutError, OSError), and body parsing +(empty body, non-UTF8, malformed JSON, wrong top-level shape, bad +``policies`` type). +""" + +from __future__ import annotations + +import io +import json +import urllib.error +from unittest.mock import MagicMock, patch + +import pytest + +from uipath.runtime.governance.native import policy_api_client +from uipath.runtime.governance.native.policy_api_client import ( + PolicyResponse, + _parse_policy_body, + build_policy_url, + fetch_policy_response, +) + + +@pytest.fixture +def _fresh_env(monkeypatch: pytest.MonkeyPatch): + """Clear the env vars that the fetch path depends on.""" + for var in ( + "UIPATH_ORGANIZATION_ID", + "UIPATH_TENANT_ID", + "UIPATH_ACCESS_TOKEN", + "UIPATH_URL", + ): + monkeypatch.delenv(var, raising=False) + yield + + +@pytest.fixture +def _populated_env(monkeypatch: pytest.MonkeyPatch): + """All three vars present — the fetch path can reach urlopen.""" + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok-abc") + monkeypatch.setenv("UIPATH_URL", "https://alpha.uipath.com") + yield + + +def _ok_response(body: bytes) -> MagicMock: + """urlopen()-compatible context manager that returns ``body``.""" + resp = MagicMock() + resp.read.return_value = body + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return resp + + +# --------------------------------------------------------------------------- +# Skip paths — fail-open without contacting the backend +# --------------------------------------------------------------------------- + + +def test_skip_when_org_id_missing(_fresh_env, monkeypatch) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + with patch.object( + policy_api_client.urllib.request, "urlopen" + ) as mock_urlopen: + assert fetch_policy_response() is None + assert not mock_urlopen.called + + +def test_skip_when_tenant_id_missing(_fresh_env, monkeypatch) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + with patch.object( + policy_api_client.urllib.request, "urlopen" + ) as mock_urlopen: + assert fetch_policy_response() is None + assert not mock_urlopen.called + + +def test_skip_when_token_missing(_fresh_env, monkeypatch) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + with patch.object( + policy_api_client.urllib.request, "urlopen" + ) as mock_urlopen: + assert fetch_policy_response() is None + assert not mock_urlopen.called + + +# --------------------------------------------------------------------------- +# HTTP failure paths — fail-open with a warning +# --------------------------------------------------------------------------- + + +def test_returns_none_on_http_error(_populated_env) -> None: + err = urllib.error.HTTPError( + url="x", code=500, msg="Server Error", hdrs=None, fp=io.BytesIO(b"") + ) + with patch.object( + policy_api_client.urllib.request, "urlopen", side_effect=err + ): + assert fetch_policy_response() is None + + +def test_returns_none_on_url_error(_populated_env) -> None: + err = urllib.error.URLError("connection refused") + with patch.object( + policy_api_client.urllib.request, "urlopen", side_effect=err + ): + assert fetch_policy_response() is None + + +def test_returns_none_on_timeout(_populated_env) -> None: + with patch.object( + policy_api_client.urllib.request, "urlopen", side_effect=TimeoutError() + ): + assert fetch_policy_response() is None + + +def test_returns_none_on_os_error(_populated_env) -> None: + with patch.object( + policy_api_client.urllib.request, + "urlopen", + side_effect=OSError("disk full"), + ): + assert fetch_policy_response() is None + + +def test_outer_swallows_unexpected_exception(_populated_env) -> None: + """Even non-HTTP exceptions from urlopen don't escape the fetch helper.""" + with patch.object( + policy_api_client.urllib.request, + "urlopen", + side_effect=RuntimeError("library bug"), + ): + assert fetch_policy_response() is None + + +# --------------------------------------------------------------------------- +# Headers / URL composition +# --------------------------------------------------------------------------- + + +def test_sends_no_content_type_on_get(_populated_env) -> None: + """The GET must NOT carry Content-Type — some servers 415 on it.""" + with patch.object( + policy_api_client.urllib.request, + "urlopen", + return_value=_ok_response(b'{"mode": "audit", "policies": ""}'), + ) as mock_urlopen: + fetch_policy_response() + request_arg = mock_urlopen.call_args.args[0] + assert request_arg.get_header("Content-type") is None + assert request_arg.get_header("Accept") == "application/json" + assert request_arg.get_header("Authorization") == "Bearer tok-abc" + assert request_arg.get_header("X-uipath-internal-tenantid") == "tenant-1" + assert request_arg.get_method() == "GET" + + +def test_url_includes_agent_type_when_set(_populated_env, monkeypatch) -> None: + """``build_policy_url`` appends ``?agentType=...`` from the selector.""" + from uipath.runtime.governance.native import backend_client + + monkeypatch.setattr(backend_client, "_agent_is_conversational", True) + url = build_policy_url("org-x") + assert "agentType=conversational" in url + + +def test_url_omits_agent_type_when_unset(_populated_env, monkeypatch) -> None: + from uipath.runtime.governance.native import backend_client + + monkeypatch.setattr(backend_client, "_agent_is_conversational", None) + url = build_policy_url("org-x") + assert "agentType=" not in url + + +# --------------------------------------------------------------------------- +# Body parser — _parse_policy_body +# --------------------------------------------------------------------------- + + +def test_parse_empty_body_returns_none() -> None: + assert _parse_policy_body(b"") is None + + +def test_parse_non_utf8_body_returns_none() -> None: + # 0xff isn't valid UTF-8. + assert _parse_policy_body(b"\xff\xfe") is None + + +def test_parse_malformed_json_returns_none() -> None: + # A common shape: server returns HTML when it should return JSON. + assert _parse_policy_body(b"oops") is None + + +def test_parse_non_object_top_level_returns_none() -> None: + """Server returning a bare JSON array is rejected — expected an object.""" + assert _parse_policy_body(b'["audit", "policies"]') is None + + +def test_parse_non_string_policies_field_returns_none() -> None: + """``policies`` must be a string YAML body, not a number / dict / list.""" + assert _parse_policy_body(b'{"mode": "audit", "policies": 42}') is None + + +def test_parse_ok_yields_policy_response() -> None: + resp = _parse_policy_body( + b'{"mode": "enforce", "policies": "standard: p\\nrules: []"}' + ) + assert resp is not None + assert resp.mode == "enforce" + assert "standard: p" in resp.policy + + +def test_parse_ok_with_missing_mode_yields_none_mode() -> None: + """A response without ``mode`` is still valid — server may not override.""" + resp = _parse_policy_body(b'{"policies": ""}') + assert resp is not None + assert resp.mode is None + assert resp.policy == "" + + +def test_parse_empty_string_mode_treated_as_unset() -> None: + """Empty-string ``mode`` is normalized to ``None`` (don't override default).""" + resp = _parse_policy_body(b'{"mode": "", "policies": ""}') + assert resp is not None + assert resp.mode is None + + +def test_parse_non_string_mode_treated_as_unset() -> None: + """If the server sends mode as a number / null, treat as unset.""" + resp = _parse_policy_body(b'{"mode": 5, "policies": ""}') + assert resp is not None + assert resp.mode is None + + +# --------------------------------------------------------------------------- +# Full happy-path round-trip +# --------------------------------------------------------------------------- + + +def test_full_fetch_round_trip(_populated_env) -> None: + body = json.dumps( + {"mode": "audit", "policies": "standard: p\nrules: []"} + ).encode("utf-8") + with patch.object( + policy_api_client.urllib.request, + "urlopen", + return_value=_ok_response(body), + ): + resp = fetch_policy_response() + assert isinstance(resp, PolicyResponse) + assert resp.mode == "audit" + assert "standard: p" in resp.policy diff --git a/tests/test_yaml_to_index.py b/tests/test_yaml_to_index.py new file mode 100644 index 0000000..5e8d338 --- /dev/null +++ b/tests/test_yaml_to_index.py @@ -0,0 +1,795 @@ +"""Tests for ``build_policy_index_from_yaml``. + +Covers every supported check type plus the pack / rule plumbing +(default action, severity defaults, hook resolution, multi-doc YAML, +malformed input handling). +""" + +from __future__ import annotations + +import pytest +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.native._yaml_to_index import ( + build_policy_index_from_yaml, +) +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] + + +# --------------------------------------------------------------------------- +# Pack / document handling +# --------------------------------------------------------------------------- + + +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"] + + +# --------------------------------------------------------------------------- +# Rule-level plumbing +# --------------------------------------------------------------------------- + + +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 + + +# --------------------------------------------------------------------------- +# Check types +# --------------------------------------------------------------------------- + + +@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 From 921868b0b1c0ac8e1be03f68dd6ba0ac3b7308a3 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Tue, 16 Jun 2026 14:37:25 +0530 Subject: [PATCH 2/7] =?UTF-8?q?fix(governance):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20don't=20cache=20empty=20PolicyIndex=20on=20worker?= =?UTF-8?q?=20failure,=20default=20explicit=20conditions=20to=20AND,=20pol?= =?UTF-8?q?icy=5Fchars=20label,=20importorskip=20wrapper=20in=20agent-type?= =?UTF-8?q?=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../governance/native/_yaml_to_index.py | 13 +++++++-- .../runtime/governance/native/loader.py | 28 +++++++++++-------- .../governance/native/policy_api_client.py | 2 +- tests/test_policy_agent_type.py | 8 +++++- 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index 2deb463..f17664e 100644 --- a/src/uipath/runtime/governance/native/_yaml_to_index.py +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -418,9 +418,16 @@ def _build_check( message = str(data.get("message", message)) - # Multi-pattern regex/parameter_validation defaults to OR semantics - # (any pattern indicates a hit); explicit `logic` in YAML wins. - if check_type in ("parameter_validation", "regex") and len(conditions) > 1: + # Multi-PATTERN shorthand (regex/parameter_validation expanded from + # several patterns for one concept) defaults to OR — any pattern + # hitting is a match. An explicit `conditions:` list defaults to AND + # (all must hold) and must NOT inherit the pattern-shorthand OR even + # though `check_type` falls back to "regex". Explicit `logic` wins. + if ( + not has_explicit_conditions + and check_type in ("parameter_validation", "regex") + and len(conditions) > 1 + ): default_logic = "any" else: default_logic = "all" diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index e2fd138..bd9a4a0 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -143,22 +143,28 @@ def get_policy_index() -> PolicyIndex: if completed and _policy_index is not None: return _policy_index if not completed: + # Timeout: deliberately cache an empty index so we don't + # re-wait the full timeout on every subsequent hook. logger.warning( "Policy prefetch did not complete in %.1fs; " "agent will run without any policies", _PREFETCH_WAIT_SECONDS, ) - else: - # Distinguish from the timeout path so production triage - # can tell "prefetch hung" from "prefetch returned empty" - # (auth failure, server error, parse failure). - logger.warning( - "Policy prefetch completed but produced no PolicyIndex " - "(see prior WARN for the root cause); agent will run " - "without any policies" - ) - _policy_index = PolicyIndex() - return _policy_index + _policy_index = PolicyIndex() + return _policy_index + + # Completed but produced no PolicyIndex — the worker hit an + # unexpected error (auth failure, server error, parse failure). + # Do NOT cache the empty result: caching would permanently + # disable governance for the process even though a later + # prefetch / clear_policy_cache could still recover. Return an + # empty index for this call only and leave the cache unset. + logger.warning( + "Policy prefetch completed but produced no PolicyIndex " + "(see prior WARN for the root cause); agent will run " + "without any policies for this call" + ) + return PolicyIndex() # No prefetch was started (direct callers / tests). Sync load — bounded # by the HTTP timeout in the API client. diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py index 325b4e0..7a9ee96 100644 --- a/src/uipath/runtime/governance/native/policy_api_client.py +++ b/src/uipath/runtime/governance/native/policy_api_client.py @@ -222,6 +222,6 @@ def _parse_policy_body(body: bytes) -> PolicyResponse | None: return None logger.info( - "Policy fetch ok: mode=%s, policy_bytes=%d", mode, len(raw_policy) + "Policy fetch ok: mode=%s, policy_chars=%d", mode, len(raw_policy) ) return PolicyResponse(mode=mode, policy=raw_policy) diff --git a/tests/test_policy_agent_type.py b/tests/test_policy_agent_type.py index 4eb30f9..f9b9fdb 100644 --- a/tests/test_policy_agent_type.py +++ b/tests/test_policy_agent_type.py @@ -18,7 +18,13 @@ set_agent_conversational, ) from uipath.runtime.governance.native.policy_api_client import build_policy_url -from uipath.runtime.governance.wrapper import GovernanceRuntime + +# The wrapper lands in a later slice of the governance stack; skip (don't +# error at collection) when it isn't present yet. +GovernanceRuntime = pytest.importorskip( + "uipath.runtime.governance.wrapper", + reason="GovernanceRuntime wrapper not yet present in this slice", +).GovernanceRuntime def _extract(delegate, context=None) -> bool: From acfa5b5228282cb728016b08e3473ba349458a2a Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 17 Jun 2026 10:57:51 +0530 Subject: [PATCH 3/7] fix(governance): decouple backend/policy client from uipath-platform - backend_client/policy_api_client/loader read org/tenant (+ job context) from the environment via runtime-local ENV_* constants instead of importing UiPathConfig. Adds ENV_TRACE_ID. Diagnostic/log messages no longer reference uipath-platform. - _yaml_to_index: convert the parsed logic string to the Logic enum (Check.logic is now typed Logic). - test_loader: assert on env-var names; import reset helper from tests._helpers. Co-Authored-By: Claude Opus 4.8 --- .../governance/native/_yaml_to_index.py | 7 +- .../governance/native/backend_client.py | 87 ++++++++----------- .../runtime/governance/native/loader.py | 8 +- .../governance/native/policy_api_client.py | 14 ++- tests/test_loader.py | 6 +- 5 files changed, 54 insertions(+), 68 deletions(-) diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index f17664e..c4080f2 100644 --- a/src/uipath/runtime/governance/native/_yaml_to_index.py +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -22,6 +22,7 @@ from uipath.runtime.governance.native.models import ( Check, Condition, + Logic, PolicyIndex, PolicyPack, Rule, @@ -431,7 +432,11 @@ def _build_check( default_logic = "any" else: default_logic = "all" - logic = str(data.get("logic", default_logic)) + logic_str = str(data.get("logic", default_logic)).lower() + try: + logic = Logic(logic_str) + except ValueError: + logic = Logic.ALL return Check(conditions=conditions, action=action, message=message, logic=logic) diff --git a/src/uipath/runtime/governance/native/backend_client.py b/src/uipath/runtime/governance/native/backend_client.py index 8269ea7..a406c35 100644 --- a/src/uipath/runtime/governance/native/backend_client.py +++ b/src/uipath/runtime/governance/native/backend_client.py @@ -11,8 +11,8 @@ - :func:`build_governance_url` — composes an org-scoped URL against the ``agenticgovernance_`` ingress. - :func:`resolve_organization_id` / :func:`resolve_tenant_id` — read - the active org/tenant from ``UiPathConfig`` with an env-var fallback - for installations that don't have ``uipath-platform``. + the active org/tenant from the environment (published by the UiPath + runtime host), keeping runtime independent of ``uipath-platform``. - :func:`safe_call` — fail-open helper that catches every non-block exception so governance hooks never crash an agent run. - Module-level constants — request timeout, service path prefix, @@ -42,7 +42,7 @@ # Explicit dev/test override — used verbatim, no path-stripping. ENV_BACKEND_BASE_URL = "UIPATH_GOVERNANCE_BACKEND_URL" -# The canonical platform URL env var (also backs ``UiPathConfig.base_url``). +# The canonical platform URL env var. ENV_PLATFORM_BASE_URL = "UIPATH_URL" # Bearer token; missing means the policy fetch and compensating call are # skipped (and that fact is logged) rather than producing 401s on every call. @@ -50,9 +50,12 @@ # Org / tenant scoping for the agenticgovernance_ ingress. ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" ENV_TENANT_ID = "UIPATH_TENANT_ID" +# Trace id used to bind governance spans / compensation records to the +# agent's trace. +ENV_TRACE_ID = "UIPATH_TRACE_ID" # Job-execution context forwarded in the /runtime/govern payload so the # server can populate the LLMOps trace record (Doc-2 audit structure). -# Each falls back to the named env var when uipath-platform isn't present. +# Published into the process environment by the UiPath runtime host. ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" ENV_JOB_KEY = "UIPATH_JOB_KEY" ENV_PROCESS_KEY = "UIPATH_PROCESS_UUID" @@ -79,7 +82,7 @@ AGENT_TYPE_CONVERSATIONAL = "conversational" AGENT_TYPE_AUTONOMOUS = "autonomous" -# Default base URL when no override and no UiPathConfig / UIPATH_URL value is +# Default base URL when no override and no UIPATH_URL value is # available. Used only on developer machines doing fully-offline work; real # deployments always have UIPATH_URL injected by the host. _DEFAULT_BACKEND_BASE_URL = "https://alpha.uipath.com" @@ -183,12 +186,10 @@ def get_backend_base_url() -> str: 1. ``UIPATH_GOVERNANCE_BACKEND_URL`` — explicit dev/test override, used verbatim. - 2. ``UiPathConfig.base_url`` from ``uipath-platform`` — the - canonical platform URL. Org/tenant path segments are stripped - so the caller can append its own org-scoped path. - 3. ``UIPATH_URL`` env var — same as (2) but works when - ``uipath-platform`` is not installed. - 4. ``https://alpha.uipath.com`` — last-resort default for offline + 2. ``UIPATH_URL`` env var — the canonical platform URL. Org/tenant + path segments are stripped so the caller can append its own + org-scoped path. + 3. ``https://alpha.uipath.com`` — last-resort default for offline development; real deployments always have ``UIPATH_URL`` set. Reading on each call (not at import) lets the runtime entrypoint @@ -198,17 +199,7 @@ def get_backend_base_url() -> str: if explicit_override: return explicit_override.rstrip("/") - # Lazy import — uipath-platform is optional; falls through to the - # env-var path when only uipath-core / uipath-runtime are installed. - platform_url: str | None = None - try: - from uipath.platform.common import UiPathConfig - - platform_url = UiPathConfig.base_url - except (ImportError, AttributeError): - pass - - raw = platform_url or os.environ.get(ENV_PLATFORM_BASE_URL) + raw = os.environ.get(ENV_PLATFORM_BASE_URL) if raw: return _strip_to_origin(raw) @@ -234,20 +225,15 @@ def build_governance_url(org_id: str, path: str) -> str: # ---------------------------------------------------------------------------- -def _resolve_uipath_config_field(attr: str, env_var: str) -> str | None: - """Read a single ``UiPathConfig`` attribute with an env-var fallback. +def _resolve_env_field(env_var: str) -> str | None: + """Read a runtime-context value from its environment variable. - Lazy-imports ``UiPathConfig`` so ``uipath-runtime`` doesn't require - ``uipath-platform`` at install time. When the platform package is - missing (``ImportError``) or the attribute isn't yet exposed - (``AttributeError``), falls back to reading the named env var. + Org/tenant ids and job context are published into the process + environment by the UiPath runtime host. Reading them directly keeps + ``uipath-runtime`` independent of ``uipath-platform`` (the lower layer + must not import the higher one). """ - try: - from uipath.platform.common import UiPathConfig - - return getattr(UiPathConfig, attr, None) or os.environ.get(env_var) - except ImportError: - return os.environ.get(env_var) + return os.environ.get(env_var) # ---------------------------------------------------------------------------- @@ -286,23 +272,22 @@ def agent_type_param() -> str | None: def resolve_organization_id() -> str | None: - """Return the current organization id from ``UiPathConfig`` / env. + """Return the current organization id from the environment. - Returns ``None`` when neither source yields a value — callers skip - the backend interaction (no URL can be built without an org id) - and the agent runs with no policies / no compensation. + Returns ``None`` when unset — callers skip the backend interaction + (no URL can be built without an org id) and the agent runs with no + policies / no compensation. """ - return _resolve_uipath_config_field("organization_id", ENV_ORGANIZATION_ID) + return _resolve_env_field(ENV_ORGANIZATION_ID) def resolve_tenant_id() -> str | None: - """Return the current tenant id from ``UiPathConfig`` / env. + """Return the current tenant id from the environment. - Returns ``None`` when neither source yields a value — callers skip - the backend interaction since the ``x-uipath-internal-tenantid`` - header would be missing. + Returns ``None`` when unset — callers skip the backend interaction + since the ``x-uipath-internal-tenantid`` header would be missing. """ - return _resolve_uipath_config_field("tenant_id", ENV_TENANT_ID) + return _resolve_env_field(ENV_TENANT_ID) @lru_cache(maxsize=1) @@ -314,13 +299,11 @@ def _resolved_job_context() -> tuple[tuple[str, str], ...]: mutate env vars can invalidate via ``resolve_job_context.cache_clear()``. """ candidates = { - "folderKey": _resolve_uipath_config_field("folder_key", ENV_FOLDER_KEY), - "jobKey": _resolve_uipath_config_field("job_key", ENV_JOB_KEY), - "processKey": _resolve_uipath_config_field("process_uuid", ENV_PROCESS_KEY), - "referenceId": _resolve_uipath_config_field("agent_id", ENV_REFERENCE_ID), - "agentVersion": _resolve_uipath_config_field( - "process_version", ENV_AGENT_VERSION - ), + "folderKey": _resolve_env_field(ENV_FOLDER_KEY), + "jobKey": _resolve_env_field(ENV_JOB_KEY), + "processKey": _resolve_env_field(ENV_PROCESS_KEY), + "referenceId": _resolve_env_field(ENV_REFERENCE_ID), + "agentVersion": _resolve_env_field(ENV_AGENT_VERSION), } return tuple((k, v) for k, v in candidates.items() if v) @@ -328,7 +311,7 @@ def _resolved_job_context() -> tuple[tuple[str, str], ...]: def resolve_job_context() -> dict[str, str]: """Return the agent's job-execution context for the govern payload. - Each field is read from ``UiPathConfig`` (env-var fallback) and only + Each field is read from its environment variable and only included when it resolves to a truthy value, so the server receives exactly the keys the agent actually knows. Cached per-process — the underlying values are immutable for the agent's lifetime. The server diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index bd9a4a0..4d02cff 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -208,13 +208,13 @@ def _empty_index_reason() -> str: """Diagnose why the policy fetch produced nothing.""" if not resolve_organization_id(): return ( - f"UiPathConfig.organization_id unavailable — set {ENV_ORGANIZATION_ID} " - "or install uipath-platform; backend API not contacted" + f"organization id unavailable — set {ENV_ORGANIZATION_ID}; " + "backend API not contacted" ) if not resolve_tenant_id(): return ( - f"UiPathConfig.tenant_id unavailable — set {ENV_TENANT_ID} " - "or install uipath-platform; backend API not contacted" + f"tenant id unavailable — set {ENV_TENANT_ID}; " + "backend API not contacted" ) if not os.environ.get(ENV_ACCESS_TOKEN): return f"{ENV_ACCESS_TOKEN} unset — backend API not contacted" diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py index 7a9ee96..0bc428a 100644 --- a/src/uipath/runtime/governance/native/policy_api_client.py +++ b/src/uipath/runtime/governance/native/policy_api_client.py @@ -120,10 +120,9 @@ def _fetch_policy_response_inner() -> PolicyResponse | None: org_id = resolve_organization_id() if not org_id: logger.warning( - "Policy fetch skipped: UiPathConfig.organization_id is not " - "available (set %s in the environment, or ensure uipath-platform " - "is installed); governance will run with no policies. The " - "backend API was NOT contacted.", + "Policy fetch skipped: organization id is not available " + "(set %s in the environment); governance will run with no " + "policies. The backend API was NOT contacted.", ENV_ORGANIZATION_ID, ) return None @@ -131,10 +130,9 @@ def _fetch_policy_response_inner() -> PolicyResponse | None: tenant_id = resolve_tenant_id() if not tenant_id: logger.warning( - "Policy fetch skipped: UiPathConfig.tenant_id is not " - "available (set %s in the environment, or ensure uipath-platform " - "is installed); governance will run with no policies. The " - "backend API was NOT contacted.", + "Policy fetch skipped: tenant id is not available " + "(set %s in the environment); governance will run with no " + "policies. The backend API was NOT contacted.", ENV_TENANT_ID, ) return None diff --git a/tests/test_loader.py b/tests/test_loader.py index 1ccc15c..202de39 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -13,10 +13,10 @@ import pytest import yaml +from tests._helpers import reset_enforcement_mode from uipath.runtime.governance.config import ( EnforcementMode, get_enforcement_mode, - reset_enforcement_mode, ) from uipath.runtime.governance.native import loader from uipath.runtime.governance.native.loader import ( @@ -74,13 +74,13 @@ def _clean_loader_state(monkeypatch: pytest.MonkeyPatch): def test_empty_index_reason_missing_org_id(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) msg = _empty_index_reason() - assert "organization_id" in msg + assert "UIPATH_ORGANIZATION_ID" in msg def test_empty_index_reason_missing_tenant_id(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) msg = _empty_index_reason() - assert "tenant_id" in msg + assert "UIPATH_TENANT_ID" in msg def test_empty_index_reason_missing_token(monkeypatch: pytest.MonkeyPatch) -> None: From 1c001d4f19128250b08d5348a5534708c3ecf7ec Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Fri, 19 Jun 2026 13:38:04 +0530 Subject: [PATCH 4/7] fix(governance): import env constants/resolvers from backend_client (their definition site) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loader.py imported ENV_ORGANIZATION_ID/ENV_TENANT_ID/resolve_organization_id/ resolve_tenant_id from policy_api_client, which only re-imports them from backend_client — tripping mypy's no_implicit_reexport (4 attr-defined errors). Import them directly from backend_client where they're defined. No runtime change; clears mypy across the stack. Co-Authored-By: Claude Opus 4.8 --- src/uipath/runtime/governance/native/loader.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index 4d02cff..6b55022 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -20,16 +20,18 @@ from uipath.runtime.governance.config import EnforcementMode, set_enforcement_mode from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml -from uipath.runtime.governance.native.backend_client import ENV_ACCESS_TOKEN -from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.native.policy_api_client import ( +from uipath.runtime.governance.native.backend_client import ( + ENV_ACCESS_TOKEN, ENV_ORGANIZATION_ID, ENV_TENANT_ID, - POLICY_API_TIMEOUT_SECONDS, - fetch_policy_response, resolve_organization_id, resolve_tenant_id, ) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.native.policy_api_client import ( + POLICY_API_TIMEOUT_SECONDS, + fetch_policy_response, +) logger = logging.getLogger(__name__) From e708f4f3912eb4cf6ffa8a7b5c301edf45e9f3b6 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Tue, 23 Jun 2026 19:13:39 +0530 Subject: [PATCH 5/7] feat(governance): provider-only policy loading via GovernancePolicyProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the direct backend HTTP fetch with a GovernancePolicyProvider indirection so the runtime no longer owns transport, auth, or wire format. Adds the GovernanceRuntime wrapper and the architecture doc. - src/uipath/runtime/governance/runtime.py: new GovernanceRuntime(delegate, policy_provider). Extracts delegate._agent_definition.is_conversational (depth-capped chain walk), registers the provider, kicks off prefetch. Passthrough at execute/stream/get_schema/dispose — policy loading only, no enforcement yet (evaluator slice lands separately). - src/uipath/runtime/governance/native/loader.py: provider-only loader. set_policy_provider, set_agent_conversational, prefetch_policy_index, get_policy_index, clear_policy_cache. Cached PolicyIndex; fail-open on every failure path (raise / empty / malformed / zero rules / timeout). - src/uipath/runtime/governance/native/_yaml_to_index.py: drop hardcoded default clause-id messages ("A.7.4" / "A.8.4" / "A.10.4"); messages now come from YAML, defaulting to "". - src/uipath/runtime/governance/config.py: docstrings reworded for the provider-supplied enforcement mode (no endpoint references). - Removed src/uipath/runtime/governance/native/policy_api_client.py and src/uipath/runtime/governance/native/backend_client.py — direct HTTP fetcher and its shared helpers. Selector + timeout moved into loader.py. - pyproject.toml: bump uipath-core to ==0.5.21. - tests: new tests/test_governance_runtime.py (extraction, fail-open, selector-overwrite regression, prefetch integration), rewritten tests/test_loader.py for the provider contract, shared StubPolicyProvider in tests/_helpers.py. - docs/governance-architecture.md: provider-only design with explicit 'policy loading only, no enforcement yet' staging caveat, module map, lifecycle diagram, failure-mode table. ruff / mypy clean, 197 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- src/uipath/runtime/governance/config.py | 38 +- .../governance/native/_yaml_to_index.py | 15 +- .../governance/native/backend_client.py | 366 -------------- .../runtime/governance/native/loader.py | 249 +++++----- .../governance/native/policy_api_client.py | 225 --------- src/uipath/runtime/governance/runtime.py | 162 ++++++ tests/_helpers.py | 45 +- tests/conftest.py | 20 +- tests/test_enforcement_mode_default.py | 10 +- tests/test_governance_runtime.py | 460 ++++++++++++++++++ tests/test_loader.py | 328 +++++-------- tests/test_policy_agent_type.py | 105 ---- tests/test_policy_api_client.py | 258 ---------- uv.lock | 8 +- 15 files changed, 925 insertions(+), 1366 deletions(-) delete mode 100644 src/uipath/runtime/governance/native/backend_client.py delete mode 100644 src/uipath/runtime/governance/native/policy_api_client.py create mode 100644 src/uipath/runtime/governance/runtime.py create mode 100644 tests/test_governance_runtime.py delete mode 100644 tests/test_policy_agent_type.py delete mode 100644 tests/test_policy_api_client.py diff --git a/pyproject.toml b/pyproject.toml index e9f9950..1c06982 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Runtime abstractions and interfaces for building agents and autom readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.19, <0.6.0", + "uipath-core>=0.5.21, <0.6.0", "pyyaml>=6.0, <7.0", "vaderSentiment>=3.3.2, <4.0", "chardet>=5.2.0, <8.0", diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py index f74d51d..d766dfd 100644 --- a/src/uipath/runtime/governance/config.py +++ b/src/uipath/runtime/governance/config.py @@ -3,10 +3,10 @@ The feature-flag gate (``is_governance_enabled``) lives in :mod:`uipath.core.governance.config` because it is process-level and must be resolvable by callers that do not depend on -``uipath-runtime``. The enforcement mode is *per-policy* — owned by the -backend and delivered on each policy fetch via the ``/runtime/policy`` -endpoint — and therefore lives here in the runtime package alongside the -policy loader that applies it. +``uipath-runtime``. The enforcement mode is *per-policy* — +provider-supplied on each policy load — and therefore lives here in +the runtime package alongside the policy loader that applies it via +:func:`set_enforcement_mode`. """ from __future__ import annotations @@ -23,30 +23,30 @@ class _EnforcementModeState: A single module-level instance backs the get/set/reset helpers, so the mode is updated by mutating an attribute rather than rebinding a module - global. ``mode is None`` means "not yet set by the backend" — until - then (and if the backend omits a mode) governance defaults to AUDIT. + global. ``mode is None`` means "no provider has supplied a mode yet" — + until then (and if the provider omits a mode) governance defaults to + AUDIT. """ def __init__(self) -> None: self.mode: EnforcementMode | None = None -# The enforcement mode is owned by the backend: the policy loader applies -# the mode from the ``/runtime/policy`` response via -# :func:`set_enforcement_mode`. +# The enforcement mode is supplied by the policy provider on each load; +# the loader applies it via :func:`set_enforcement_mode`. _state = _EnforcementModeState() def get_enforcement_mode() -> EnforcementMode: """Return the current enforcement mode. - The canonical source is the backend ``/runtime/policy`` response, - applied by the policy loader via :func:`set_enforcement_mode`. Until - that fetch lands (or if the backend returns no mode), the default is - :attr:`EnforcementMode.AUDIT` — evaluate and log without blocking. - Defaulting to AUDIT avoids the chicken-and-egg where a DISABLED - default would short-circuit evaluation before the background policy - fetch could ever opt the tenant in. + The canonical source is whatever the policy provider supplied on + the most recent load, applied via :func:`set_enforcement_mode`. + Until that load lands (or if the provider returns no mode), the + default is :attr:`EnforcementMode.AUDIT` — evaluate and log without + blocking. Defaulting to AUDIT avoids the chicken-and-egg where a + DISABLED default would short-circuit evaluation before the + background policy load could ever opt the tenant in. """ return _state.mode if _state.mode is not None else EnforcementMode.AUDIT @@ -54,7 +54,7 @@ def get_enforcement_mode() -> EnforcementMode: def set_enforcement_mode(mode: EnforcementMode) -> None: """Set the enforcement mode programmatically. - The policy loader calls this with the backend-supplied mode on each - fetch so the evaluator picks up the platform-controlled value. + The policy loader calls this with the provider-supplied mode on + each load so the evaluator picks up the platform-controlled value. """ - _state.mode = mode \ No newline at end of file + _state.mode = mode diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index c4080f2..3bf264c 100644 --- a/src/uipath/runtime/governance/native/_yaml_to_index.py +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -2,8 +2,9 @@ Mirrors the shape produced by ``packs/compile_packs.py`` but builds the PolicyIndex directly from parsed YAML data rather than generating Python -source. Used by :mod:`uipath.runtime.governance.native.loader` when policies are fetched -from the governance backend at startup. +source. Used by :mod:`uipath.runtime.governance.native.loader` to +compile the YAML body returned by the registered policy provider into +an in-memory index at startup. Accepts either a single YAML document (one pack) or a multi-document stream (``---``-separated packs). Unknown check types and malformed @@ -334,9 +335,7 @@ def _build_check( }, ) ) - message = str( - data.get("message", "A.7.4: Data quality signal (encoding or entropy)") - ) + message = str(data.get("message", "")) elif check_type == "incident_taxonomy": field = data.get("field", "model_output") @@ -347,7 +346,7 @@ def _build_check( conditions.append( Condition(operator="incident_concern", field=field, value=value) ) - message = str(data.get("message", "A.8.4: Incident signal detected")) + message = str(data.get("message", "")) elif check_type == "commitment_extractor": field = data.get("field", "model_output") @@ -361,9 +360,7 @@ def _build_check( }, ) ) - message = str( - data.get("message", "A.10.4: Customer commitment language detected") - ) + message = str(data.get("message", "")) elif check_type == "sentiment_concern": field = data.get("field", "model_input") diff --git a/src/uipath/runtime/governance/native/backend_client.py b/src/uipath/runtime/governance/native/backend_client.py deleted file mode 100644 index a406c35..0000000 --- a/src/uipath/runtime/governance/native/backend_client.py +++ /dev/null @@ -1,366 +0,0 @@ -"""Governance backend client. - -Hosts the shared infrastructure used by every governance-backend call: - -- :func:`get_backend_base_url` — resolves the cloud host (with the - org/tenant path segments stripped) so each endpoint builder can - append its own scoped path. -- :func:`governance_request_headers` — composes the headers shared by - the policy fetch and the ``/runtime/govern`` compensating POST - (Accept, User-Agent, optional Content-Type, optional Bearer auth). -- :func:`build_governance_url` — composes an org-scoped URL against - the ``agenticgovernance_`` ingress. -- :func:`resolve_organization_id` / :func:`resolve_tenant_id` — read - the active org/tenant from the environment (published by the UiPath - runtime host), keeping runtime independent of ``uipath-platform``. -- :func:`safe_call` — fail-open helper that catches every non-block - exception so governance hooks never crash an agent run. -- Module-level constants — request timeout, service path prefix, - compensation pool size — all the tunables an operator might care - about. Defined once here so the policy fetch, the compensating - ``/runtime/govern`` call, and the loader share one definition. - -The endpoint clients live next door: - -- :mod:`uipath.runtime.governance.native.policy_api_client` — policy fetch -- :mod:`uipath.runtime.governance.native.guardrail_compensation` — /runtime/govern -""" - -from __future__ import annotations - -import logging -import os -from functools import lru_cache -from typing import Callable -from urllib.parse import urlparse - -logger = logging.getLogger(__name__) - -# ---------------------------------------------------------------------------- -# Env-var names (consumed by the helpers below + diagnostic messages) -# ---------------------------------------------------------------------------- - -# Explicit dev/test override — used verbatim, no path-stripping. -ENV_BACKEND_BASE_URL = "UIPATH_GOVERNANCE_BACKEND_URL" -# The canonical platform URL env var. -ENV_PLATFORM_BASE_URL = "UIPATH_URL" -# Bearer token; missing means the policy fetch and compensating call are -# skipped (and that fact is logged) rather than producing 401s on every call. -ENV_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" -# Org / tenant scoping for the agenticgovernance_ ingress. -ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" -ENV_TENANT_ID = "UIPATH_TENANT_ID" -# Trace id used to bind governance spans / compensation records to the -# agent's trace. -ENV_TRACE_ID = "UIPATH_TRACE_ID" -# Job-execution context forwarded in the /runtime/govern payload so the -# server can populate the LLMOps trace record (Doc-2 audit structure). -# Published into the process environment by the UiPath runtime host. -ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" -ENV_JOB_KEY = "UIPATH_JOB_KEY" -ENV_PROCESS_KEY = "UIPATH_PROCESS_UUID" -ENV_REFERENCE_ID = "UIPATH_AGENT_ID" -ENV_AGENT_VERSION = "UIPATH_PROCESS_VERSION" - -# ---------------------------------------------------------------------------- -# Endpoint shape — all governance calls hit the org-scoped agenticgovernance_ -# service. Centralised so adding a third endpoint is "one new path constant" -# instead of "a new path template that someone forgets to keep in sync." -# ---------------------------------------------------------------------------- - -GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" -POLICY_API_PATH = "api/v1/runtime/policy" -GOVERN_API_PATH = "api/v1/runtime/govern" -TENANT_HEADER = "x-uipath-internal-tenantid" -# Query param on the policy fetch that selects the agent-type view of the -# policy: the server's clause-resolver reads the matching container key -# (``*-in-flight-conversational-agents`` vs ``*-in-flight-agents``). It's a -# representation selector (it changes the returned policy), so it travels as a -# query param — cache-correct and part of resource identification — not a -# header. Values: "conversational" | "autonomous". -AGENT_TYPE_PARAM = "agentType" -AGENT_TYPE_CONVERSATIONAL = "conversational" -AGENT_TYPE_AUTONOMOUS = "autonomous" - -# Default base URL when no override and no UIPATH_URL value is -# available. Used only on developer machines doing fully-offline work; real -# deployments always have UIPATH_URL injected by the host. -_DEFAULT_BACKEND_BASE_URL = "https://alpha.uipath.com" - -# ---------------------------------------------------------------------------- -# Tunables — one place so an ops change is one edit. The values that bound -# how long a single agent run can spend on governance traffic. -# ---------------------------------------------------------------------------- - -# Per-request timeout for any governance backend HTTP call (policy fetch, -# /runtime/govern compensating POST). Same value used everywhere so an agent -# can't accidentally end up with a "long" timeout on one call and "short" on -# another. -BACKEND_REQUEST_TIMEOUT_SECONDS = 10.0 - -# Bound on concurrent /runtime/govern requests in flight. A misbehaving -# agent that fires `before_model` 100 times in a session with three matched -# fallback rules each would otherwise spawn 100 daemon threads; this pool -# caps the concurrency. Saturated submissions are logged and dropped — the -# server still receives traces from the requests that did land. -COMPENSATION_MAX_WORKERS = 4 - -# Browser-shaped User-Agent. Required because the alpha/production -# governance ingress runs a WAF whose default scanner rule set blocks -# ``Python-urllib/``. Identifying as a real browser keeps the -# request from being rejected before any auth/tenant logic runs. -USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/148.0.0.0 Safari/537.36" -) - - -# ---------------------------------------------------------------------------- -# Headers -# ---------------------------------------------------------------------------- - - -def governance_request_headers(*, json_body: bool = False) -> dict[str, str]: - """Return the common HTTP headers for governance backend requests. - - Centralises the headers shared between the policy fetch and the - compensating ``/runtime/govern`` POST so the UA and auth shape are - declared once. - - Args: - json_body: When ``True`` (POST/PATCH/etc. with a JSON payload), - adds ``Content-Type: application/json``. GETs leave it off - so origin servers that 415 on unexpected Content-Type stay - happy. - - Returns: - A new dict with: - - - ``Accept: application/json`` - - ``User-Agent`` (the browser-shaped string above) - - ``Content-Type: application/json`` when ``json_body=True`` - - ``Authorization: Bearer `` when the env - var is set; omitted otherwise (caller decides whether the - missing token is fatal). - - Endpoint-specific headers (e.g. ``x-uipath-internal-tenantid``) are - added by the caller after this helper returns. - """ - headers: dict[str, str] = { - "Accept": "application/json", - "User-Agent": USER_AGENT, - } - if json_body: - headers["Content-Type"] = "application/json" - token = os.environ.get(ENV_ACCESS_TOKEN) - if token: - headers["Authorization"] = f"Bearer {token}" - return headers - - -# ---------------------------------------------------------------------------- -# URL composition -# ---------------------------------------------------------------------------- - - -def _strip_to_origin(raw_url: str) -> str: - """Return ``scheme://host[:port]`` for ``raw_url``, dropping any path. - - Platform URLs are commonly ``https://cloud.uipath.com//``; - the governance endpoints construct their own - ``/{org}/agenticgovernance_/...`` suffix, so the org/tenant segments - in the base must be stripped to avoid a duplicated org path. - """ - parsed = urlparse(raw_url) - if not parsed.scheme or not parsed.netloc: - # Not a parseable absolute URL — leave it to the caller. - return raw_url.rstrip("/") - return f"{parsed.scheme}://{parsed.netloc}" - - -def get_backend_base_url() -> str: - """Resolve the governance backend base URL on each call. - - Resolution order (first hit wins): - - 1. ``UIPATH_GOVERNANCE_BACKEND_URL`` — explicit dev/test override, - used verbatim. - 2. ``UIPATH_URL`` env var — the canonical platform URL. Org/tenant - path segments are stripped so the caller can append its own - org-scoped path. - 3. ``https://alpha.uipath.com`` — last-resort default for offline - development; real deployments always have ``UIPATH_URL`` set. - - Reading on each call (not at import) lets the runtime entrypoint - configure the env vars after this module is already loaded. - """ - explicit_override = os.environ.get(ENV_BACKEND_BASE_URL) - if explicit_override: - return explicit_override.rstrip("/") - - raw = os.environ.get(ENV_PLATFORM_BASE_URL) - if raw: - return _strip_to_origin(raw) - - return _DEFAULT_BACKEND_BASE_URL - - -def build_governance_url(org_id: str, path: str) -> str: - """Compose an org-scoped governance backend URL. - - Final shape: ``{backend_base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}``. - - Args: - org_id: Active organization id; the URL is meaningless without it. - path: API suffix WITHOUT the org/service prefix - (e.g. :data:`POLICY_API_PATH` or :data:`GOVERN_API_PATH`). - """ - base = get_backend_base_url() - return f"{base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}" - - -# ---------------------------------------------------------------------------- -# Org / tenant resolution -# ---------------------------------------------------------------------------- - - -def _resolve_env_field(env_var: str) -> str | None: - """Read a runtime-context value from its environment variable. - - Org/tenant ids and job context are published into the process - environment by the UiPath runtime host. Reading them directly keeps - ``uipath-runtime`` independent of ``uipath-platform`` (the lower layer - must not import the higher one). - """ - return os.environ.get(env_var) - - -# ---------------------------------------------------------------------------- -# Agent-type selector (conversational vs autonomous) -# -# Set once by the governance wrapper at runtime init (before the background -# policy prefetch is kicked off) and read by the policy fetch when composing -# the request URL. A process-level holder — not a ContextVar — because the -# prefetch runs on a separate thread that wouldn't inherit a ContextVar, and a -# coded-agent process hosts a single agent so the value is stable per process. -# ---------------------------------------------------------------------------- - -_agent_is_conversational: bool | None = None - - -def set_agent_conversational(value: bool | None) -> None: - """Record whether the hosted agent is conversational. - - ``None`` clears the selector (used by tests / direct callers); the policy - fetch then omits the param and the server applies its default. - """ - global _agent_is_conversational - _agent_is_conversational = value - - -def agent_type_param() -> str | None: - """Return the ``agentType`` query value, or ``None`` when unknown. - - ``"conversational"`` / ``"autonomous"`` map to the server's - conversational-vs-autonomous container keys; ``None`` (selector never set) - omits the param so the server's default applies. - """ - if _agent_is_conversational is None: - return None - return AGENT_TYPE_CONVERSATIONAL if _agent_is_conversational else AGENT_TYPE_AUTONOMOUS - - -def resolve_organization_id() -> str | None: - """Return the current organization id from the environment. - - Returns ``None`` when unset — callers skip the backend interaction - (no URL can be built without an org id) and the agent runs with no - policies / no compensation. - """ - return _resolve_env_field(ENV_ORGANIZATION_ID) - - -def resolve_tenant_id() -> str | None: - """Return the current tenant id from the environment. - - Returns ``None`` when unset — callers skip the backend interaction - since the ``x-uipath-internal-tenantid`` header would be missing. - """ - return _resolve_env_field(ENV_TENANT_ID) - - -@lru_cache(maxsize=1) -def _resolved_job_context() -> tuple[tuple[str, str], ...]: - """Resolve and freeze the job context once per process. - - Returned as a tuple of ``(key, value)`` pairs so the cached value is - immutable — callers materialize a fresh dict each call. Tests that - mutate env vars can invalidate via ``resolve_job_context.cache_clear()``. - """ - candidates = { - "folderKey": _resolve_env_field(ENV_FOLDER_KEY), - "jobKey": _resolve_env_field(ENV_JOB_KEY), - "processKey": _resolve_env_field(ENV_PROCESS_KEY), - "referenceId": _resolve_env_field(ENV_REFERENCE_ID), - "agentVersion": _resolve_env_field(ENV_AGENT_VERSION), - } - return tuple((k, v) for k, v in candidates.items() if v) - - -def resolve_job_context() -> dict[str, str]: - """Return the agent's job-execution context for the govern payload. - - Each field is read from its environment variable and only - included when it resolves to a truthy value, so the server receives - exactly the keys the agent actually knows. Cached per-process — the - underlying values are immutable for the agent's lifetime. The server - maps these onto the LLMOps trace record: - - - ``folderKey`` → ``FolderKey`` / ``uipath.folder_key`` - - ``jobKey`` → ``JobKey`` / ``uipath.job_key`` - - ``processKey`` → ``ProcessKey`` - - ``referenceId`` → ``ReferenceId`` (typically the agent id) - - ``agentVersion`` → ``AgentVersion`` - """ - return dict(_resolved_job_context()) - - -resolve_job_context.cache_clear = _resolved_job_context.cache_clear # type: ignore[attr-defined] - - -# ---------------------------------------------------------------------------- -# Generic safe-call helper. Used by callers that want "log and continue" on -# any unexpected failure path without spelling out the same try/except every -# time. The intentional GovernanceBlockException ALWAYS propagates — only -# this exception type carries policy intent; anything else is a bug. -# ---------------------------------------------------------------------------- - - -def safe_call( - fn: Callable[..., None], - *args: object, - what: str, - **kwargs: object, -) -> None: - """Call ``fn(*args, **kwargs)`` and swallow any non-block exception. - - ``GovernanceBlockException`` propagates (intentional policy block); - everything else is logged at WARNING with the ``what`` label and - swallowed so the agent can continue. Designed for fire-and-forget - governance paths that should never fail an agent run. - - Args: - fn: Callable to invoke. - what: Short label used in the log line on failure - (e.g. ``"BEFORE_AGENT governance check"``). - """ - # Lazy import to avoid pulling uipath-core into module load. - from uipath.core.governance.exceptions import GovernanceBlockException - - try: - fn(*args, **kwargs) - except GovernanceBlockException: - raise - except Exception as exc: # noqa: BLE001 - fail-open by contract - logger.warning("%s failed (continuing): %s", what, exc) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index 6b55022..6603a50 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -1,51 +1,31 @@ """Policy pack loader. -Resolves the active PolicyIndex at startup. Policies are fetched -exclusively from the governance backend (``api/v1/policy``); there is -no local compiled fallback. When the backend is unavailable, the -access token is unset, or the fetch times out, the loader returns an -empty PolicyIndex and the agent runs without any rules. +Resolves the active PolicyIndex at startup by calling a registered +:class:`GovernancePolicyProvider`. The runtime never contacts the +governance backend directly; the provider owns the wire / transport +(auth, retries, telemetry). When no provider is registered, or the +provider raises / returns an empty body / yields zero rules, the +loader returns an empty PolicyIndex and the agent runs without any +rules. """ from __future__ import annotations import logging -import os import threading import time from collections import Counter import yaml +from uipath.core.governance import GovernancePolicyProvider, PolicyContext from uipath.core.governance.config import is_governance_enabled -from uipath.runtime.governance.config import EnforcementMode, set_enforcement_mode +from uipath.runtime.governance.config import set_enforcement_mode from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml -from uipath.runtime.governance.native.backend_client import ( - ENV_ACCESS_TOKEN, - ENV_ORGANIZATION_ID, - ENV_TENANT_ID, - resolve_organization_id, - resolve_tenant_id, -) from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.native.policy_api_client import ( - POLICY_API_TIMEOUT_SECONDS, - fetch_policy_response, -) logger = logging.getLogger(__name__) -# Pack name aliases for backward compatibility -PACK_ALIASES: dict[str, str] = { - "owasp": "owasp_agentic", - "hipaa": "hipaa_runtime", - "soc2": "soc2_runtime", - "nist": "nist_ai_rmf_runtime", - "eu_ai": "eu_ai_act_runtime", - "iso": "iso42001_runtime", -} - - # Module-level cache _policy_index: PolicyIndex | None = None @@ -57,27 +37,63 @@ _prefetch_event: threading.Event | None = None _prefetch_lock = threading.Lock() -# Default wait when ``get_policy_index()`` blocks on an in-flight -# prefetch. Matched to the policy-API HTTP timeout so a stuck backend -# bounds the total time spent waiting at first hook fire to -# ~POLICY_API_TIMEOUT_SECONDS. If the wait expires we return an empty -# PolicyIndex — the agent runs without any policies rather than -# blocking further or retrying. -_PREFETCH_WAIT_SECONDS = POLICY_API_TIMEOUT_SECONDS +# Upper bound on how long ``get_policy_index()`` waits for an in-flight +# prefetch before falling back to an empty PolicyIndex. The provider +# owns its own transport timeouts; this is the runtime's ceiling on +# blocking the first hook fire. +_PROVIDER_WAIT_SECONDS = 10.0 + +# Registered :class:`GovernancePolicyProvider`. Set by +# :class:`GovernanceRuntime` at init. ``None`` means no provider is +# registered — :func:`load_policy_index` returns an empty PolicyIndex +# in that case. +_policy_provider: GovernancePolicyProvider | None = None + +# Whether the hosted agent is conversational. Travels in the +# :class:`PolicyContext` so the provider can select the matching policy +# view. A process-level holder (not a ContextVar) because the prefetch +# runs on a separate thread that wouldn't inherit one, and a +# coded-agent process hosts a single agent so the value is stable per +# process. ``None`` leaves the selector unset — the provider applies +# its default. +_agent_is_conversational: bool | None = None + + +def set_policy_provider(provider: GovernancePolicyProvider | None) -> None: + """Register the policy provider the loader will use to fetch policies. + + Called once by :class:`GovernanceRuntime` during init before + :func:`prefetch_policy_index`. ``None`` clears the registration — + used by tests and by callers that opt out of governance. + """ + global _policy_provider + _policy_provider = provider + + +def set_agent_conversational(value: bool | None) -> None: + """Record whether the hosted agent is conversational. + + Threaded into :class:`PolicyContext` on every provider call so the + provider can resolve the conversational-vs-autonomous policy view. + ``None`` clears the selector — the provider then applies its + default. + """ + global _agent_is_conversational + _agent_is_conversational = value def prefetch_policy_index() -> None: """Kick off a background load of the policy index. Non-blocking. Designed to be called as early as possible (at - ``GovernanceRuntime.__init__``) so the HTTP call to the governance - backend overlaps with the rest of agent setup. The result lands in - the same module cache that ``get_policy_index()`` reads from; - ``get_policy_index()`` waits on this prefetch when it's in flight. + ``GovernanceRuntime.__init__``) so the policy fetch overlaps with + the rest of agent setup. The result lands in the same module cache + that ``get_policy_index()`` reads from; ``get_policy_index()`` waits + on this prefetch when it's in flight. Idempotent: subsequent calls while the first is running are no-ops, and calls after completion are no-ops. Skipped entirely when the - governance feature flag is OFF so no network call is made. + governance feature flag is OFF so the provider is never invoked. """ global _prefetch_event @@ -116,11 +132,13 @@ def get_policy_index() -> PolicyIndex: Resolution order on first call: 1. If the governance feature flag is OFF, return an empty - PolicyIndex (cached). No network call. + PolicyIndex (cached). Provider is not invoked. 2. If a prefetch (see :func:`prefetch_policy_index`) is in flight, - wait for it to complete (bounded by ``_PREFETCH_WAIT_SECONDS``). - 3. Governance backend at ``api/v1/policy`` (one HTTP GET, cached). - 4. Empty PolicyIndex when the backend is unavailable or times out. + wait for it to complete (bounded by ``_PROVIDER_WAIT_SECONDS``). + 3. Synchronously call :func:`load_policy_index` (which invokes the + registered :class:`GovernancePolicyProvider`). + 4. Empty PolicyIndex when no provider is registered or the + provider fails / returns nothing. Result is cached for the process lifetime; per-hook evaluation never touches the network. Call :func:`clear_policy_cache` to force a @@ -141,7 +159,7 @@ def get_policy_index() -> PolicyIndex: event = _prefetch_event if event is not None: - completed = event.wait(timeout=_PREFETCH_WAIT_SECONDS) + completed = event.wait(timeout=_PROVIDER_WAIT_SECONDS) if completed and _policy_index is not None: return _policy_index if not completed: @@ -150,17 +168,17 @@ def get_policy_index() -> PolicyIndex: logger.warning( "Policy prefetch did not complete in %.1fs; " "agent will run without any policies", - _PREFETCH_WAIT_SECONDS, + _PROVIDER_WAIT_SECONDS, ) _policy_index = PolicyIndex() return _policy_index # Completed but produced no PolicyIndex — the worker hit an - # unexpected error (auth failure, server error, parse failure). - # Do NOT cache the empty result: caching would permanently - # disable governance for the process even though a later - # prefetch / clear_policy_cache could still recover. Return an - # empty index for this call only and leave the cache unset. + # unexpected error (provider failure, parse failure). Do NOT + # cache the empty result: caching would permanently disable + # governance for the process even though a later prefetch / + # clear_policy_cache could still recover. Return an empty index + # for this call only and leave the cache unset. logger.warning( "Policy prefetch completed but produced no PolicyIndex " "(see prior WARN for the root cause); agent will run " @@ -168,34 +186,31 @@ def get_policy_index() -> PolicyIndex: ) return PolicyIndex() - # No prefetch was started (direct callers / tests). Sync load — bounded - # by the HTTP timeout in the API client. + # No prefetch was started (direct callers / tests). Sync load. _policy_index = load_policy_index() return _policy_index -def load_policy_index(pack_name: str | None = None) -> PolicyIndex: - """Load the active PolicyIndex from the governance backend. - - Args: - pack_name: Ignored. Pack selection is controlled entirely by the - backend. +def load_policy_index() -> PolicyIndex: + """Load the active PolicyIndex via the registered policy provider. Returns: - PolicyIndex parsed from the backend response. Empty PolicyIndex - when the backend is unavailable, the token is unset, the YAML + PolicyIndex parsed from the provider response. Empty PolicyIndex + when no provider is registered, the provider raises, the YAML is malformed, or the response yields zero rules. """ start = time.perf_counter() - api_index = _load_from_api() - if api_index is not None: - _log_index_summary(api_index) + provider = _policy_provider + index = _load_from_provider(provider) if provider is not None else None + + if index is not None: + _log_index_summary(index) logger.info( - "Policy index ready: source=backend, total_ms=%.1f", + "Policy index ready: source=provider, total_ms=%.1f", (time.perf_counter() - start) * 1000, ) - return api_index + return index reason = _empty_index_reason() logger.info( @@ -207,85 +222,60 @@ def load_policy_index(pack_name: str | None = None) -> PolicyIndex: def _empty_index_reason() -> str: - """Diagnose why the policy fetch produced nothing.""" - if not resolve_organization_id(): - return ( - f"organization id unavailable — set {ENV_ORGANIZATION_ID}; " - "backend API not contacted" - ) - if not resolve_tenant_id(): - return ( - f"tenant id unavailable — set {ENV_TENANT_ID}; " - "backend API not contacted" - ) - if not os.environ.get(ENV_ACCESS_TOKEN): - return f"{ENV_ACCESS_TOKEN} unset — backend API not contacted" - return "backend returned no policies (timeout / error / empty body)" - - -def _apply_enforcement_mode(mode_str: str | None) -> None: - """Map a backend-supplied mode string onto :class:`EnforcementMode`. - - Unknown values log a warning and leave the existing mode untouched. - """ - if not mode_str: - return - try: - mode = EnforcementMode(mode_str.lower()) - except ValueError: - logger.warning( - "Backend returned unknown enforcement mode %r; keeping current mode", - mode_str, - ) - return - set_enforcement_mode(mode) - logger.info("Enforcement mode set from backend: %s", mode.value) + """Diagnose why policy loading produced nothing.""" + if _policy_provider is None: + return "no policy provider registered" + return "provider returned no policies (error / empty body / zero rules)" -def _load_from_api() -> PolicyIndex | None: - """Fetch and parse the policy index from the governance backend. +def _load_from_provider(provider: GovernancePolicyProvider) -> PolicyIndex | None: + """Fetch and parse the policy index via a :class:`GovernancePolicyProvider`. - Applies the backend-supplied enforcement mode as a side effect. - Returns ``None`` when the backend skips/errors, when the YAML is + Applies the provider-supplied enforcement mode as a side effect. + Returns ``None`` when the provider raises, when the YAML is malformed, or when the resulting index has no rules — caller returns an empty PolicyIndex in those cases. """ start = time.perf_counter() - response = fetch_policy_response() - if response is None: + + ctx = PolicyContext(is_conversational=_agent_is_conversational) + + try: + response = provider.get_policy(ctx) + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning("Policy provider get_policy failed: %s", exc) return None - # Apply the platform-controlled enforcement mode before building the - # index, so anything that reads ``get_enforcement_mode()`` during - # index compilation already sees the right value. - _apply_enforcement_mode(response.mode) + if response.mode is not None: + set_enforcement_mode(response.mode) + logger.info("Enforcement mode set from provider: %s", response.mode.value) - if not response.policy: + if not response.policies: logger.warning( - "Policy fetch returned empty policy field; " + "Policy provider returned empty policies field; " "agent will run without any policies" ) return None try: - index = build_policy_index_from_yaml(response.policy) + index = build_policy_index_from_yaml(response.policies) except yaml.YAMLError as exc: - logger.warning("Policy YAML from backend was malformed: %s", exc) + logger.warning("Policy YAML from provider was malformed: %s", exc) return None except Exception as exc: # noqa: BLE001 - never let load break agent startup - logger.warning("Failed to build PolicyIndex from backend YAML: %s", exc) + logger.warning("Failed to build PolicyIndex from provider YAML: %s", exc) return None if index.total_rules == 0: logger.warning( - "Policy YAML from backend yielded zero rules; " + "Policy YAML from provider yielded zero rules; " "agent will run without any policies" ) return None elapsed_ms = (time.perf_counter() - start) * 1000 logger.info( - "Loaded policy index from backend: packs=%s, rules=%d, elapsed_ms=%.1f", + "Loaded policy index from provider: packs=%s, rules=%d, elapsed_ms=%.1f", index.pack_names, index.total_rules, elapsed_ms, @@ -293,21 +283,8 @@ def _load_from_api() -> PolicyIndex | None: return index -def _backend_base_url() -> str: - """Return the backend base URL for logging; imported lazily to avoid cycles.""" - try: - from uipath.runtime.governance.native.backend_client import ( - get_backend_base_url, - ) - - return get_backend_base_url() - except Exception: # noqa: BLE001 - return "backend" - - def _log_index_summary(index: PolicyIndex) -> None: """Log summary of loaded policy index.""" - # Count rules by hook hook_counts: Counter[str] = Counter() for rule in index.all_rules: hook_counts[rule.hook.value] += 1 @@ -323,9 +300,8 @@ def _log_index_summary(index: PolicyIndex) -> None: def get_available_packs() -> list[str]: """Get list of pack names from the currently loaded policy index. - Returns whatever the backend supplied on the most recent load. - Empty list if no index has been loaded yet or the backend yielded - no packs. + Returns whatever the provider supplied on the most recent load. + Empty list if no index has been loaded yet. """ if _policy_index is None: return [] @@ -335,14 +311,11 @@ def get_available_packs() -> list[str]: def clear_policy_cache() -> None: """Clear the cached policy index and any in-flight prefetch state. - Next call to ``get_policy_index()`` will refetch from the backend. + Next call to ``get_policy_index()`` will reload from the registered + :class:`GovernancePolicyProvider`. """ global _policy_index, _prefetch_event with _prefetch_lock: _policy_index = None _prefetch_event = None logger.debug("Policy index cache cleared") - - -# Backward compatibility alias -reset_policy_index = clear_policy_cache diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py deleted file mode 100644 index 0bc428a..0000000 --- a/src/uipath/runtime/governance/native/policy_api_client.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Governance policy API client. - -Fetches the governance backend response so policies can be controlled -centrally without redeploying agents. Called once at process startup -from :mod:`uipath.runtime.governance.native.loader`; per-hook evaluation -stays in-process. - -Response shape (JSON):: - - { - "mode": "audit" | "enforce" | "disabled", - "policies": "" - } - -``mode`` is the platform-controlled enforcement mode for the tenant; -the loader applies it via -:func:`uipath.runtime.governance.config.set_enforcement_mode`. ``policies`` -is the YAML the evaluator compiles into a :class:`PolicyIndex`. - -Failure mode is fail-open: when the organization id is unknown, the -access token is missing, the backend errors, or the body can't be -parsed, the caller falls back to an empty PolicyIndex. The fetch is -single-shot (no retry by design — see :func:`_get_once`) so a slow -backend can't extend agent startup beyond -:data:`BACKEND_REQUEST_TIMEOUT_SECONDS`. Nothing in this module ever -raises to the caller. -""" - -from __future__ import annotations - -import json -import logging -import os -import urllib.error -import urllib.request -from dataclasses import dataclass -from urllib.parse import urlencode - -from uipath.runtime.governance.native.backend_client import ( - AGENT_TYPE_PARAM, - BACKEND_REQUEST_TIMEOUT_SECONDS, - ENV_ACCESS_TOKEN, - ENV_ORGANIZATION_ID, - ENV_TENANT_ID, - POLICY_API_PATH, - TENANT_HEADER, - agent_type_param, - build_governance_url, - governance_request_headers, - resolve_organization_id, - resolve_tenant_id, -) - -logger = logging.getLogger(__name__) - -# Re-exported alias kept for callers that imported the old name. -POLICY_API_TIMEOUT_SECONDS = BACKEND_REQUEST_TIMEOUT_SECONDS - - -@dataclass(frozen=True) -class PolicyResponse: - """Parsed governance backend response. - - Attributes: - mode: Enforcement mode string the backend returned - (``"audit"`` / ``"enforce"`` / ``"disabled"``), or ``None`` - when the backend omitted it. Loader applies this via - :func:`uipath.runtime.governance.config.set_enforcement_mode`. - policy: Policy pack YAML to compile into a ``PolicyIndex``. May - be an empty string if the backend returned no rules. - """ - - mode: str | None - policy: str - - -def build_policy_url(org_id: str) -> str: - """Build the policy endpoint URL for the given organization id. - - The tenant id is not part of the URL; it travels in the - ``x-uipath-internal-tenantid`` request header (see - :func:`fetch_policy_response`). - - When the hosted agent's type is known (see - :func:`uipath.runtime.governance.native.backend_client.set_agent_conversational`), - an ``agentType`` query param is appended so the server resolves the - conversational-vs-autonomous container key. Omitted when unknown — the - server then applies its default. - """ - url = build_governance_url(org_id, POLICY_API_PATH) - agent_type = agent_type_param() - if agent_type: - url = f"{url}?{urlencode({AGENT_TYPE_PARAM: agent_type})}" - return url - - -def fetch_policy_response() -> PolicyResponse | None: - """Fetch the governance backend's policy response. - - Single shot, no retry: a failed fetch (timeout / network error / - HTTP error / malformed body) returns ``None`` and the caller falls - back to an empty PolicyIndex. The agent must not spend time on a - second attempt — keeping governance off the critical path is more - important than maximising policy availability. - - Returns: - :class:`PolicyResponse` on success. ``None`` on any failure - path — caller falls back to an empty PolicyIndex. - - Never raises. - """ - try: - return _fetch_policy_response_inner() - except Exception as exc: # noqa: BLE001 - loader path must never raise - logger.warning("Policy fetch failed unexpectedly: %s", exc) - return None - - -def _fetch_policy_response_inner() -> PolicyResponse | None: - org_id = resolve_organization_id() - if not org_id: - logger.warning( - "Policy fetch skipped: organization id is not available " - "(set %s in the environment); governance will run with no " - "policies. The backend API was NOT contacted.", - ENV_ORGANIZATION_ID, - ) - return None - - tenant_id = resolve_tenant_id() - if not tenant_id: - logger.warning( - "Policy fetch skipped: tenant id is not available " - "(set %s in the environment); governance will run with no " - "policies. The backend API was NOT contacted.", - ENV_TENANT_ID, - ) - return None - - policy_url = build_policy_url(org_id) - - token = os.environ.get(ENV_ACCESS_TOKEN) - if not token: - logger.warning( - "Policy fetch skipped: %s is not set in the environment; " - "governance will run with no policies.", - ENV_ACCESS_TOKEN, - ) - return None - - # Policy fetch is a GET; ``json_body=False`` so ``Content-Type`` is - # omitted. Strict origin servers may 415 on unexpected Content-Type - # for GETs (see :func:`governance_request_headers` docstring). - headers = governance_request_headers(json_body=False) - headers[TENANT_HEADER] = tenant_id - logger.info("Policy fetch starting (org=%s, tenant=%s)", org_id, tenant_id) - - body = _get_once(policy_url, headers) - if body is None: - return None - return _parse_policy_body(body) - - -def _get_once(url: str, headers: dict[str, str]) -> bytes | None: - """GET ``url`` once. Returns body bytes, or ``None`` on any failure. - - No retry by design — see :func:`fetch_policy_response` for the - rationale. Every failure path logs a single WARNING and returns - ``None`` so the caller (the loader) falls back to an empty - PolicyIndex without delay. - """ - request = urllib.request.Request(url, headers=headers, method="GET") - try: - with urllib.request.urlopen( # noqa: S310 - URL is built from config - request, timeout=BACKEND_REQUEST_TIMEOUT_SECONDS - ) as response: - return response.read() - except urllib.error.HTTPError as exc: - logger.warning("Policy fetch returned HTTP %d: %s", exc.code, exc) - except (urllib.error.URLError, TimeoutError, OSError) as exc: - logger.warning("Policy fetch failed: %s", exc) - return None - - -def _parse_policy_body(body: bytes) -> PolicyResponse | None: - """Parse the JSON envelope into a :class:`PolicyResponse`.""" - if not body: - logger.warning("Policy fetch returned empty body") - return None - - try: - payload = json.loads(body.decode("utf-8")) - except UnicodeDecodeError as exc: - logger.warning("Policy fetch returned non-UTF8 body: %s", exc) - return None - except json.JSONDecodeError as exc: - logger.warning( - "Policy fetch returned malformed JSON " - "(server may have returned an HTML error page): %s", - exc, - ) - return None - - if not isinstance(payload, dict): - logger.warning( - "Policy fetch returned unexpected JSON shape (expected object, got %s)", - type(payload).__name__, - ) - return None - - raw_mode = payload.get("mode") - mode = raw_mode if isinstance(raw_mode, str) and raw_mode else None - - raw_policy = payload.get("policies", "") - if not isinstance(raw_policy, str): - logger.warning( - "Policy fetch returned non-string 'policies' field (got %s)", - type(raw_policy).__name__, - ) - return None - - logger.info( - "Policy fetch ok: mode=%s, policy_chars=%d", mode, len(raw_policy) - ) - return PolicyResponse(mode=mode, policy=raw_policy) diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py new file mode 100644 index 0000000..12001c2 --- /dev/null +++ b/src/uipath/runtime/governance/runtime.py @@ -0,0 +1,162 @@ +"""Governance runtime wrapper. + +Wraps a :class:`UiPathRuntimeProtocol` delegate so policy data is sourced +through a :class:`GovernancePolicyProvider`. The provider owns the wire +/ transport (auth, retries, telemetry); the runtime only consumes the +parsed :class:`PolicyResponse`. There is no direct backend fallback — +when ``policy_provider`` is ``None`` the agent runs without any +governance policies. + +**Staging caveat — policy loading only, no enforcement yet.** This +module is the policy-loading scaffold: ``__init__`` registers the +provider, extracts the conversational/autonomous selector, and kicks +off a background prefetch into the loader cache. ``execute`` / +``stream`` / ``get_schema`` / ``dispose`` are pure passthroughs — no +per-hook policy evaluation runs. The evaluator + adapter wiring that +consumes :func:`get_policy_index` lands in a follow-up slice. Customers +constructing :class:`GovernanceRuntime` today get policy loading without +policy enforcement; this is intentional and will change when the +evaluator slice merges. +""" + +from __future__ import annotations + +import logging +from typing import Any, AsyncGenerator + +from uipath.core.governance import GovernancePolicyProvider +from uipath.core.governance.config import is_governance_enabled + +from uipath.runtime.base import ( + UiPathExecuteOptions, + UiPathRuntimeProtocol, + UiPathStreamOptions, +) +from uipath.runtime.events import UiPathRuntimeEvent +from uipath.runtime.governance.native.loader import ( + prefetch_policy_index, + set_agent_conversational, + set_policy_provider, +) +from uipath.runtime.result import UiPathRuntimeResult +from uipath.runtime.schema import UiPathRuntimeSchema + +logger = logging.getLogger(__name__) + +# Bound on how deeply we walk ``_delegate`` / ``delegate`` chains when +# looking for an :class:`AgentDefinition`. Wrappers like +# :class:`UiPathExecutionRuntime` and :class:`UiPathResumableRuntime` +# add at most a handful of layers; 10 is well above any realistic +# stack and keeps a pathological self-referential wrapper from looping. +_MAX_DELEGATE_UNWRAP_DEPTH = 10 + + +def _extract_is_conversational(delegate: object) -> bool | None: + """Read ``is_conversational`` off the delegate's agent definition. + + Walks ``delegate._agent_definition.is_conversational`` (the + LicensedRuntime pattern published by the agents SDK), unwrapping + the ``_delegate`` / ``delegate`` chain up to + :data:`_MAX_DELEGATE_UNWRAP_DEPTH` so wrapper layers don't hide the + licensed runtime. + + Returns ``None`` when no agent definition is reachable — the + provider then applies its default rather than the runtime guessing + a value. + """ + node: object | None = delegate + for _ in range(_MAX_DELEGATE_UNWRAP_DEPTH): + if node is None: + break + agent_def = getattr(node, "_agent_definition", None) + if agent_def is not None: + value = getattr(agent_def, "is_conversational", None) + if value is not None: + return bool(value) + node = getattr(node, "_delegate", None) or getattr(node, "delegate", None) + return None + + +class GovernanceRuntime: + """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. + + Registers the supplied :class:`GovernancePolicyProvider` with the + policy loader and kicks off a non-blocking prefetch so the policy + pack overlaps with the rest of agent setup. When ``policy_provider`` + is ``None``, no provider is registered and the agent runs without + any governance policies (the loader yields an empty PolicyIndex). + + **Policy loading only — no enforcement yet.** ``execute`` / ``stream`` + / ``get_schema`` / ``dispose`` are passthroughs to the delegate; no + per-hook policy evaluation runs in this slice. The evaluator and + framework adapter wiring that consumes :func:`get_policy_index` is + staged separately. Constructing this wrapper today gives you the + policy load (provider invoked, index cached) but no actual + enforcement of the loaded rules. + """ + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + policy_provider: GovernancePolicyProvider | None, + ): + """Initialize the governance runtime. + + Args: + delegate: The wrapped runtime to forward execution to. + policy_provider: Source of the policy pack. ``None`` means + no policies will be loaded — the agent runs without + governance for the lifetime of this instance. + """ + self._delegate = delegate + self._policy_provider = policy_provider + + if is_governance_enabled(): + # Record agent-type before the prefetch fires so the + # provider's first ``get_policy`` call sees the right + # selector on its ``PolicyContext``. Wrapped in try/except + # so a misbehaving delegate getattr can't break runtime + # init — fail-open: on failure the selector keeps whatever + # value an integration may have set externally. + # + # Only write when extraction returned a concrete bool. An + # extraction miss (``None``) leaves the selector untouched + # so an externally-set value (e.g. an integration that + # pre-seeded the selector from a different signal) is not + # silently clobbered by our init. + try: + extracted = _extract_is_conversational(delegate) + except Exception as exc: # noqa: BLE001 - fail-open + logger.warning( + "Failed to extract is_conversational from delegate: %s", exc + ) + else: + if extracted is not None: + set_agent_conversational(extracted) + set_policy_provider(policy_provider) + prefetch_policy_index() + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the delegate. Policy evaluation hooks are wired separately.""" + return await self._delegate.execute(input, options=options) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events from the delegate. Hooks are wired separately.""" + async for event in self._delegate.stream(input, options=options): + yield event + + async def get_schema(self) -> UiPathRuntimeSchema: + """Passthrough schema for the delegate.""" + return await self._delegate.get_schema() + + async def dispose(self) -> None: + """Dispose the delegate.""" + await self._delegate.dispose() diff --git a/tests/_helpers.py b/tests/_helpers.py index 7d839ea..c7dbbd8 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,12 +1,16 @@ """Shared test-only helpers. -Keeps test concerns out of the production governance package: the -enforcement-mode reset used for per-test isolation lives here rather than -in :mod:`uipath.runtime.governance.config`. +Keeps test concerns out of the production governance package: per-test +isolation utilities and shared stubs live here rather than inside the +production modules. """ from __future__ import annotations +import time + +from uipath.core.governance import PolicyContext, PolicyResponse + from uipath.runtime.governance import config @@ -14,6 +18,37 @@ def reset_enforcement_mode() -> None: """Clear the process-wide enforcement mode so the AUDIT default re-applies. Test isolation only — production code never resets the mode; the policy - loader sets it from the backend ``/runtime/policy`` response. + loader sets it from the provider-supplied :class:`PolicyResponse`. + """ + config._state.mode = None + + +class StubPolicyProvider: + """Minimal in-memory :class:`GovernancePolicyProvider` for tests. + + Records every :class:`PolicyContext` it receives so tests can assert + on the selector that travelled to the provider. Either returns a + pre-canned :class:`PolicyResponse` or raises a pre-canned exception; + the optional ``slow`` knob lets tests exercise the prefetch-wait + path. """ - config._state.mode = None \ No newline at end of file + + def __init__( + self, + response: PolicyResponse | None = None, + raises: Exception | None = None, + slow: float = 0.0, + ): + self.calls: list[PolicyContext] = [] + self._response = response + self._raises = raises + self._slow = slow + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + self.calls.append(context) + if self._slow: + time.sleep(self._slow) + if self._raises is not None: + raise self._raises + assert self._response is not None + return self._response diff --git a/tests/conftest.py b/tests/conftest.py index e337e96..78ea6ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,27 +23,23 @@ def temp_dir() -> Generator[str, None, None]: def _reset_governance_process_state() -> Generator[None, None, None]: """Clear process-level governance state around every test. - The native governance layer keeps two pieces of state at module scope: - the conversational/autonomous selector consumed by the policy fetch, - and the memoized job-context. Both are stable per process in + The loader keeps the conversational selector and the registered + policy provider at module scope. Both are stable per process in production but leak across tests when not reset, masking ordering - bugs and producing flakes. - - ``backend_client`` is imported lazily and guarded: this shared - conftest ships alongside the foundation slice, where that module may - not exist yet, and the reset is simply a no-op until it does. + bugs and producing flakes. Import is guarded so this fixture is a + no-op when the governance package isn't built yet. """ try: - from uipath.runtime.governance.native.backend_client import ( - resolve_job_context, + from uipath.runtime.governance.native.loader import ( set_agent_conversational, + set_policy_provider, ) except ImportError: yield return set_agent_conversational(None) - resolve_job_context.cache_clear() + set_policy_provider(None) yield set_agent_conversational(None) - resolve_job_context.cache_clear() + set_policy_provider(None) diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py index 992641a..5159c78 100644 --- a/tests/test_enforcement_mode_default.py +++ b/tests/test_enforcement_mode_default.py @@ -1,13 +1,13 @@ """Tests for the default enforcement-mode resolution. The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at -runtime construction and the background policy fetch can run. If the -backend later returns ``disabled``, ``set_enforcement_mode`` flips the -mode and ``evaluate()`` short-circuits per-call. +runtime construction and the background policy load can run. If the +provider later returns ``disabled``, ``set_enforcement_mode`` flips +the mode and ``evaluate()`` short-circuits per-call. Resolution (per :func:`get_enforcement_mode`): -1. The backend-supplied value set via ``set_enforcement_mode`` (the - ``/runtime/policy`` response, applied by the policy loader). +1. The provider-supplied value applied via ``set_enforcement_mode`` by + the policy loader. 2. Default ``AUDIT``. """ diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py new file mode 100644 index 0000000..b2b126b --- /dev/null +++ b/tests/test_governance_runtime.py @@ -0,0 +1,460 @@ +"""Tests for the GovernanceRuntime wrapper and the provider loader path.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +from uipath.core.governance import ( + EnforcementMode, + PolicyResponse, +) + +from tests._helpers import StubPolicyProvider, reset_enforcement_mode +from uipath.runtime.governance.config import get_enforcement_mode +from uipath.runtime.governance.native import loader +from uipath.runtime.governance.native.loader import ( + _load_from_provider, + clear_policy_cache, + load_policy_index, + set_agent_conversational, + set_policy_provider, +) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.runtime import ( + GovernanceRuntime, + _extract_is_conversational, +) + +SIMPLE_POLICY_YAML = """ +standard: provider-pack +version: "1.0" +rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["leak"] +""" + + +@pytest.fixture(autouse=True) +def _enable_ff_and_reset(monkeypatch: pytest.MonkeyPatch): + """Reset module state and turn the governance FF on per test.""" + from uipath.core.feature_flags import FeatureFlags + + clear_policy_cache() + reset_enforcement_mode() + set_policy_provider(None) + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) + yield + clear_policy_cache() + reset_enforcement_mode() + set_policy_provider(None) + FeatureFlags.reset_flags() + + +# --------------------------------------------------------------------------- +# _load_from_provider — direct unit tests +# --------------------------------------------------------------------------- + + +def test_load_from_provider_builds_index_and_applies_mode() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) + ) + + index = _load_from_provider(provider) + + assert isinstance(index, PolicyIndex) + assert index.total_rules == 1 + assert "provider-pack" in index.pack_names + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +def test_load_from_provider_passes_is_conversational_in_context() -> None: + set_agent_conversational(True) + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + _load_from_provider(provider) + + assert len(provider.calls) == 1 + assert provider.calls[0].is_conversational is True + + +def test_load_from_provider_returns_none_when_provider_raises() -> None: + provider = StubPolicyProvider(raises=RuntimeError("boom")) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_empty_policies() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_zero_rules() -> None: + empty_pack_yaml = "standard: empty\nrules: []\n" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=empty_pack_yaml) + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_malformed_yaml() -> None: + provider = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.AUDIT, policies="key: : invalid: : yaml" + ) + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_does_not_change_mode_when_none() -> None: + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.ENFORCE) + provider = StubPolicyProvider( + response=PolicyResponse(mode=None, policies=SIMPLE_POLICY_YAML) + ) + + _load_from_provider(provider) + + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +# --------------------------------------------------------------------------- +# load_policy_index dispatch — registered provider vs empty fallback +# --------------------------------------------------------------------------- + + +def test_load_policy_index_uses_registered_provider() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + set_policy_provider(provider) + + index = load_policy_index() + + assert index.total_rules == 1 + assert provider.calls, "provider.get_policy was not called" + + +def test_load_policy_index_returns_empty_when_no_provider() -> None: + """No provider registered → empty PolicyIndex (no fallback path).""" + index = load_policy_index() + assert index.total_rules == 0 + + +def test_load_policy_index_empty_when_provider_yields_nothing() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") + ) + set_policy_provider(provider) + + index = load_policy_index() + + assert index.total_rules == 0 + + +# --------------------------------------------------------------------------- +# GovernanceRuntime +# --------------------------------------------------------------------------- + + +class _StubDelegate: + """Captures delegate calls so the passthroughs can be asserted.""" + + def __init__(self) -> None: + self.execute_calls: list[tuple[Any, Any]] = [] + self.stream_calls: list[tuple[Any, Any]] = [] + self.disposed = False + self.schema_called = False + + async def execute(self, input=None, options=None): + self.execute_calls.append((input, options)) + return "result" + + async def stream(self, input=None, options=None): + self.stream_calls.append((input, options)) + for event in ("a", "b"): + yield event + + async def get_schema(self): + self.schema_called = True + return "schema" + + async def dispose(self): + self.disposed = True + + +def test_governance_runtime_registers_provider_and_prefetches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Init wires provider into loader state and kicks off prefetch.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + # Spy on prefetch + set_policy_provider so we don't need a real + # background thread in the unit test. + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + delegate = _StubDelegate() + + GovernanceRuntime(delegate, policy_provider=provider) + + set_provider_spy.assert_called_once_with(provider) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_with_none_provider_still_prefetches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Passing ``None`` registers None → loader yields an empty PolicyIndex.""" + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + GovernanceRuntime(_StubDelegate(), policy_provider=None) + + set_provider_spy.assert_called_once_with(None) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_skips_prefetch_when_ff_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FF off → no provider registration, no prefetch.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + GovernanceRuntime(_StubDelegate(), policy_provider=StubPolicyProvider()) + + assert not set_provider_spy.called + assert not prefetch_spy.called + + +async def test_governance_runtime_execute_delegates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + result = await runtime.execute({"x": 1}) + + assert result == "result" + assert delegate.execute_calls == [({"x": 1}, None)] + + +async def test_governance_runtime_stream_delegates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + events = [e async for e in runtime.stream({"x": 1})] + + assert events == ["a", "b"] + assert delegate.stream_calls == [({"x": 1}, None)] + + +async def test_governance_runtime_schema_and_dispose_delegate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + assert await runtime.get_schema() == "schema" + await runtime.dispose() + assert delegate.schema_called + assert delegate.disposed + + +# --------------------------------------------------------------------------- +# _extract_is_conversational +# --------------------------------------------------------------------------- + + +def test_extract_is_conversational_true_from_agent_definition() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True) + ) + assert _extract_is_conversational(delegate) is True + + +def test_extract_is_conversational_false_from_agent_definition() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=False) + ) + assert _extract_is_conversational(delegate) is False + + +def test_extract_is_conversational_returns_none_when_unreachable() -> None: + """No ``_agent_definition`` anywhere on the chain → ``None`` (let the provider default).""" + assert _extract_is_conversational(SimpleNamespace()) is None + + +def test_extract_is_conversational_returns_none_when_field_is_none() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=None) + ) + assert _extract_is_conversational(delegate) is None + + +def test_extract_is_conversational_unwraps_via_underscore_delegate() -> None: + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + outer = SimpleNamespace(_delegate=inner) + assert _extract_is_conversational(outer) is True + + +def test_extract_is_conversational_unwraps_via_delegate_attr() -> None: + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) + outer = SimpleNamespace(delegate=inner) + assert _extract_is_conversational(outer) is False + + +def test_extract_is_conversational_depth_capped() -> None: + """A pathological self-referential wrapper can't loop forever.""" + self_ref = SimpleNamespace() + self_ref._delegate = self_ref # type: ignore[attr-defined] + assert _extract_is_conversational(self_ref) is None + + +# --------------------------------------------------------------------------- +# GovernanceRuntime wires the selector +# --------------------------------------------------------------------------- + + +def test_governance_runtime_sets_agent_type_from_delegate() -> None: + """Init reads ``delegate._agent_definition.is_conversational`` and writes the selector.""" + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True), + execute=_StubDelegate().execute, + stream=_StubDelegate().stream, + get_schema=_StubDelegate().get_schema, + dispose=_StubDelegate().dispose, + ) + + # Don't run the real prefetch thread — just confirm the selector + # ended up where the provider would read it. + GovernanceRuntime(delegate, policy_provider=None) + + assert loader._agent_is_conversational is True + + +def test_governance_runtime_sets_none_when_agent_definition_missing() -> None: + """No ``_agent_definition`` → selector stays unset (``None``).""" + GovernanceRuntime(_StubDelegate(), policy_provider=None) + assert loader._agent_is_conversational is None + + +def test_governance_runtime_preserves_externally_set_selector_on_extraction_miss() -> None: + """Externally-set selector survives a runtime init that finds no ``_agent_definition``. + + Regression: previously ``__init__`` unconditionally wrote whatever + ``_extract_is_conversational`` returned, so an extraction miss + (``None``) silently clobbered a value an integration had pre-seeded. + """ + set_agent_conversational(True) + GovernanceRuntime(_StubDelegate(), policy_provider=None) + assert loader._agent_is_conversational is True + + +def test_governance_runtime_fails_open_when_extraction_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A pathological delegate accessor raising mid-extraction can't break init.""" + monkeypatch.setattr( + "uipath.runtime.governance.runtime._extract_is_conversational", + MagicMock(side_effect=RuntimeError("boom")), + ) + set_provider_spy = MagicMock() + prefetch_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + + # No exception escapes; the rest of init still runs. + GovernanceRuntime(_StubDelegate(), policy_provider=None) + + set_provider_spy.assert_called_once_with(None) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_skips_extraction_when_ff_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FF off → no selector write, no provider registration, no prefetch.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + + extract_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime._extract_is_conversational", extract_spy + ) + + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True), + execute=_StubDelegate().execute, + stream=_StubDelegate().stream, + get_schema=_StubDelegate().get_schema, + dispose=_StubDelegate().dispose, + ) + GovernanceRuntime(delegate, policy_provider=None) + + assert not extract_spy.called + assert loader._agent_is_conversational is None diff --git a/tests/test_loader.py b/tests/test_loader.py index 202de39..23df2d6 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,36 +1,39 @@ """Tests for the policy loader module. -Covers prefetch / get_policy_index / load_policy_index / _apply_enforcement_mode -plus the empty-index reason helper. +Provider-only world: the loader fetches policies exclusively through a +registered :class:`GovernancePolicyProvider`. Tests here cover the +caching, FF-gate, prefetch coordination, and fallback-to-empty behavior +that's independent of any specific provider. End-to-end provider +plumbing (mode application, YAML parsing, runtime wrapper integration) +lives in :mod:`tests.test_governance_runtime`. """ from __future__ import annotations import threading import time +from typing import Any from unittest.mock import patch import pytest -import yaml - -from tests._helpers import reset_enforcement_mode -from uipath.runtime.governance.config import ( +from uipath.core.governance import ( EnforcementMode, - get_enforcement_mode, + PolicyContext, + PolicyResponse, ) + +from tests._helpers import StubPolicyProvider, reset_enforcement_mode from uipath.runtime.governance.native import loader from uipath.runtime.governance.native.loader import ( - _apply_enforcement_mode, _empty_index_reason, - _load_from_api, clear_policy_cache, get_available_packs, get_policy_index, load_policy_index, prefetch_policy_index, + set_policy_provider, ) from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.native.policy_api_client import PolicyResponse SIMPLE_POLICY_YAML = """ standard: test-pack @@ -44,25 +47,25 @@ """ +def _ok_response() -> PolicyResponse: + return PolicyResponse( + mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML + ) + + @pytest.fixture(autouse=True) -def _clean_loader_state(monkeypatch: pytest.MonkeyPatch): - """Each test starts with a fresh loader cache and a known env. +def _clean_loader_state(): + """Each test starts with a fresh loader cache and FF on.""" + from uipath.core.feature_flags import FeatureFlags - Without this, tests leak the policy_index module global and - `_prefetch_event` into one another. - """ clear_policy_cache() reset_enforcement_mode() - # Enable the FF so the loader doesn't short-circuit immediately. - from uipath.core.feature_flags import FeatureFlags - + set_policy_provider(None) FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) - monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") - monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") - monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") yield clear_policy_cache() reset_enforcement_mode() + set_policy_provider(None) FeatureFlags.reset_flags() @@ -71,153 +74,43 @@ def _clean_loader_state(monkeypatch: pytest.MonkeyPatch): # --------------------------------------------------------------------------- -def test_empty_index_reason_missing_org_id(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) +def test_empty_index_reason_no_provider() -> None: msg = _empty_index_reason() - assert "UIPATH_ORGANIZATION_ID" in msg + assert "no policy provider" in msg -def test_empty_index_reason_missing_tenant_id(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) +def test_empty_index_reason_with_provider() -> None: + set_policy_provider(StubPolicyProvider(response=_ok_response())) msg = _empty_index_reason() - assert "UIPATH_TENANT_ID" in msg - - -def test_empty_index_reason_missing_token(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) - msg = _empty_index_reason() - assert "UIPATH_ACCESS_TOKEN" in msg - - -def test_empty_index_reason_backend_returned_nothing() -> None: - """All env present → reason is 'backend returned no policies'.""" - msg = _empty_index_reason() - assert "backend returned no policies" in msg - - -# --------------------------------------------------------------------------- -# _apply_enforcement_mode -# --------------------------------------------------------------------------- - - -def test_apply_enforcement_mode_none_leaves_current() -> None: - """Calling with ``None`` is a no-op — the existing mode is preserved.""" - from uipath.runtime.governance.config import set_enforcement_mode - - set_enforcement_mode(EnforcementMode.ENFORCE) - _apply_enforcement_mode(None) - assert get_enforcement_mode() == EnforcementMode.ENFORCE - - -def test_apply_enforcement_mode_empty_string_leaves_current() -> None: - from uipath.runtime.governance.config import set_enforcement_mode - - set_enforcement_mode(EnforcementMode.AUDIT) - _apply_enforcement_mode("") - assert get_enforcement_mode() == EnforcementMode.AUDIT - - -@pytest.mark.parametrize( - "mode_str,expected", - [ - ("audit", EnforcementMode.AUDIT), - ("enforce", EnforcementMode.ENFORCE), - ("disabled", EnforcementMode.DISABLED), - ("AUDIT", EnforcementMode.AUDIT), # case-insensitive - ], -) -def test_apply_enforcement_mode_known_values( - mode_str: str, expected: EnforcementMode -) -> None: - _apply_enforcement_mode(mode_str) - assert get_enforcement_mode() == expected - - -def test_apply_enforcement_mode_unknown_value_keeps_current() -> None: - from uipath.runtime.governance.config import set_enforcement_mode - - set_enforcement_mode(EnforcementMode.AUDIT) - _apply_enforcement_mode("not-a-real-mode") - # Mode is unchanged after the warning. - assert get_enforcement_mode() == EnforcementMode.AUDIT + assert "provider returned no policies" in msg # --------------------------------------------------------------------------- -# _load_from_api +# load_policy_index — public entry # --------------------------------------------------------------------------- -def test_load_from_api_returns_none_when_fetch_returns_none() -> None: - with patch.object(loader, "fetch_policy_response", return_value=None): - assert _load_from_api() is None - - -def test_load_from_api_returns_none_when_policy_is_empty() -> None: - """A response with mode but empty policies field is treated as nothing.""" - response = PolicyResponse(mode="audit", policy="") - with patch.object(loader, "fetch_policy_response", return_value=response): - assert _load_from_api() is None - - -def test_load_from_api_applies_mode_then_parses() -> None: - """The mode is applied BEFORE the YAML is parsed, so downstream sees it.""" - response = PolicyResponse(mode="enforce", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - index = _load_from_api() +def test_load_policy_index_empty_when_no_provider() -> None: + """No provider registered → empty PolicyIndex.""" + index = load_policy_index() assert isinstance(index, PolicyIndex) - assert index.total_rules == 1 - assert get_enforcement_mode() == EnforcementMode.ENFORCE - - -def test_load_from_api_swallows_yaml_error() -> None: - """A malformed YAML body produces None, not an exception.""" - response = PolicyResponse(mode="audit", policy="key: : invalid: : yaml") - with patch.object(loader, "fetch_policy_response", return_value=response): - with patch.object( - loader, - "build_policy_index_from_yaml", - side_effect=yaml.YAMLError("bad yaml"), - ): - assert _load_from_api() is None - - -def test_load_from_api_swallows_unexpected_exception() -> None: - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - with patch.object( - loader, - "build_policy_index_from_yaml", - side_effect=RuntimeError("library bug"), - ): - assert _load_from_api() is None - - -def test_load_from_api_returns_none_when_zero_rules() -> None: - """YAML parses cleanly but yields no rules → treated as no-op.""" - empty_pack_yaml = "standard: empty\nrules: []\n" - response = PolicyResponse(mode="audit", policy=empty_pack_yaml) - with patch.object(loader, "fetch_policy_response", return_value=response): - assert _load_from_api() is None + assert index.total_rules == 0 -# --------------------------------------------------------------------------- -# load_policy_index — public entry -# --------------------------------------------------------------------------- +def test_load_policy_index_uses_registered_provider() -> None: + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + index = load_policy_index() -def test_load_policy_index_success_path() -> None: - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - index = load_policy_index() assert isinstance(index, PolicyIndex) assert "test-pack" in index.pack_names + assert len(provider.calls) == 1 -def test_load_policy_index_returns_empty_on_failure() -> None: - """When the API yields None, the loader returns an empty PolicyIndex.""" - with patch.object(loader, "fetch_policy_response", return_value=None): - index = load_policy_index() - assert isinstance(index, PolicyIndex) +def test_load_policy_index_returns_empty_when_provider_raises() -> None: + set_policy_provider(StubPolicyProvider(raises=RuntimeError("boom"))) + index = load_policy_index() assert index.total_rules == 0 @@ -227,33 +120,35 @@ def test_load_policy_index_returns_empty_on_failure() -> None: def test_get_policy_index_caches_after_first_call() -> None: - """A second call returns the cached index without re-fetching.""" - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object( - loader, "fetch_policy_response", return_value=response - ) as mock_fetch: - a = get_policy_index() - b = get_policy_index() + """A second call returns the cached index without re-invoking the provider.""" + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + a = get_policy_index() + b = get_policy_index() + assert a is b - assert mock_fetch.call_count == 1 + assert len(provider.calls) == 1 def test_get_policy_index_short_circuits_when_ff_off() -> None: - """FF off → return an empty index without contacting the backend.""" + """FF off → return an empty index without invoking the provider.""" from uipath.core.feature_flags import FeatureFlags FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - with patch.object(loader, "fetch_policy_response") as mock_fetch: - index = get_policy_index() + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + index = get_policy_index() + assert index.total_rules == 0 - assert not mock_fetch.called + assert provider.calls == [] def test_get_policy_index_sync_load_when_no_prefetch() -> None: """Without a prefetch in flight, get_policy_index synchronously loads.""" - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - index = get_policy_index() + set_policy_provider(StubPolicyProvider(response=_ok_response())) + index = get_policy_index() assert index.total_rules == 1 @@ -266,19 +161,20 @@ def test_prefetch_is_idempotent() -> None: """Second call while first is in flight is a no-op (no second thread).""" block = threading.Event() - def _slow_fetch(): + def _slow_get(context: PolicyContext) -> PolicyResponse: block.wait(timeout=2.0) - return None + return _ok_response() - with patch.object(loader, "fetch_policy_response", side_effect=_slow_fetch): - prefetch_policy_index() - first_event = loader._prefetch_event - prefetch_policy_index() - assert loader._prefetch_event is first_event - # Let the worker finish so the autouse fixture's clear runs cleanly. - block.set() - if first_event is not None: - first_event.wait(timeout=2.0) + provider: Any = type("P", (), {"get_policy": staticmethod(_slow_get)})() + set_policy_provider(provider) + + prefetch_policy_index() + first_event = loader._prefetch_event + prefetch_policy_index() + assert loader._prefetch_event is first_event + block.set() + if first_event is not None: + first_event.wait(timeout=2.0) def test_prefetch_skipped_when_ff_off() -> None: @@ -286,54 +182,52 @@ def test_prefetch_skipped_when_ff_off() -> None: from uipath.core.feature_flags import FeatureFlags FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - with patch.object(loader, "fetch_policy_response") as mock_fetch: - prefetch_policy_index() - assert not mock_fetch.called + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + prefetch_policy_index() + + assert provider.calls == [] assert loader._prefetch_event is None def test_prefetch_no_op_when_index_already_loaded() -> None: """If the index is already cached, prefetch is a no-op.""" - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - get_policy_index() # populate the cache - with patch.object(loader, "fetch_policy_response") as mock_fetch: - prefetch_policy_index() - assert not mock_fetch.called + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + get_policy_index() # populate the cache + + prefetch_policy_index() + + assert len(provider.calls) == 1 def test_get_policy_index_waits_for_prefetch_then_returns() -> None: """When a prefetch is in flight, get_policy_index waits for completion.""" - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) started = threading.Event() release = threading.Event() - def _fetch(): + def _fetch(context: PolicyContext) -> PolicyResponse: started.set() release.wait(timeout=2.0) - return response - - with patch.object(loader, "fetch_policy_response", side_effect=_fetch): - prefetch_policy_index() - assert started.wait(timeout=2.0) - # Release the worker in a side thread so get_policy_index's wait - # actually overlaps with the slow fetch. - threading.Thread( - target=lambda: (time.sleep(0.05), release.set()), daemon=True - ).start() - index = get_policy_index() + return _ok_response() + + provider: Any = type("P", (), {"get_policy": staticmethod(_fetch)})() + set_policy_provider(provider) + + prefetch_policy_index() + assert started.wait(timeout=2.0) + threading.Thread( + target=lambda: (time.sleep(0.05), release.set()), daemon=True + ).start() + index = get_policy_index() assert index.total_rules == 1 def test_get_policy_index_logs_when_prefetch_completes_with_empty_index( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The 'completed but produced no PolicyIndex' branch fires on auth/parse fail. - - Capturing via a logger mock instead of caplog because some - test-isolation paths (other tests installing log interceptors) - can prevent records from reaching caplog's root-attached handler. - """ + """The 'completed but produced no PolicyIndex' branch fires on provider failure.""" event = threading.Event() event.set() # prefetch already completed monkeypatch.setattr(loader, "_prefetch_event", event) @@ -357,23 +251,19 @@ def test_get_available_packs_before_load_returns_empty() -> None: def test_get_available_packs_after_load() -> None: - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - get_policy_index() + set_policy_provider(StubPolicyProvider(response=_ok_response())) + get_policy_index() assert "test-pack" in get_available_packs() def test_clear_policy_cache_forces_refetch() -> None: - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object( - loader, "fetch_policy_response", return_value=response - ) as mock_fetch: - get_policy_index() - clear_policy_cache() - get_policy_index() - assert mock_fetch.call_count == 2 - - -def test_reset_policy_index_alias_for_clear() -> None: - """``reset_policy_index`` is the legacy alias for ``clear_policy_cache``.""" - assert loader.reset_policy_index is loader.clear_policy_cache + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + get_policy_index() + clear_policy_cache() + get_policy_index() + + assert len(provider.calls) == 2 + + diff --git a/tests/test_policy_agent_type.py b/tests/test_policy_agent_type.py deleted file mode 100644 index f9b9fdb..0000000 --- a/tests/test_policy_agent_type.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Tests for the conversational-vs-autonomous agent-type selector. - -The governance wrapper records whether the hosted agent is conversational; -the policy fetch then appends an ``agentType`` query param so the server's -clause-resolver reads the matching container key (``*-in-flight-agents`` vs -``*-in-flight-conversational-agents``). -""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -from uipath.runtime.governance.native import backend_client -from uipath.runtime.governance.native.backend_client import ( - agent_type_param, - set_agent_conversational, -) -from uipath.runtime.governance.native.policy_api_client import build_policy_url - -# The wrapper lands in a later slice of the governance stack; skip (don't -# error at collection) when it isn't present yet. -GovernanceRuntime = pytest.importorskip( - "uipath.runtime.governance.wrapper", - reason="GovernanceRuntime wrapper not yet present in this slice", -).GovernanceRuntime - - -def _extract(delegate, context=None) -> bool: - """Call _extract_is_conversational without running __init__.""" - runtime = object.__new__(GovernanceRuntime) - return runtime._extract_is_conversational(delegate, context) - - -@pytest.fixture(autouse=True) -def _reset_selector(): - """Clear the process-level selector around each test.""" - set_agent_conversational(None) - yield - set_agent_conversational(None) - - -def test_agent_type_param_unset_is_none(): - assert agent_type_param() is None - - -def test_agent_type_param_conversational(): - set_agent_conversational(True) - assert agent_type_param() == "conversational" - - -def test_agent_type_param_autonomous(): - set_agent_conversational(False) - assert agent_type_param() == "autonomous" - - -def test_build_policy_url_omits_param_when_unset(monkeypatch): - monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") - url = build_policy_url("my-org") - assert url == "https://alpha.uipath.com/my-org/agenticgovernance_/api/v1/runtime/policy" - assert "agentType" not in url - - -def test_build_policy_url_appends_conversational(monkeypatch): - monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") - set_agent_conversational(True) - assert build_policy_url("my-org").endswith( - "/my-org/agenticgovernance_/api/v1/runtime/policy?agentType=conversational" - ) - - -def test_build_policy_url_appends_autonomous(monkeypatch): - monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") - set_agent_conversational(False) - assert build_policy_url("my-org").endswith("?agentType=autonomous") - - -# ── _extract_is_conversational ────────────────────────────────────────────── - - -def test_extract_conversational_from_agent_definition(): - delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) - assert _extract(delegate) is True - - -def test_extract_autonomous_from_agent_definition(): - delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) - assert _extract(delegate) is False - - -def test_extract_unwraps_delegate_chain(): - inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) - outer = SimpleNamespace(_delegate=inner) # no _agent_definition on the outer - assert _extract(outer) is True - - -def test_extract_falls_back_to_context_conversation_id(): - delegate = SimpleNamespace() # nothing reachable - context = SimpleNamespace(conversation_id="conv-1") - assert _extract(delegate, context) is True - - -def test_extract_defaults_to_autonomous_when_unknown(): - assert _extract(SimpleNamespace(), SimpleNamespace()) is False \ No newline at end of file diff --git a/tests/test_policy_api_client.py b/tests/test_policy_api_client.py deleted file mode 100644 index 9ebcdb5..0000000 --- a/tests/test_policy_api_client.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Tests for ``fetch_policy_response`` and the body parser. - -Covers the skip paths (missing org / tenant / token), HTTP failures -(HTTPError, URLError, TimeoutError, OSError), and body parsing -(empty body, non-UTF8, malformed JSON, wrong top-level shape, bad -``policies`` type). -""" - -from __future__ import annotations - -import io -import json -import urllib.error -from unittest.mock import MagicMock, patch - -import pytest - -from uipath.runtime.governance.native import policy_api_client -from uipath.runtime.governance.native.policy_api_client import ( - PolicyResponse, - _parse_policy_body, - build_policy_url, - fetch_policy_response, -) - - -@pytest.fixture -def _fresh_env(monkeypatch: pytest.MonkeyPatch): - """Clear the env vars that the fetch path depends on.""" - for var in ( - "UIPATH_ORGANIZATION_ID", - "UIPATH_TENANT_ID", - "UIPATH_ACCESS_TOKEN", - "UIPATH_URL", - ): - monkeypatch.delenv(var, raising=False) - yield - - -@pytest.fixture -def _populated_env(monkeypatch: pytest.MonkeyPatch): - """All three vars present — the fetch path can reach urlopen.""" - monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") - monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") - monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok-abc") - monkeypatch.setenv("UIPATH_URL", "https://alpha.uipath.com") - yield - - -def _ok_response(body: bytes) -> MagicMock: - """urlopen()-compatible context manager that returns ``body``.""" - resp = MagicMock() - resp.read.return_value = body - resp.__enter__.return_value = resp - resp.__exit__.return_value = False - return resp - - -# --------------------------------------------------------------------------- -# Skip paths — fail-open without contacting the backend -# --------------------------------------------------------------------------- - - -def test_skip_when_org_id_missing(_fresh_env, monkeypatch) -> None: - monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") - monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") - with patch.object( - policy_api_client.urllib.request, "urlopen" - ) as mock_urlopen: - assert fetch_policy_response() is None - assert not mock_urlopen.called - - -def test_skip_when_tenant_id_missing(_fresh_env, monkeypatch) -> None: - monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") - monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") - with patch.object( - policy_api_client.urllib.request, "urlopen" - ) as mock_urlopen: - assert fetch_policy_response() is None - assert not mock_urlopen.called - - -def test_skip_when_token_missing(_fresh_env, monkeypatch) -> None: - monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") - monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") - with patch.object( - policy_api_client.urllib.request, "urlopen" - ) as mock_urlopen: - assert fetch_policy_response() is None - assert not mock_urlopen.called - - -# --------------------------------------------------------------------------- -# HTTP failure paths — fail-open with a warning -# --------------------------------------------------------------------------- - - -def test_returns_none_on_http_error(_populated_env) -> None: - err = urllib.error.HTTPError( - url="x", code=500, msg="Server Error", hdrs=None, fp=io.BytesIO(b"") - ) - with patch.object( - policy_api_client.urllib.request, "urlopen", side_effect=err - ): - assert fetch_policy_response() is None - - -def test_returns_none_on_url_error(_populated_env) -> None: - err = urllib.error.URLError("connection refused") - with patch.object( - policy_api_client.urllib.request, "urlopen", side_effect=err - ): - assert fetch_policy_response() is None - - -def test_returns_none_on_timeout(_populated_env) -> None: - with patch.object( - policy_api_client.urllib.request, "urlopen", side_effect=TimeoutError() - ): - assert fetch_policy_response() is None - - -def test_returns_none_on_os_error(_populated_env) -> None: - with patch.object( - policy_api_client.urllib.request, - "urlopen", - side_effect=OSError("disk full"), - ): - assert fetch_policy_response() is None - - -def test_outer_swallows_unexpected_exception(_populated_env) -> None: - """Even non-HTTP exceptions from urlopen don't escape the fetch helper.""" - with patch.object( - policy_api_client.urllib.request, - "urlopen", - side_effect=RuntimeError("library bug"), - ): - assert fetch_policy_response() is None - - -# --------------------------------------------------------------------------- -# Headers / URL composition -# --------------------------------------------------------------------------- - - -def test_sends_no_content_type_on_get(_populated_env) -> None: - """The GET must NOT carry Content-Type — some servers 415 on it.""" - with patch.object( - policy_api_client.urllib.request, - "urlopen", - return_value=_ok_response(b'{"mode": "audit", "policies": ""}'), - ) as mock_urlopen: - fetch_policy_response() - request_arg = mock_urlopen.call_args.args[0] - assert request_arg.get_header("Content-type") is None - assert request_arg.get_header("Accept") == "application/json" - assert request_arg.get_header("Authorization") == "Bearer tok-abc" - assert request_arg.get_header("X-uipath-internal-tenantid") == "tenant-1" - assert request_arg.get_method() == "GET" - - -def test_url_includes_agent_type_when_set(_populated_env, monkeypatch) -> None: - """``build_policy_url`` appends ``?agentType=...`` from the selector.""" - from uipath.runtime.governance.native import backend_client - - monkeypatch.setattr(backend_client, "_agent_is_conversational", True) - url = build_policy_url("org-x") - assert "agentType=conversational" in url - - -def test_url_omits_agent_type_when_unset(_populated_env, monkeypatch) -> None: - from uipath.runtime.governance.native import backend_client - - monkeypatch.setattr(backend_client, "_agent_is_conversational", None) - url = build_policy_url("org-x") - assert "agentType=" not in url - - -# --------------------------------------------------------------------------- -# Body parser — _parse_policy_body -# --------------------------------------------------------------------------- - - -def test_parse_empty_body_returns_none() -> None: - assert _parse_policy_body(b"") is None - - -def test_parse_non_utf8_body_returns_none() -> None: - # 0xff isn't valid UTF-8. - assert _parse_policy_body(b"\xff\xfe") is None - - -def test_parse_malformed_json_returns_none() -> None: - # A common shape: server returns HTML when it should return JSON. - assert _parse_policy_body(b"oops") is None - - -def test_parse_non_object_top_level_returns_none() -> None: - """Server returning a bare JSON array is rejected — expected an object.""" - assert _parse_policy_body(b'["audit", "policies"]') is None - - -def test_parse_non_string_policies_field_returns_none() -> None: - """``policies`` must be a string YAML body, not a number / dict / list.""" - assert _parse_policy_body(b'{"mode": "audit", "policies": 42}') is None - - -def test_parse_ok_yields_policy_response() -> None: - resp = _parse_policy_body( - b'{"mode": "enforce", "policies": "standard: p\\nrules: []"}' - ) - assert resp is not None - assert resp.mode == "enforce" - assert "standard: p" in resp.policy - - -def test_parse_ok_with_missing_mode_yields_none_mode() -> None: - """A response without ``mode`` is still valid — server may not override.""" - resp = _parse_policy_body(b'{"policies": ""}') - assert resp is not None - assert resp.mode is None - assert resp.policy == "" - - -def test_parse_empty_string_mode_treated_as_unset() -> None: - """Empty-string ``mode`` is normalized to ``None`` (don't override default).""" - resp = _parse_policy_body(b'{"mode": "", "policies": ""}') - assert resp is not None - assert resp.mode is None - - -def test_parse_non_string_mode_treated_as_unset() -> None: - """If the server sends mode as a number / null, treat as unset.""" - resp = _parse_policy_body(b'{"mode": 5, "policies": ""}') - assert resp is not None - assert resp.mode is None - - -# --------------------------------------------------------------------------- -# Full happy-path round-trip -# --------------------------------------------------------------------------- - - -def test_full_fetch_round_trip(_populated_env) -> None: - body = json.dumps( - {"mode": "audit", "policies": "standard: p\nrules: []"} - ).encode("utf-8") - with patch.object( - policy_api_client.urllib.request, - "urlopen", - return_value=_ok_response(body), - ): - resp = fetch_policy_response() - assert isinstance(resp, PolicyResponse) - assert resp.mode == "audit" - assert "standard: p" in resp.policy diff --git a/uv.lock b/uv.lock index cc63250..39942dd 100644 --- a/uv.lock +++ b/uv.lock @@ -1148,16 +1148,16 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.19" +version = "0.5.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/f7/6250b221d99bfbfb3ac2cb79efb3049deac4b406d5b2ae93e67e21c34ba2/uipath_core-0.5.19.tar.gz", hash = "sha256:8a4b0425b4e1edf9588e04997c0fdbcc4d3051ac00337eb626d4d62129d73af4", size = 132274, upload-time = "2026-06-17T08:15:34.924Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/df/0b49804f00cda5641f41fdfc5f2f3b11d2dfb7f8ec956f74ffe02b4f76d3/uipath_core-0.5.21.tar.gz", hash = "sha256:be0d8a148cf27ffd86a06d2582e948d9ab181012616849181947c10dbcbfc81c", size = 135316, upload-time = "2026-06-23T11:16:43.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/b6/a8081ef1e08f0b12df36beac7d4176a53443d2d8da1f001cfc09da29083a/uipath_core-0.5.19-py3-none-any.whl", hash = "sha256:085ec8bb2c61711859127506bc472289ecad3b196378c3d42bc59e333496a2c6", size = 54889, upload-time = "2026-06-17T08:15:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3d/e3c5f935013cd2cb17edc4c826cbe1f3d513f2a4a07faf8dec80659420c5/uipath_core-0.5.21-py3-none-any.whl", hash = "sha256:dce40f766987e907655311e13d4b8106766152da3995794cdbcbf712603388dd", size = 57273, upload-time = "2026-06-23T11:16:41.701Z" }, ] [[package]] @@ -1191,7 +1191,7 @@ dev = [ requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "uipath-core", specifier = ">=0.5.19,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.21,<0.6.0" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] From 4e6627d3287d4ccac92580a5cbdf038098c50081 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Wed, 24 Jun 2026 14:25:26 +0530 Subject: [PATCH 6/7] refactor(governance): instance-scope PolicyLoader; explicit is_conversational MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses radu's review on PR #121 — collapses three architectural boundary concerns into the loader/runtime layers. 1. PolicyLoader is now instance-scoped, not module-globals. Each GovernanceRuntime constructs its own loader carrying its own provider, cache, prefetch state, and conversational selector. uipath eval can spin up multiple runtimes in parallel without them clobbering each other's policy state. 2. is_governance_enabled() reads removed from the runtime layer. The decision "should governance attach?" belongs to the wiring layer (uipath CLI) — it chooses whether to construct GovernanceRuntime at all. Inside the loader the contract is purely "provider present → load policies; provider missing → empty PolicyIndex". The feature flag itself stays in uipath-core. 3. _extract_is_conversational and its delegate-walking deleted. GovernanceRuntime now takes is_conversational explicitly as a keyword arg; the wiring layer (which knows the agent type) passes it in. Runtime no longer reaches into _delegate._agent_definition private attrs. Plus two correctness fixes called out in the readiness re-check: - clear_cache() vs in-flight prefetch worker race: worker now checks _prefetch_event is event before publishing self._policy_index so an orphaned worker can't clobber the just-cleared cache. - _load_from_provider takes the narrowed provider as a parameter instead of asserting self._provider is not None — the bandit B101 "assert stripped under -O" finding is now gone. Tests rewritten around PolicyLoader instances; cross-instance isolation pinned; orphan-worker race regression test added; conftest autouse reset fixture removed (no module state to clean). 187 pass, ruff/mypy/bandit clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runtime/governance/native/loader.py | 540 +++++++++--------- src/uipath/runtime/governance/runtime.py | 130 ++--- tests/conftest.py | 29 +- tests/test_governance_runtime.py | 381 +++--------- tests/test_loader.py | 258 +++++---- 5 files changed, 550 insertions(+), 788 deletions(-) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index 6603a50..a59d96f 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -1,12 +1,17 @@ """Policy pack loader. -Resolves the active PolicyIndex at startup by calling a registered -:class:`GovernancePolicyProvider`. The runtime never contacts the -governance backend directly; the provider owns the wire / transport -(auth, retries, telemetry). When no provider is registered, or the -provider raises / returns an empty body / yields zero rules, the -loader returns an empty PolicyIndex and the agent runs without any -rules. +Per-runtime policy loading: a :class:`PolicyLoader` instance owns one +provider plus the cached PolicyIndex and prefetch state. The runtime +never contacts the governance backend directly; the provider owns the +wire / transport (auth, retries, telemetry). When no provider is +supplied, or the provider raises / returns an empty body / yields zero +rules, the loader returns an empty PolicyIndex and the agent runs +without any rules. + +The loader holds **no module-level state**. ``uipath eval`` can spin up +multiple ``GovernanceRuntime`` instances in the same process and each +gets its own loader with its own provider, cache, and selector — no +cross-instance interference. """ from __future__ import annotations @@ -18,7 +23,6 @@ import yaml from uipath.core.governance import GovernancePolicyProvider, PolicyContext -from uipath.core.governance.config import is_governance_enabled from uipath.runtime.governance.config import set_enforcement_mode from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml @@ -26,296 +30,286 @@ logger = logging.getLogger(__name__) -# Module-level cache -_policy_index: PolicyIndex | None = None - -# Background-prefetch coordination. ``_prefetch_event`` is set once the -# background load_policy_index() call finishes (success OR failure); -# callers of ``get_policy_index()`` wait on it. ``_prefetch_lock`` -# protects the start-once semantics so concurrent ``prefetch`` calls -# don't kick off duplicate threads. -_prefetch_event: threading.Event | None = None -_prefetch_lock = threading.Lock() - -# Upper bound on how long ``get_policy_index()`` waits for an in-flight -# prefetch before falling back to an empty PolicyIndex. The provider -# owns its own transport timeouts; this is the runtime's ceiling on -# blocking the first hook fire. -_PROVIDER_WAIT_SECONDS = 10.0 - -# Registered :class:`GovernancePolicyProvider`. Set by -# :class:`GovernanceRuntime` at init. ``None`` means no provider is -# registered — :func:`load_policy_index` returns an empty PolicyIndex -# in that case. -_policy_provider: GovernancePolicyProvider | None = None - -# Whether the hosted agent is conversational. Travels in the -# :class:`PolicyContext` so the provider can select the matching policy -# view. A process-level holder (not a ContextVar) because the prefetch -# runs on a separate thread that wouldn't inherit one, and a -# coded-agent process hosts a single agent so the value is stable per -# process. ``None`` leaves the selector unset — the provider applies -# its default. -_agent_is_conversational: bool | None = None - - -def set_policy_provider(provider: GovernancePolicyProvider | None) -> None: - """Register the policy provider the loader will use to fetch policies. - - Called once by :class:`GovernanceRuntime` during init before - :func:`prefetch_policy_index`. ``None`` clears the registration — - used by tests and by callers that opt out of governance. - """ - global _policy_provider - _policy_provider = provider - - -def set_agent_conversational(value: bool | None) -> None: - """Record whether the hosted agent is conversational. - Threaded into :class:`PolicyContext` on every provider call so the - provider can resolve the conversational-vs-autonomous policy view. - ``None`` clears the selector — the provider then applies its - default. - """ - global _agent_is_conversational - _agent_is_conversational = value +class PolicyLoader: + """Instance-scoped policy loader bound to one provider. + Owns the policy-index cache, prefetch coordination, and the + conversational selector for a single :class:`GovernanceRuntime` + instance. Multiple loaders coexist in the same process without + clobbering each other. -def prefetch_policy_index() -> None: - """Kick off a background load of the policy index. + Typical lifecycle:: - Non-blocking. Designed to be called as early as possible (at - ``GovernanceRuntime.__init__``) so the policy fetch overlaps with - the rest of agent setup. The result lands in the same module cache - that ``get_policy_index()`` reads from; ``get_policy_index()`` waits - on this prefetch when it's in flight. + loader = PolicyLoader(provider, is_conversational=False) + loader.prefetch() # non-blocking, optional + index = loader.get_policy_index() # cached after first call - Idempotent: subsequent calls while the first is running are no-ops, - and calls after completion are no-ops. Skipped entirely when the - governance feature flag is OFF so the provider is never invoked. + When ``provider`` is ``None``, every load returns an empty + PolicyIndex without invoking anything. """ - global _prefetch_event - - if not is_governance_enabled(): - return - - with _prefetch_lock: - if _policy_index is not None: - return # already loaded - if _prefetch_event is not None: - return # already in flight - event = threading.Event() - _prefetch_event = event - def _worker() -> None: - global _policy_index - try: - loaded = load_policy_index() - except Exception as exc: # noqa: BLE001 - logged; first hook will retry sync - logger.warning("Policy prefetch failed: %s", exc) - else: - with _prefetch_lock: - _policy_index = loaded - finally: - event.set() - - threading.Thread( - target=_worker, - name="governance-policy-prefetch", - daemon=True, - ).start() - - -def get_policy_index() -> PolicyIndex: - """Get the cached policy index, loading if necessary. - - Resolution order on first call: - 1. If the governance feature flag is OFF, return an empty - PolicyIndex (cached). Provider is not invoked. - 2. If a prefetch (see :func:`prefetch_policy_index`) is in flight, - wait for it to complete (bounded by ``_PROVIDER_WAIT_SECONDS``). - 3. Synchronously call :func:`load_policy_index` (which invokes the - registered :class:`GovernancePolicyProvider`). - 4. Empty PolicyIndex when no provider is registered or the - provider fails / returns nothing. - - Result is cached for the process lifetime; per-hook evaluation never - touches the network. Call :func:`clear_policy_cache` to force a - refetch (mainly for tests). - """ - global _policy_index - - if _policy_index is not None: - return _policy_index - - if not is_governance_enabled(): - logger.info( - "Governance feature flag is OFF; returning empty PolicyIndex. " - "No rules will fire. Set EnablePythonGovernanceChecker=True to enable." - ) - _policy_index = PolicyIndex() - return _policy_index - - event = _prefetch_event - if event is not None: - completed = event.wait(timeout=_PROVIDER_WAIT_SECONDS) - if completed and _policy_index is not None: - return _policy_index - if not completed: - # Timeout: deliberately cache an empty index so we don't - # re-wait the full timeout on every subsequent hook. + # Upper bound on how long :meth:`get_policy_index` waits for an + # in-flight prefetch before falling back to an empty PolicyIndex. + # The provider owns its own transport timeouts; this is the runtime's + # ceiling on blocking the first hook fire. + _PROVIDER_WAIT_SECONDS = 10.0 + + def __init__( + self, + provider: GovernancePolicyProvider | None, + *, + is_conversational: bool | None = None, + ) -> None: + """Construct a per-runtime policy loader. + + Args: + provider: Policy source. ``None`` means no policies will be + loaded — the loader yields an empty PolicyIndex. + is_conversational: Whether the hosted agent is + conversational. Travels in the :class:`PolicyContext` + so the provider can select the matching policy view. + ``None`` leaves the selector unset — the provider + applies its default. + """ + self._provider = provider + self._is_conversational = is_conversational + self._policy_index: PolicyIndex | None = None + # ``_prefetch_event`` is set once the background load finishes + # (success OR failure); callers of ``get_policy_index`` wait on + # it. ``_prefetch_lock`` guards the start-once semantics so + # concurrent ``prefetch`` calls don't kick off duplicate threads. + self._prefetch_event: threading.Event | None = None + self._prefetch_lock = threading.Lock() + + def prefetch(self) -> None: + """Kick off a background load of the policy index. + + Non-blocking. Designed to be called as early as possible (at + :class:`GovernanceRuntime` init) so the policy fetch overlaps + with the rest of agent setup. The result lands in this loader's + cache; :meth:`get_policy_index` waits on the prefetch when it's + in flight. + + Idempotent: subsequent calls while the first is running are + no-ops, and calls after completion are no-ops. No-op when no + provider is supplied — there's nothing to fetch. + """ + if self._provider is None: + return + + with self._prefetch_lock: + if self._policy_index is not None: + return # already loaded + if self._prefetch_event is not None: + return # already in flight + event = threading.Event() + self._prefetch_event = event + + def _worker() -> None: + try: + loaded = self.load_policy_index() + except Exception as exc: # noqa: BLE001 - logged; first hook will retry sync + logger.warning("Policy prefetch failed: %s", exc) + else: + with self._prefetch_lock: + # Only publish if we're still the live prefetch. + # ``clear_cache`` nulls ``_prefetch_event`` to retire + # an in-flight worker; in that case the loaded value + # belongs to a stale generation and must be dropped + # rather than clobbering the just-cleared state. + if self._prefetch_event is event: + self._policy_index = loaded + finally: + event.set() + + threading.Thread( + target=_worker, + name="governance-policy-prefetch", + daemon=True, + ).start() + + def get_policy_index(self) -> PolicyIndex: + """Get the cached policy index, loading if necessary. + + Resolution order on first call: + 1. If a prefetch (see :meth:`prefetch`) is in flight, wait + for it to complete (bounded by ``_PROVIDER_WAIT_SECONDS``). + 2. Synchronously call :meth:`load_policy_index` (which invokes + the provider). + 3. Empty PolicyIndex when no provider is supplied or the + provider fails / returns nothing. + + Result is cached for the loader's lifetime; per-hook evaluation + never touches the network. Call :meth:`clear_cache` to force a + refetch (mainly for tests). + """ + if self._policy_index is not None: + return self._policy_index + + event = self._prefetch_event + if event is not None: + completed = event.wait(timeout=self._PROVIDER_WAIT_SECONDS) + if completed and self._policy_index is not None: + return self._policy_index + if not completed: + # Timeout: cache an empty index so we don't re-wait the + # full timeout on every subsequent hook. + logger.warning( + "Policy prefetch did not complete in %.1fs; " + "agent will run without any policies", + self._PROVIDER_WAIT_SECONDS, + ) + self._policy_index = PolicyIndex() + return self._policy_index + + # Completed but produced no PolicyIndex — the worker hit an + # unexpected error. Do NOT cache the empty result: caching + # would permanently disable governance for the loader's + # lifetime even though a later prefetch / clear_cache could + # still recover. Return an empty index for this call only. logger.warning( - "Policy prefetch did not complete in %.1fs; " - "agent will run without any policies", - _PROVIDER_WAIT_SECONDS, + "Policy prefetch completed but produced no PolicyIndex " + "(see prior WARN for the root cause); agent will run " + "without any policies for this call" ) - _policy_index = PolicyIndex() - return _policy_index - - # Completed but produced no PolicyIndex — the worker hit an - # unexpected error (provider failure, parse failure). Do NOT - # cache the empty result: caching would permanently disable - # governance for the process even though a later prefetch / - # clear_policy_cache could still recover. Return an empty index - # for this call only and leave the cache unset. - logger.warning( - "Policy prefetch completed but produced no PolicyIndex " - "(see prior WARN for the root cause); agent will run " - "without any policies for this call" + return PolicyIndex() + + # No prefetch was started (direct callers / tests). Sync load. + self._policy_index = self.load_policy_index() + return self._policy_index + + def load_policy_index(self) -> PolicyIndex: + """Synchronously load and parse the policy index. + + Returns: + PolicyIndex parsed from the provider response. Empty + PolicyIndex when no provider is supplied, the provider + raises, the YAML is malformed, or the response yields + zero rules. + """ + start = time.perf_counter() + + index = ( + self._load_from_provider(self._provider) + if self._provider is not None + else None ) - return PolicyIndex() - - # No prefetch was started (direct callers / tests). Sync load. - _policy_index = load_policy_index() - return _policy_index - - -def load_policy_index() -> PolicyIndex: - """Load the active PolicyIndex via the registered policy provider. - Returns: - PolicyIndex parsed from the provider response. Empty PolicyIndex - when no provider is registered, the provider raises, the YAML - is malformed, or the response yields zero rules. - """ - start = time.perf_counter() - - provider = _policy_provider - index = _load_from_provider(provider) if provider is not None else None + if index is not None: + self._log_index_summary(index) + logger.info( + "Policy index ready: source=provider, total_ms=%.1f", + (time.perf_counter() - start) * 1000, + ) + return index - if index is not None: - _log_index_summary(index) + reason = self._empty_index_reason() logger.info( - "Policy index ready: source=provider, total_ms=%.1f", + "Policy index ready: source=empty (%s), total_ms=%.1f", + reason, (time.perf_counter() - start) * 1000, ) - return index + return PolicyIndex() - reason = _empty_index_reason() - logger.info( - "Policy index ready: source=empty (%s), total_ms=%.1f", - reason, - (time.perf_counter() - start) * 1000, - ) - return PolicyIndex() + def _empty_index_reason(self) -> str: + """Diagnose why policy loading produced nothing.""" + if self._provider is None: + return "no policy provider supplied" + return "provider returned no policies (error / empty body / zero rules)" + def _load_from_provider( + self, provider: GovernancePolicyProvider + ) -> PolicyIndex | None: + """Fetch and parse the policy index via the supplied provider. -def _empty_index_reason() -> str: - """Diagnose why policy loading produced nothing.""" - if _policy_provider is None: - return "no policy provider registered" - return "provider returned no policies (error / empty body / zero rules)" + Applies the provider-supplied enforcement mode as a side effect. + Returns ``None`` when the provider raises, when the YAML is + malformed, or when the resulting index has no rules — caller + returns an empty PolicyIndex in those cases. + Takes ``provider`` as a parameter (rather than reading + ``self._provider``) so the type system can prove the call site + is non-None — :meth:`load_policy_index` guards on ``None`` and + passes the narrowed value through. + """ + start = time.perf_counter() -def _load_from_provider(provider: GovernancePolicyProvider) -> PolicyIndex | None: - """Fetch and parse the policy index via a :class:`GovernancePolicyProvider`. + ctx = PolicyContext(is_conversational=self._is_conversational) - Applies the provider-supplied enforcement mode as a side effect. - Returns ``None`` when the provider raises, when the YAML is - malformed, or when the resulting index has no rules — caller returns - an empty PolicyIndex in those cases. - """ - start = time.perf_counter() + try: + response = provider.get_policy(ctx) + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning("Policy provider get_policy failed: %s", exc) + return None - ctx = PolicyContext(is_conversational=_agent_is_conversational) + if response.mode is not None: + set_enforcement_mode(response.mode) + logger.info("Enforcement mode set from provider: %s", response.mode.value) - try: - response = provider.get_policy(ctx) - except Exception as exc: # noqa: BLE001 - fail-open by contract - logger.warning("Policy provider get_policy failed: %s", exc) - return None + if not response.policies: + logger.warning( + "Policy provider returned empty policies field; " + "agent will run without any policies" + ) + return None - if response.mode is not None: - set_enforcement_mode(response.mode) - logger.info("Enforcement mode set from provider: %s", response.mode.value) + try: + index = build_policy_index_from_yaml(response.policies) + except yaml.YAMLError as exc: + logger.warning("Policy YAML from provider was malformed: %s", exc) + return None + except Exception as exc: # noqa: BLE001 - never let load break agent startup + logger.warning("Failed to build PolicyIndex from provider YAML: %s", exc) + return None + + if index.total_rules == 0: + logger.warning( + "Policy YAML from provider yielded zero rules; " + "agent will run without any policies" + ) + return None - if not response.policies: - logger.warning( - "Policy provider returned empty policies field; " - "agent will run without any policies" - ) - return None - - try: - index = build_policy_index_from_yaml(response.policies) - except yaml.YAMLError as exc: - logger.warning("Policy YAML from provider was malformed: %s", exc) - return None - except Exception as exc: # noqa: BLE001 - never let load break agent startup - logger.warning("Failed to build PolicyIndex from provider YAML: %s", exc) - return None - - if index.total_rules == 0: - logger.warning( - "Policy YAML from provider yielded zero rules; " - "agent will run without any policies" + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info( + "Loaded policy index from provider: packs=%s, rules=%d, elapsed_ms=%.1f", + index.pack_names, + index.total_rules, + elapsed_ms, ) - return None - - elapsed_ms = (time.perf_counter() - start) * 1000 - logger.info( - "Loaded policy index from provider: packs=%s, rules=%d, elapsed_ms=%.1f", - index.pack_names, - index.total_rules, - elapsed_ms, - ) - return index - - -def _log_index_summary(index: PolicyIndex) -> None: - """Log summary of loaded policy index.""" - hook_counts: Counter[str] = Counter() - for rule in index.all_rules: - hook_counts[rule.hook.value] += 1 - - logger.debug( - "Policy packs: %s, total rules: %d, by hook: %s", - index.pack_names, - index.total_rules, - dict(hook_counts), - ) - - -def get_available_packs() -> list[str]: - """Get list of pack names from the currently loaded policy index. - - Returns whatever the provider supplied on the most recent load. - Empty list if no index has been loaded yet. - """ - if _policy_index is None: - return [] - return _policy_index.pack_names - + return index -def clear_policy_cache() -> None: - """Clear the cached policy index and any in-flight prefetch state. + def _log_index_summary(self, index: PolicyIndex) -> None: + """Log summary of loaded policy index.""" + hook_counts: Counter[str] = Counter() + for rule in index.all_rules: + hook_counts[rule.hook.value] += 1 + + logger.debug( + "Policy packs: %s, total rules: %d, by hook: %s", + index.pack_names, + index.total_rules, + dict(hook_counts), + ) - Next call to ``get_policy_index()`` will reload from the registered - :class:`GovernancePolicyProvider`. - """ - global _policy_index, _prefetch_event - with _prefetch_lock: - _policy_index = None - _prefetch_event = None - logger.debug("Policy index cache cleared") + @property + def available_packs(self) -> list[str]: + """Pack names from the currently loaded policy index. + + Returns whatever the provider supplied on the most recent load. + Empty list if no index has been loaded yet. + """ + if self._policy_index is None: + return [] + return self._policy_index.pack_names + + def clear_cache(self) -> None: + """Clear the cached policy index and any in-flight prefetch state. + + Next call to :meth:`get_policy_index` will reload from the + provider. + """ + with self._prefetch_lock: + self._policy_index = None + self._prefetch_event = None + logger.debug("Policy index cache cleared") diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py index 12001c2..c8f9dd9 100644 --- a/src/uipath/runtime/governance/runtime.py +++ b/src/uipath/runtime/governance/runtime.py @@ -7,16 +7,22 @@ when ``policy_provider`` is ``None`` the agent runs without any governance policies. +The wiring layer (uipath CLI) decides whether to construct +``GovernanceRuntime`` at all (feature flag, project config, etc.) and +passes ``is_conversational`` explicitly when it knows the agent type. +The runtime layer does not introspect the delegate's private attributes +to discover that. + **Staging caveat — policy loading only, no enforcement yet.** This -module is the policy-loading scaffold: ``__init__`` registers the -provider, extracts the conversational/autonomous selector, and kicks -off a background prefetch into the loader cache. ``execute`` / -``stream`` / ``get_schema`` / ``dispose`` are pure passthroughs — no -per-hook policy evaluation runs. The evaluator + adapter wiring that -consumes :func:`get_policy_index` lands in a follow-up slice. Customers -constructing :class:`GovernanceRuntime` today get policy loading without -policy enforcement; this is intentional and will change when the -evaluator slice merges. +module is the policy-loading scaffold: ``__init__`` constructs an +instance-scoped :class:`PolicyLoader` and kicks off a background +prefetch. ``execute`` / ``stream`` / ``get_schema`` / ``dispose`` are +pure passthroughs — no per-hook policy evaluation runs. The evaluator +and framework adapter wiring that consumes the loader's policy index +lands in a follow-up slice. Customers constructing +:class:`GovernanceRuntime` today get policy loading without policy +enforcement; this is intentional and will change when the evaluator +slice merges. """ from __future__ import annotations @@ -25,7 +31,6 @@ from typing import Any, AsyncGenerator from uipath.core.governance import GovernancePolicyProvider -from uipath.core.governance.config import is_governance_enabled from uipath.runtime.base import ( UiPathExecuteOptions, @@ -33,72 +38,36 @@ UiPathStreamOptions, ) from uipath.runtime.events import UiPathRuntimeEvent -from uipath.runtime.governance.native.loader import ( - prefetch_policy_index, - set_agent_conversational, - set_policy_provider, -) +from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.result import UiPathRuntimeResult from uipath.runtime.schema import UiPathRuntimeSchema logger = logging.getLogger(__name__) -# Bound on how deeply we walk ``_delegate`` / ``delegate`` chains when -# looking for an :class:`AgentDefinition`. Wrappers like -# :class:`UiPathExecutionRuntime` and :class:`UiPathResumableRuntime` -# add at most a handful of layers; 10 is well above any realistic -# stack and keeps a pathological self-referential wrapper from looping. -_MAX_DELEGATE_UNWRAP_DEPTH = 10 - - -def _extract_is_conversational(delegate: object) -> bool | None: - """Read ``is_conversational`` off the delegate's agent definition. - - Walks ``delegate._agent_definition.is_conversational`` (the - LicensedRuntime pattern published by the agents SDK), unwrapping - the ``_delegate`` / ``delegate`` chain up to - :data:`_MAX_DELEGATE_UNWRAP_DEPTH` so wrapper layers don't hide the - licensed runtime. - - Returns ``None`` when no agent definition is reachable — the - provider then applies its default rather than the runtime guessing - a value. - """ - node: object | None = delegate - for _ in range(_MAX_DELEGATE_UNWRAP_DEPTH): - if node is None: - break - agent_def = getattr(node, "_agent_definition", None) - if agent_def is not None: - value = getattr(agent_def, "is_conversational", None) - if value is not None: - return bool(value) - node = getattr(node, "_delegate", None) or getattr(node, "delegate", None) - return None - class GovernanceRuntime: """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. - Registers the supplied :class:`GovernancePolicyProvider` with the - policy loader and kicks off a non-blocking prefetch so the policy - pack overlaps with the rest of agent setup. When ``policy_provider`` - is ``None``, no provider is registered and the agent runs without - any governance policies (the loader yields an empty PolicyIndex). + Constructs an instance-scoped :class:`PolicyLoader` bound to the + supplied provider and kicks off a non-blocking prefetch so the + policy pack overlaps with the rest of agent setup. When + ``policy_provider`` is ``None``, the loader yields an empty + PolicyIndex and the agent runs without any governance policies for + the lifetime of this instance. **Policy loading only — no enforcement yet.** ``execute`` / ``stream`` / ``get_schema`` / ``dispose`` are passthroughs to the delegate; no per-hook policy evaluation runs in this slice. The evaluator and - framework adapter wiring that consumes :func:`get_policy_index` is - staged separately. Constructing this wrapper today gives you the - policy load (provider invoked, index cached) but no actual - enforcement of the loaded rules. + framework adapter wiring that consumes the loader's policy index is + staged separately. """ def __init__( self, delegate: UiPathRuntimeProtocol, policy_provider: GovernancePolicyProvider | None, + *, + is_conversational: bool | None = None, ): """Initialize the governance runtime. @@ -107,34 +76,29 @@ def __init__( policy_provider: Source of the policy pack. ``None`` means no policies will be loaded — the agent runs without governance for the lifetime of this instance. + is_conversational: Whether the hosted agent is + conversational. Forwarded into the provider's + :class:`PolicyContext` so it can pick the right policy + view (conversational vs autonomous). ``None`` (default) + leaves the selector unset — the provider applies its + default. The wiring layer (uipath CLI) is expected to + pass the concrete value when it knows the agent type. """ self._delegate = delegate - self._policy_provider = policy_provider - - if is_governance_enabled(): - # Record agent-type before the prefetch fires so the - # provider's first ``get_policy`` call sees the right - # selector on its ``PolicyContext``. Wrapped in try/except - # so a misbehaving delegate getattr can't break runtime - # init — fail-open: on failure the selector keeps whatever - # value an integration may have set externally. - # - # Only write when extraction returned a concrete bool. An - # extraction miss (``None``) leaves the selector untouched - # so an externally-set value (e.g. an integration that - # pre-seeded the selector from a different signal) is not - # silently clobbered by our init. - try: - extracted = _extract_is_conversational(delegate) - except Exception as exc: # noqa: BLE001 - fail-open - logger.warning( - "Failed to extract is_conversational from delegate: %s", exc - ) - else: - if extracted is not None: - set_agent_conversational(extracted) - set_policy_provider(policy_provider) - prefetch_policy_index() + self._loader = PolicyLoader( + policy_provider, + is_conversational=is_conversational, + ) + self._loader.prefetch() + + @property + def loader(self) -> PolicyLoader: + """The instance-scoped policy loader. + + Exposed so adapters / evaluators wired into this runtime can + call :meth:`PolicyLoader.get_policy_index` at hook time. + """ + return self._loader async def execute( self, diff --git a/tests/conftest.py b/tests/conftest.py index 78ea6ff..01e5dc9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,27 +19,8 @@ def temp_dir() -> Generator[str, None, None]: yield tmp_dir -@pytest.fixture(autouse=True) -def _reset_governance_process_state() -> Generator[None, None, None]: - """Clear process-level governance state around every test. - - The loader keeps the conversational selector and the registered - policy provider at module scope. Both are stable per process in - production but leak across tests when not reset, masking ordering - bugs and producing flakes. Import is guarded so this fixture is a - no-op when the governance package isn't built yet. - """ - try: - from uipath.runtime.governance.native.loader import ( - set_agent_conversational, - set_policy_provider, - ) - except ImportError: - yield - return - - set_agent_conversational(None) - set_policy_provider(None) - yield - set_agent_conversational(None) - set_policy_provider(None) +# The loader no longer keeps provider / selector at module scope — +# state is owned by each :class:`PolicyLoader` instance — so no +# autouse cross-test reset is needed. Tests that share enforcement +# mode call :func:`reset_enforcement_mode` from ``tests._helpers`` +# directly. diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py index b2b126b..91fca09 100644 --- a/tests/test_governance_runtime.py +++ b/tests/test_governance_runtime.py @@ -1,10 +1,15 @@ -"""Tests for the GovernanceRuntime wrapper and the provider loader path.""" +"""Tests for the GovernanceRuntime wrapper and the provider loader path. + +The runtime no longer introspects the delegate's private attributes to +discover the conversational flag — the wiring layer passes it +explicitly. The runtime also no longer reads the governance feature +flag: the wiring layer decides whether to construct +:class:`GovernanceRuntime` at all. +""" from __future__ import annotations -from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock import pytest from uipath.core.governance import ( @@ -14,19 +19,9 @@ from tests._helpers import StubPolicyProvider, reset_enforcement_mode from uipath.runtime.governance.config import get_enforcement_mode -from uipath.runtime.governance.native import loader -from uipath.runtime.governance.native.loader import ( - _load_from_provider, - clear_policy_cache, - load_policy_index, - set_agent_conversational, - set_policy_provider, -) +from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.runtime import ( - GovernanceRuntime, - _extract_is_conversational, -) +from uipath.runtime.governance.runtime import GovernanceRuntime SIMPLE_POLICY_YAML = """ standard: provider-pack @@ -41,32 +36,24 @@ @pytest.fixture(autouse=True) -def _enable_ff_and_reset(monkeypatch: pytest.MonkeyPatch): - """Reset module state and turn the governance FF on per test.""" - from uipath.core.feature_flags import FeatureFlags - - clear_policy_cache() +def _reset_mode() -> Any: + """Each test starts with a clean enforcement-mode slate.""" reset_enforcement_mode() - set_policy_provider(None) - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) yield - clear_policy_cache() reset_enforcement_mode() - set_policy_provider(None) - FeatureFlags.reset_flags() # --------------------------------------------------------------------------- -# _load_from_provider — direct unit tests +# PolicyLoader — provider plumbing (mode application, context, errors) # --------------------------------------------------------------------------- -def test_load_from_provider_builds_index_and_applies_mode() -> None: +def test_loader_builds_index_and_applies_mode() -> None: provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) ) - index = _load_from_provider(provider) + index = PolicyLoader(provider).load_policy_index() assert isinstance(index, PolicyIndex) assert index.total_rules == 1 @@ -74,52 +61,63 @@ def test_load_from_provider_builds_index_and_applies_mode() -> None: assert get_enforcement_mode() == EnforcementMode.ENFORCE -def test_load_from_provider_passes_is_conversational_in_context() -> None: - set_agent_conversational(True) +def test_loader_passes_is_conversational_in_context() -> None: provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) ) - _load_from_provider(provider) + PolicyLoader(provider, is_conversational=True).load_policy_index() assert len(provider.calls) == 1 assert provider.calls[0].is_conversational is True -def test_load_from_provider_returns_none_when_provider_raises() -> None: - provider = StubPolicyProvider(raises=RuntimeError("boom")) +def test_loader_omits_is_conversational_when_unset() -> None: + """``is_conversational=None`` (the default) leaves the selector unset.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + PolicyLoader(provider).load_policy_index() + + assert len(provider.calls) == 1 + assert provider.calls[0].is_conversational is None - assert _load_from_provider(provider) is None + +def test_loader_returns_empty_when_provider_raises() -> None: + provider = StubPolicyProvider(raises=RuntimeError("boom")) + index = PolicyLoader(provider).load_policy_index() + assert index.total_rules == 0 -def test_load_from_provider_returns_none_on_empty_policies() -> None: +def test_loader_returns_empty_on_empty_policies() -> None: provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") ) - - assert _load_from_provider(provider) is None + index = PolicyLoader(provider).load_policy_index() + assert index.total_rules == 0 -def test_load_from_provider_returns_none_on_zero_rules() -> None: +def test_loader_returns_empty_on_zero_rules() -> None: empty_pack_yaml = "standard: empty\nrules: []\n" provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=empty_pack_yaml) ) - - assert _load_from_provider(provider) is None + index = PolicyLoader(provider).load_policy_index() + assert index.total_rules == 0 -def test_load_from_provider_returns_none_on_malformed_yaml() -> None: +def test_loader_returns_empty_on_malformed_yaml() -> None: provider = StubPolicyProvider( response=PolicyResponse( mode=EnforcementMode.AUDIT, policies="key: : invalid: : yaml" ) ) - - assert _load_from_provider(provider) is None + index = PolicyLoader(provider).load_policy_index() + assert index.total_rules == 0 -def test_load_from_provider_does_not_change_mode_when_none() -> None: +def test_loader_does_not_change_mode_when_response_mode_is_none() -> None: from uipath.runtime.governance.config import set_enforcement_mode set_enforcement_mode(EnforcementMode.ENFORCE) @@ -127,47 +125,13 @@ def test_load_from_provider_does_not_change_mode_when_none() -> None: response=PolicyResponse(mode=None, policies=SIMPLE_POLICY_YAML) ) - _load_from_provider(provider) + PolicyLoader(provider).load_policy_index() assert get_enforcement_mode() == EnforcementMode.ENFORCE # --------------------------------------------------------------------------- -# load_policy_index dispatch — registered provider vs empty fallback -# --------------------------------------------------------------------------- - - -def test_load_policy_index_uses_registered_provider() -> None: - provider = StubPolicyProvider( - response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) - ) - set_policy_provider(provider) - - index = load_policy_index() - - assert index.total_rules == 1 - assert provider.calls, "provider.get_policy was not called" - - -def test_load_policy_index_returns_empty_when_no_provider() -> None: - """No provider registered → empty PolicyIndex (no fallback path).""" - index = load_policy_index() - assert index.total_rules == 0 - - -def test_load_policy_index_empty_when_provider_yields_nothing() -> None: - provider = StubPolicyProvider( - response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") - ) - set_policy_provider(provider) - - index = load_policy_index() - - assert index.total_rules == 0 - - -# --------------------------------------------------------------------------- -# GovernanceRuntime +# GovernanceRuntime — passthroughs + loader wiring # --------------------------------------------------------------------------- @@ -180,101 +144,72 @@ def __init__(self) -> None: self.disposed = False self.schema_called = False - async def execute(self, input=None, options=None): + async def execute(self, input: Any = None, options: Any = None) -> Any: self.execute_calls.append((input, options)) return "result" - async def stream(self, input=None, options=None): + async def stream(self, input: Any = None, options: Any = None) -> Any: self.stream_calls.append((input, options)) for event in ("a", "b"): yield event - async def get_schema(self): + async def get_schema(self) -> Any: self.schema_called = True return "schema" - async def dispose(self): + async def dispose(self) -> None: self.disposed = True -def test_governance_runtime_registers_provider_and_prefetches( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Init wires provider into loader state and kicks off prefetch.""" +def test_governance_runtime_exposes_loader_bound_to_provider() -> None: + """The wrapper builds an instance-scoped PolicyLoader carrying the provider.""" provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) ) - # Spy on prefetch + set_policy_provider so we don't need a real - # background thread in the unit test. - prefetch_spy = MagicMock() - set_provider_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy - ) - - delegate = _StubDelegate() + runtime = GovernanceRuntime(_StubDelegate(), policy_provider=provider) - GovernanceRuntime(delegate, policy_provider=provider) + assert isinstance(runtime.loader, PolicyLoader) + assert runtime.loader._provider is provider - set_provider_spy.assert_called_once_with(provider) - prefetch_spy.assert_called_once_with() - -def test_governance_runtime_with_none_provider_still_prefetches( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Passing ``None`` registers None → loader yields an empty PolicyIndex.""" - prefetch_spy = MagicMock() - set_provider_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy +def test_governance_runtime_forwards_is_conversational_to_loader() -> None: + """The constructor's explicit ``is_conversational`` reaches PolicyContext.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + + runtime = GovernanceRuntime( + _StubDelegate(), policy_provider=provider, is_conversational=True ) + # Force the prefetch to land — load synchronously so we can read calls[0]. + runtime.loader.get_policy_index() - GovernanceRuntime(_StubDelegate(), policy_provider=None) + assert provider.calls, "provider.get_policy was never invoked" + assert provider.calls[0].is_conversational is True - set_provider_spy.assert_called_once_with(None) - prefetch_spy.assert_called_once_with() +def test_governance_runtime_loader_default_selector_is_none() -> None: + """Omitting ``is_conversational`` leaves the selector unset on PolicyContext.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) -def test_governance_runtime_skips_prefetch_when_ff_off( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """FF off → no provider registration, no prefetch.""" - from uipath.core.feature_flags import FeatureFlags + runtime = GovernanceRuntime(_StubDelegate(), policy_provider=provider) + runtime.loader.get_policy_index() - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + assert provider.calls[0].is_conversational is None - prefetch_spy = MagicMock() - set_provider_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy - ) - GovernanceRuntime(_StubDelegate(), policy_provider=StubPolicyProvider()) +def test_governance_runtime_with_none_provider_yields_empty_index() -> None: + """No provider → loader yields an empty PolicyIndex, no provider invocation.""" + runtime = GovernanceRuntime(_StubDelegate(), policy_provider=None) - assert not set_provider_spy.called - assert not prefetch_spy.called + index = runtime.loader.get_policy_index() + assert index.total_rules == 0 -async def test_governance_runtime_execute_delegates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() - ) +async def test_governance_runtime_execute_delegates() -> None: delegate = _StubDelegate() runtime = GovernanceRuntime(delegate, policy_provider=None) @@ -284,15 +219,7 @@ async def test_governance_runtime_execute_delegates( assert delegate.execute_calls == [({"x": 1}, None)] -async def test_governance_runtime_stream_delegates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() - ) +async def test_governance_runtime_stream_delegates() -> None: delegate = _StubDelegate() runtime = GovernanceRuntime(delegate, policy_provider=None) @@ -302,15 +229,7 @@ async def test_governance_runtime_stream_delegates( assert delegate.stream_calls == [({"x": 1}, None)] -async def test_governance_runtime_schema_and_dispose_delegate( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() - ) +async def test_governance_runtime_schema_and_dispose_delegate() -> None: delegate = _StubDelegate() runtime = GovernanceRuntime(delegate, policy_provider=None) @@ -318,143 +237,3 @@ async def test_governance_runtime_schema_and_dispose_delegate( await runtime.dispose() assert delegate.schema_called assert delegate.disposed - - -# --------------------------------------------------------------------------- -# _extract_is_conversational -# --------------------------------------------------------------------------- - - -def test_extract_is_conversational_true_from_agent_definition() -> None: - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=True) - ) - assert _extract_is_conversational(delegate) is True - - -def test_extract_is_conversational_false_from_agent_definition() -> None: - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=False) - ) - assert _extract_is_conversational(delegate) is False - - -def test_extract_is_conversational_returns_none_when_unreachable() -> None: - """No ``_agent_definition`` anywhere on the chain → ``None`` (let the provider default).""" - assert _extract_is_conversational(SimpleNamespace()) is None - - -def test_extract_is_conversational_returns_none_when_field_is_none() -> None: - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=None) - ) - assert _extract_is_conversational(delegate) is None - - -def test_extract_is_conversational_unwraps_via_underscore_delegate() -> None: - inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) - outer = SimpleNamespace(_delegate=inner) - assert _extract_is_conversational(outer) is True - - -def test_extract_is_conversational_unwraps_via_delegate_attr() -> None: - inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) - outer = SimpleNamespace(delegate=inner) - assert _extract_is_conversational(outer) is False - - -def test_extract_is_conversational_depth_capped() -> None: - """A pathological self-referential wrapper can't loop forever.""" - self_ref = SimpleNamespace() - self_ref._delegate = self_ref # type: ignore[attr-defined] - assert _extract_is_conversational(self_ref) is None - - -# --------------------------------------------------------------------------- -# GovernanceRuntime wires the selector -# --------------------------------------------------------------------------- - - -def test_governance_runtime_sets_agent_type_from_delegate() -> None: - """Init reads ``delegate._agent_definition.is_conversational`` and writes the selector.""" - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=True), - execute=_StubDelegate().execute, - stream=_StubDelegate().stream, - get_schema=_StubDelegate().get_schema, - dispose=_StubDelegate().dispose, - ) - - # Don't run the real prefetch thread — just confirm the selector - # ended up where the provider would read it. - GovernanceRuntime(delegate, policy_provider=None) - - assert loader._agent_is_conversational is True - - -def test_governance_runtime_sets_none_when_agent_definition_missing() -> None: - """No ``_agent_definition`` → selector stays unset (``None``).""" - GovernanceRuntime(_StubDelegate(), policy_provider=None) - assert loader._agent_is_conversational is None - - -def test_governance_runtime_preserves_externally_set_selector_on_extraction_miss() -> None: - """Externally-set selector survives a runtime init that finds no ``_agent_definition``. - - Regression: previously ``__init__`` unconditionally wrote whatever - ``_extract_is_conversational`` returned, so an extraction miss - (``None``) silently clobbered a value an integration had pre-seeded. - """ - set_agent_conversational(True) - GovernanceRuntime(_StubDelegate(), policy_provider=None) - assert loader._agent_is_conversational is True - - -def test_governance_runtime_fails_open_when_extraction_raises( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A pathological delegate accessor raising mid-extraction can't break init.""" - monkeypatch.setattr( - "uipath.runtime.governance.runtime._extract_is_conversational", - MagicMock(side_effect=RuntimeError("boom")), - ) - set_provider_spy = MagicMock() - prefetch_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy - ) - - # No exception escapes; the rest of init still runs. - GovernanceRuntime(_StubDelegate(), policy_provider=None) - - set_provider_spy.assert_called_once_with(None) - prefetch_spy.assert_called_once_with() - - -def test_governance_runtime_skips_extraction_when_ff_off( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """FF off → no selector write, no provider registration, no prefetch.""" - from uipath.core.feature_flags import FeatureFlags - - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - - extract_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime._extract_is_conversational", extract_spy - ) - - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=True), - execute=_StubDelegate().execute, - stream=_StubDelegate().stream, - get_schema=_StubDelegate().get_schema, - dispose=_StubDelegate().dispose, - ) - GovernanceRuntime(delegate, policy_provider=None) - - assert not extract_spy.called - assert loader._agent_is_conversational is None diff --git a/tests/test_loader.py b/tests/test_loader.py index 23df2d6..4823014 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,11 +1,15 @@ -"""Tests for the policy loader module. - -Provider-only world: the loader fetches policies exclusively through a -registered :class:`GovernancePolicyProvider`. Tests here cover the -caching, FF-gate, prefetch coordination, and fallback-to-empty behavior -that's independent of any specific provider. End-to-end provider -plumbing (mode application, YAML parsing, runtime wrapper integration) -lives in :mod:`tests.test_governance_runtime`. +"""Tests for the policy loader. + +Provider-only world: each :class:`PolicyLoader` is instance-scoped and +bound to one :class:`GovernancePolicyProvider`. Tests here cover the +caching, prefetch coordination, and fallback-to-empty behavior +independent of any specific provider. End-to-end provider plumbing +(mode application, YAML parsing, runtime wrapper integration) lives in +:mod:`tests.test_governance_runtime`. + +The loader no longer reads the governance feature flag — deciding +whether governance attaches at all is the wiring layer's concern, not +the loader's. """ from __future__ import annotations @@ -23,16 +27,8 @@ ) from tests._helpers import StubPolicyProvider, reset_enforcement_mode -from uipath.runtime.governance.native import loader -from uipath.runtime.governance.native.loader import ( - _empty_index_reason, - clear_policy_cache, - get_available_packs, - get_policy_index, - load_policy_index, - prefetch_policy_index, - set_policy_provider, -) +from uipath.runtime.governance.native import loader as loader_mod +from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.governance.native.models import PolicyIndex SIMPLE_POLICY_YAML = """ @@ -48,60 +44,48 @@ def _ok_response() -> PolicyResponse: - return PolicyResponse( - mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML - ) + return PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) @pytest.fixture(autouse=True) -def _clean_loader_state(): - """Each test starts with a fresh loader cache and FF on.""" - from uipath.core.feature_flags import FeatureFlags - - clear_policy_cache() +def _clean_enforcement_mode() -> Any: + """Each test starts from a clean enforcement-mode slate.""" reset_enforcement_mode() - set_policy_provider(None) - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) yield - clear_policy_cache() reset_enforcement_mode() - set_policy_provider(None) - FeatureFlags.reset_flags() # --------------------------------------------------------------------------- -# _empty_index_reason +# _empty_index_reason — diagnostic string for the "no policies" log # --------------------------------------------------------------------------- def test_empty_index_reason_no_provider() -> None: - msg = _empty_index_reason() + msg = PolicyLoader(None)._empty_index_reason() assert "no policy provider" in msg def test_empty_index_reason_with_provider() -> None: - set_policy_provider(StubPolicyProvider(response=_ok_response())) - msg = _empty_index_reason() + msg = PolicyLoader(StubPolicyProvider(response=_ok_response()))._empty_index_reason() assert "provider returned no policies" in msg # --------------------------------------------------------------------------- -# load_policy_index — public entry +# load_policy_index — synchronous entry point # --------------------------------------------------------------------------- def test_load_policy_index_empty_when_no_provider() -> None: - """No provider registered → empty PolicyIndex.""" - index = load_policy_index() + """No provider supplied → empty PolicyIndex.""" + index = PolicyLoader(None).load_policy_index() assert isinstance(index, PolicyIndex) assert index.total_rules == 0 -def test_load_policy_index_uses_registered_provider() -> None: +def test_load_policy_index_uses_provider() -> None: provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) - index = load_policy_index() + index = PolicyLoader(provider).load_policy_index() assert isinstance(index, PolicyIndex) assert "test-pack" in index.pack_names @@ -109,54 +93,56 @@ def test_load_policy_index_uses_registered_provider() -> None: def test_load_policy_index_returns_empty_when_provider_raises() -> None: - set_policy_provider(StubPolicyProvider(raises=RuntimeError("boom"))) - index = load_policy_index() + provider = StubPolicyProvider(raises=RuntimeError("boom")) + index = PolicyLoader(provider).load_policy_index() assert index.total_rules == 0 # --------------------------------------------------------------------------- -# get_policy_index — caching + FF gate +# get_policy_index — caching # --------------------------------------------------------------------------- def test_get_policy_index_caches_after_first_call() -> None: """A second call returns the cached index without re-invoking the provider.""" provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) + loader = PolicyLoader(provider) - a = get_policy_index() - b = get_policy_index() + a = loader.get_policy_index() + b = loader.get_policy_index() assert a is b assert len(provider.calls) == 1 -def test_get_policy_index_short_circuits_when_ff_off() -> None: - """FF off → return an empty index without invoking the provider.""" - from uipath.core.feature_flags import FeatureFlags - - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) - - index = get_policy_index() - - assert index.total_rules == 0 - assert provider.calls == [] - - def test_get_policy_index_sync_load_when_no_prefetch() -> None: """Without a prefetch in flight, get_policy_index synchronously loads.""" - set_policy_provider(StubPolicyProvider(response=_ok_response())) - index = get_policy_index() + loader = PolicyLoader(StubPolicyProvider(response=_ok_response())) + index = loader.get_policy_index() assert index.total_rules == 1 +def test_get_policy_index_empty_with_no_provider() -> None: + """No provider supplied → cached empty index, provider never invoked.""" + loader = PolicyLoader(None) + a = loader.get_policy_index() + b = loader.get_policy_index() + assert a is b + assert a.total_rules == 0 + + # --------------------------------------------------------------------------- # Prefetch — idempotency + completion + timeout # --------------------------------------------------------------------------- +def test_prefetch_no_op_when_provider_is_none() -> None: + """No provider → prefetch is a no-op (no thread, no event).""" + loader = PolicyLoader(None) + loader.prefetch() + assert loader._prefetch_event is None + + def test_prefetch_is_idempotent() -> None: """Second call while first is in flight is a no-op (no second thread).""" block = threading.Event() @@ -166,38 +152,24 @@ def _slow_get(context: PolicyContext) -> PolicyResponse: return _ok_response() provider: Any = type("P", (), {"get_policy": staticmethod(_slow_get)})() - set_policy_provider(provider) + loader = PolicyLoader(provider) - prefetch_policy_index() + loader.prefetch() first_event = loader._prefetch_event - prefetch_policy_index() + loader.prefetch() assert loader._prefetch_event is first_event block.set() if first_event is not None: first_event.wait(timeout=2.0) -def test_prefetch_skipped_when_ff_off() -> None: - """FF off → no prefetch thread started.""" - from uipath.core.feature_flags import FeatureFlags - - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) - - prefetch_policy_index() - - assert provider.calls == [] - assert loader._prefetch_event is None - - def test_prefetch_no_op_when_index_already_loaded() -> None: """If the index is already cached, prefetch is a no-op.""" provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) - get_policy_index() # populate the cache + loader = PolicyLoader(provider) + loader.get_policy_index() # populate the cache - prefetch_policy_index() + loader.prefetch() assert len(provider.calls) == 1 @@ -213,27 +185,32 @@ def _fetch(context: PolicyContext) -> PolicyResponse: return _ok_response() provider: Any = type("P", (), {"get_policy": staticmethod(_fetch)})() - set_policy_provider(provider) + loader = PolicyLoader(provider) - prefetch_policy_index() + loader.prefetch() assert started.wait(timeout=2.0) threading.Thread( target=lambda: (time.sleep(0.05), release.set()), daemon=True ).start() - index = get_policy_index() + index = loader.get_policy_index() assert index.total_rules == 1 -def test_get_policy_index_logs_when_prefetch_completes_with_empty_index( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The 'completed but produced no PolicyIndex' branch fires on provider failure.""" +def test_get_policy_index_logs_when_prefetch_completes_with_empty_index() -> None: + """The 'completed but produced no PolicyIndex' branch fires on provider failure. + + Manually wire a completed event without populating ``_policy_index`` — + simulates a prefetch worker that hit an unexpected error after the + event was claimed but before the index was set. + """ + loader = PolicyLoader(StubPolicyProvider(response=_ok_response())) event = threading.Event() - event.set() # prefetch already completed - monkeypatch.setattr(loader, "_prefetch_event", event) - # _policy_index stays None — simulating "prefetch completed but produced nothing" - with patch.object(loader.logger, "warning") as mock_warning: - index = get_policy_index() + event.set() + loader._prefetch_event = event + + with patch.object(loader_mod.logger, "warning") as mock_warning: + index = loader.get_policy_index() + assert index.total_rules == 0 assert any( "completed but produced no PolicyIndex" in str(call.args[0]) @@ -242,28 +219,95 @@ def test_get_policy_index_logs_when_prefetch_completes_with_empty_index( # --------------------------------------------------------------------------- -# get_available_packs / clear_policy_cache +# available_packs / clear_cache # --------------------------------------------------------------------------- -def test_get_available_packs_before_load_returns_empty() -> None: - assert get_available_packs() == [] +def test_available_packs_before_load_returns_empty() -> None: + assert PolicyLoader(None).available_packs == [] -def test_get_available_packs_after_load() -> None: - set_policy_provider(StubPolicyProvider(response=_ok_response())) - get_policy_index() - assert "test-pack" in get_available_packs() +def test_available_packs_after_load() -> None: + loader = PolicyLoader(StubPolicyProvider(response=_ok_response())) + loader.get_policy_index() + assert "test-pack" in loader.available_packs -def test_clear_policy_cache_forces_refetch() -> None: +def test_clear_cache_forces_refetch() -> None: provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) + loader = PolicyLoader(provider) - get_policy_index() - clear_policy_cache() - get_policy_index() + loader.get_policy_index() + loader.clear_cache() + loader.get_policy_index() assert len(provider.calls) == 2 +def test_clear_cache_drops_in_flight_worker_result() -> None: + """A worker spawned before ``clear_cache`` must not clobber state after it. + + The race: ``prefetch()`` starts a worker, ``clear_cache()`` retires + the prefetch event, then the worker finishes and (incorrectly, + before the fix) writes its loaded index back over the cleared + cache. With the fix the worker checks ``_prefetch_event is event`` + before publishing and discards its result when orphaned. + """ + block = threading.Event() + + def _slow_get(context: PolicyContext) -> PolicyResponse: + block.wait(timeout=2.0) + return _ok_response() + + provider: Any = type("P", (), {"get_policy": staticmethod(_slow_get)})() + loader = PolicyLoader(provider) + + loader.prefetch() + captured_event = loader._prefetch_event + assert captured_event is not None # prefetch actually started + + # Retire the in-flight worker. + loader.clear_cache() + assert loader._policy_index is None + assert loader._prefetch_event is None + + # Release the worker; let it finish and try to publish. + block.set() + assert captured_event.wait(timeout=2.0) + + # The orphan worker's result must NOT land in the cache. + assert loader._policy_index is None + + +# --------------------------------------------------------------------------- +# Cross-instance isolation — the whole point of instance-scoped state +# --------------------------------------------------------------------------- + + +def test_two_loaders_do_not_share_cache() -> None: + """Concurrent loaders maintain independent caches. + + ``uipath eval`` runs multiple runtimes in parallel; each gets its + own loader and must not leak its cached PolicyIndex into the next. + """ + p1 = StubPolicyProvider(response=_ok_response()) + p2 = StubPolicyProvider(response=_ok_response()) + l1 = PolicyLoader(p1) + l2 = PolicyLoader(p2) + + l1.get_policy_index() + l2.get_policy_index() + + assert len(p1.calls) == 1 + assert len(p2.calls) == 1 + + +def test_two_loaders_carry_independent_conversational_selectors() -> None: + """Each loader threads its own selector into PolicyContext.""" + p1 = StubPolicyProvider(response=_ok_response()) + p2 = StubPolicyProvider(response=_ok_response()) + PolicyLoader(p1, is_conversational=True).load_policy_index() + PolicyLoader(p2, is_conversational=False).load_policy_index() + + assert p1.calls[0].is_conversational is True + assert p2.calls[0].is_conversational is False From 6b0d951806b7e11fa40e0139f2c960e716825415 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Wed, 24 Jun 2026 14:58:02 +0530 Subject: [PATCH 7/7] refactor(governance): instance-scope enforcement mode on PolicyLoader Addresses radu's follow-up on PR #121 (discussion r3465934815): the enforcement mode was still process-level scoped via config._state, defeating the point of the loader instance-scoping when uipath eval runs parallel runtimes with mixed-mode policies. - PolicyLoader now owns _enforcement_mode and exposes it via the enforcement_mode property (defaults to AUDIT when no provider response has supplied a mode) - _load_from_provider writes the instance field instead of calling the global set_enforcement_mode - config.py deleted entirely: _state / _EnforcementModeState / get_enforcement_mode / set_enforcement_mode are gone. No production consumers outside the loader; canonical EnforcementMode lives in uipath.core.governance Tests: - _helpers.reset_enforcement_mode dropped (no global to reset) - test_enforcement_mode_default rewritten around PolicyLoader.enforcement_mode; new test_two_loaders_carry_independent_enforcement_modes pins the cross-instance isolation invariant - test_governance_runtime / test_loader drop the reset fixture and the get/set imports; mode-persistence test exercises two consecutive loads on a single loader 188 passed, ruff/mypy/bandit clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uipath/runtime/governance/config.py | 60 -------- .../runtime/governance/native/loader.py | 33 ++++- tests/_helpers.py | 20 +-- tests/conftest.py | 9 +- tests/test_enforcement_mode_default.py | 140 ++++++++++++------ tests/test_governance_runtime.py | 36 ++--- tests/test_loader.py | 10 +- 7 files changed, 158 insertions(+), 150 deletions(-) delete mode 100644 src/uipath/runtime/governance/config.py diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py deleted file mode 100644 index d766dfd..0000000 --- a/src/uipath/runtime/governance/config.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Runtime-level governance enforcement-mode state. - -The feature-flag gate (``is_governance_enabled``) lives in -:mod:`uipath.core.governance.config` because it is process-level and -must be resolvable by callers that do not depend on -``uipath-runtime``. The enforcement mode is *per-policy* — -provider-supplied on each policy load — and therefore lives here in -the runtime package alongside the policy loader that applies it via -:func:`set_enforcement_mode`. -""" - -from __future__ import annotations - -# ``EnforcementMode`` is the shared governance value type; it's defined in -# uipath.core.governance (a lower abstraction level) and re-exported here so -# runtime callers keep a single import site. The per-process mode *state* -# below is runtime-owned and applied by the policy loader. -from uipath.core.governance import EnforcementMode as EnforcementMode - - -class _EnforcementModeState: - """Holds the active enforcement mode. - - A single module-level instance backs the get/set/reset helpers, so the - mode is updated by mutating an attribute rather than rebinding a module - global. ``mode is None`` means "no provider has supplied a mode yet" — - until then (and if the provider omits a mode) governance defaults to - AUDIT. - """ - - def __init__(self) -> None: - self.mode: EnforcementMode | None = None - - -# The enforcement mode is supplied by the policy provider on each load; -# the loader applies it via :func:`set_enforcement_mode`. -_state = _EnforcementModeState() - - -def get_enforcement_mode() -> EnforcementMode: - """Return the current enforcement mode. - - The canonical source is whatever the policy provider supplied on - the most recent load, applied via :func:`set_enforcement_mode`. - Until that load lands (or if the provider returns no mode), the - default is :attr:`EnforcementMode.AUDIT` — evaluate and log without - blocking. Defaulting to AUDIT avoids the chicken-and-egg where a - DISABLED default would short-circuit evaluation before the - background policy load could ever opt the tenant in. - """ - return _state.mode if _state.mode is not None else EnforcementMode.AUDIT - - -def set_enforcement_mode(mode: EnforcementMode) -> None: - """Set the enforcement mode programmatically. - - The policy loader calls this with the provider-supplied mode on - each load so the evaluator picks up the platform-controlled value. - """ - _state.mode = mode diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index a59d96f..5b45d21 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -22,9 +22,12 @@ from collections import Counter import yaml -from uipath.core.governance import GovernancePolicyProvider, PolicyContext +from uipath.core.governance import ( + EnforcementMode, + GovernancePolicyProvider, + PolicyContext, +) -from uipath.runtime.governance.config import set_enforcement_mode from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml from uipath.runtime.governance.native.models import PolicyIndex @@ -75,6 +78,12 @@ def __init__( self._provider = provider self._is_conversational = is_conversational self._policy_index: PolicyIndex | None = None + # Enforcement mode supplied by the provider on the most recent + # load. ``None`` until the first load lands (or whenever the + # provider omits a mode); :attr:`enforcement_mode` returns + # ``AUDIT`` in that case. Instance-scoped so parallel runtimes + # (e.g. ``uipath eval``) don't clobber each other. + self._enforcement_mode: EnforcementMode | None = None # ``_prefetch_event`` is set once the background load finishes # (success OR failure); callers of ``get_policy_index`` wait on # it. ``_prefetch_lock`` guards the start-once semantics so @@ -244,7 +253,7 @@ def _load_from_provider( return None if response.mode is not None: - set_enforcement_mode(response.mode) + self._enforcement_mode = response.mode logger.info("Enforcement mode set from provider: %s", response.mode.value) if not response.policies: @@ -292,6 +301,24 @@ def _log_index_summary(self, index: PolicyIndex) -> None: dict(hook_counts), ) + @property + def enforcement_mode(self) -> EnforcementMode: + """Active enforcement mode for this loader. + + The canonical source is whatever the policy provider supplied on + the most recent load. Until that load lands (or if the provider + omits a mode), the default is :attr:`EnforcementMode.AUDIT` — + evaluate and log without blocking. Defaulting to AUDIT avoids + the chicken-and-egg where a DISABLED default would short-circuit + evaluation before the background load could ever opt the tenant + in. + """ + return ( + self._enforcement_mode + if self._enforcement_mode is not None + else EnforcementMode.AUDIT + ) + @property def available_packs(self) -> list[str]: """Pack names from the currently loaded policy index. diff --git a/tests/_helpers.py b/tests/_helpers.py index c7dbbd8..2d3d924 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,8 +1,11 @@ """Shared test-only helpers. -Keeps test concerns out of the production governance package: per-test -isolation utilities and shared stubs live here rather than inside the -production modules. +Keeps test concerns out of the production governance package: shared +stubs live here rather than inside the production modules. + +The enforcement-mode reset helper is gone because the mode is now +instance-scoped on :class:`PolicyLoader` — tests that want a clean +slate just construct a fresh loader instead of touching a global. """ from __future__ import annotations @@ -11,17 +14,6 @@ from uipath.core.governance import PolicyContext, PolicyResponse -from uipath.runtime.governance import config - - -def reset_enforcement_mode() -> None: - """Clear the process-wide enforcement mode so the AUDIT default re-applies. - - Test isolation only — production code never resets the mode; the policy - loader sets it from the provider-supplied :class:`PolicyResponse`. - """ - config._state.mode = None - class StubPolicyProvider: """Minimal in-memory :class:`GovernancePolicyProvider` for tests. diff --git a/tests/conftest.py b/tests/conftest.py index 01e5dc9..ba76eca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,8 +19,7 @@ def temp_dir() -> Generator[str, None, None]: yield tmp_dir -# The loader no longer keeps provider / selector at module scope — -# state is owned by each :class:`PolicyLoader` instance — so no -# autouse cross-test reset is needed. Tests that share enforcement -# mode call :func:`reset_enforcement_mode` from ``tests._helpers`` -# directly. +# Governance state — provider, conversational selector, policy cache, +# enforcement mode — is owned by each :class:`PolicyLoader` instance, +# so no autouse cross-test reset is needed. Tests that want a clean +# slate just construct a fresh loader. diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py index 5159c78..78230fd 100644 --- a/tests/test_enforcement_mode_default.py +++ b/tests/test_enforcement_mode_default.py @@ -1,60 +1,114 @@ -"""Tests for the default enforcement-mode resolution. +"""Tests for the default enforcement-mode resolution on :class:`PolicyLoader`. The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at runtime construction and the background policy load can run. If the -provider later returns ``disabled``, ``set_enforcement_mode`` flips -the mode and ``evaluate()`` short-circuits per-call. +provider later returns ``disabled``, the loader records it and +:attr:`enforcement_mode` flips. -Resolution (per :func:`get_enforcement_mode`): -1. The provider-supplied value applied via ``set_enforcement_mode`` by - the policy loader. -2. Default ``AUDIT``. +Resolution (per :attr:`PolicyLoader.enforcement_mode`): +1. The provider-supplied value on the most recent load. +2. Default :attr:`EnforcementMode.AUDIT`. """ from __future__ import annotations -import pytest +from uipath.core.governance import EnforcementMode, PolicyResponse -from tests._helpers import reset_enforcement_mode -from uipath.runtime.governance.config import ( - EnforcementMode, - get_enforcement_mode, - set_enforcement_mode, -) - - -@pytest.fixture(autouse=True) -def _isolate_mode(): - """Each test starts from a clean module-state slate.""" - reset_enforcement_mode() - yield - reset_enforcement_mode() +from tests._helpers import StubPolicyProvider +from uipath.runtime.governance.native.loader import PolicyLoader def test_default_mode_is_audit() -> None: - """No backend-supplied mode → AUDIT. + """No provider-supplied mode yet → AUDIT. AUDIT is the default so the wrapper attaches and the background policy fetch can run. The backend can flip the mode to DISABLED on fetch when the tenant has no policies. """ - assert get_enforcement_mode() is EnforcementMode.AUDIT - - -def test_backend_disabled_wins_over_default() -> None: - """The backend mode (via ``set_enforcement_mode``) overrides the default.""" - set_enforcement_mode(EnforcementMode.DISABLED) - assert get_enforcement_mode() is EnforcementMode.DISABLED - - -def test_backend_enforce_wins_over_default() -> None: - set_enforcement_mode(EnforcementMode.ENFORCE) - assert get_enforcement_mode() is EnforcementMode.ENFORCE - - -def test_reset_returns_to_default() -> None: - """``reset_enforcement_mode`` clears the mode so the default re-applies.""" - set_enforcement_mode(EnforcementMode.ENFORCE) - assert get_enforcement_mode() is EnforcementMode.ENFORCE - reset_enforcement_mode() - assert get_enforcement_mode() is EnforcementMode.AUDIT \ No newline at end of file + loader = PolicyLoader(None) + assert loader.enforcement_mode is EnforcementMode.AUDIT + + +def test_provider_disabled_wins_over_default() -> None: + """A provider supplying DISABLED overrides the AUDIT default.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.DISABLED, policies="") + ) + loader = PolicyLoader(provider) + loader.load_policy_index() + assert loader.enforcement_mode is EnforcementMode.DISABLED + + +def test_provider_enforce_wins_over_default() -> None: + """A provider supplying ENFORCE flips the loader to enforce.""" + provider = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.ENFORCE, + policies="standard: p\nrules: [{id: r1, hook: before_model, " + "checks: [{type: regex, patterns: ['x']}]}]\n", + ) + ) + loader = PolicyLoader(provider) + loader.load_policy_index() + assert loader.enforcement_mode is EnforcementMode.ENFORCE + + +def test_loader_with_none_mode_response_keeps_previous_value() -> None: + """Provider returning ``mode=None`` doesn't clobber a previously-set mode. + + The wire response model treats ``None`` as "no opinion" — the loader + must not overwrite a real value with it. Otherwise a transient + provider response could silently demote a tenant's enforcement + posture. + """ + p1 = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.ENFORCE, + policies="standard: p\nrules: [{id: r1, hook: before_model, " + "checks: [{type: regex, patterns: ['x']}]}]\n", + ) + ) + loader = PolicyLoader(p1) + loader.load_policy_index() + assert loader.enforcement_mode is EnforcementMode.ENFORCE + + # A second provider response that omits mode should not flip back to AUDIT. + loader._provider = StubPolicyProvider( + response=PolicyResponse( + mode=None, + policies="standard: p\nrules: [{id: r1, hook: before_model, " + "checks: [{type: regex, patterns: ['x']}]}]\n", + ) + ) + loader.clear_cache() + loader.load_policy_index() + assert loader.enforcement_mode is EnforcementMode.ENFORCE + + +def test_two_loaders_carry_independent_enforcement_modes() -> None: + """The whole point of the refactor: parallel loaders don't share mode. + + Previously :func:`set_enforcement_mode` wrote a module global, so an + ENFORCE-mode loader and a DISABLED-mode loader running concurrently + in the same process clobbered each other (last writer wins). + Instance-scoped mode means each loader's mode is read-isolated. + """ + p_enforce = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.ENFORCE, + policies="standard: e\nrules: [{id: r1, hook: before_model, " + "checks: [{type: regex, patterns: ['x']}]}]\n", + ) + ) + p_disabled = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.DISABLED, policies="") + ) + + enforce_loader = PolicyLoader(p_enforce) + disabled_loader = PolicyLoader(p_disabled) + + enforce_loader.load_policy_index() + disabled_loader.load_policy_index() + + assert enforce_loader.enforcement_mode is EnforcementMode.ENFORCE + assert disabled_loader.enforcement_mode is EnforcementMode.DISABLED diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py index 91fca09..810a881 100644 --- a/tests/test_governance_runtime.py +++ b/tests/test_governance_runtime.py @@ -11,14 +11,12 @@ from typing import Any -import pytest from uipath.core.governance import ( EnforcementMode, PolicyResponse, ) -from tests._helpers import StubPolicyProvider, reset_enforcement_mode -from uipath.runtime.governance.config import get_enforcement_mode +from tests._helpers import StubPolicyProvider from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.governance.native.models import PolicyIndex from uipath.runtime.governance.runtime import GovernanceRuntime @@ -35,12 +33,8 @@ """ -@pytest.fixture(autouse=True) -def _reset_mode() -> Any: - """Each test starts with a clean enforcement-mode slate.""" - reset_enforcement_mode() - yield - reset_enforcement_mode() +# Each test constructs a fresh ``PolicyLoader`` / ``GovernanceRuntime`` +# — no module-level state to reset. # --------------------------------------------------------------------------- @@ -53,12 +47,13 @@ def test_loader_builds_index_and_applies_mode() -> None: response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) ) - index = PolicyLoader(provider).load_policy_index() + loader = PolicyLoader(provider) + index = loader.load_policy_index() assert isinstance(index, PolicyIndex) assert index.total_rules == 1 assert "provider-pack" in index.pack_names - assert get_enforcement_mode() == EnforcementMode.ENFORCE + assert loader.enforcement_mode == EnforcementMode.ENFORCE def test_loader_passes_is_conversational_in_context() -> None: @@ -118,16 +113,23 @@ def test_loader_returns_empty_on_malformed_yaml() -> None: def test_loader_does_not_change_mode_when_response_mode_is_none() -> None: - from uipath.runtime.governance.config import set_enforcement_mode + """Provider returning ``mode=None`` doesn't clobber a previously-set mode.""" + p1 = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) + ) + loader = PolicyLoader(p1) + loader.load_policy_index() + assert loader.enforcement_mode == EnforcementMode.ENFORCE - set_enforcement_mode(EnforcementMode.ENFORCE) - provider = StubPolicyProvider( + # Next load via a different provider that returns mode=None must not + # demote the loader's mode back to AUDIT. + loader._provider = StubPolicyProvider( response=PolicyResponse(mode=None, policies=SIMPLE_POLICY_YAML) ) + loader.clear_cache() + loader.load_policy_index() - PolicyLoader(provider).load_policy_index() - - assert get_enforcement_mode() == EnforcementMode.ENFORCE + assert loader.enforcement_mode == EnforcementMode.ENFORCE # --------------------------------------------------------------------------- diff --git a/tests/test_loader.py b/tests/test_loader.py index 4823014..87e453b 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -19,14 +19,13 @@ from typing import Any from unittest.mock import patch -import pytest from uipath.core.governance import ( EnforcementMode, PolicyContext, PolicyResponse, ) -from tests._helpers import StubPolicyProvider, reset_enforcement_mode +from tests._helpers import StubPolicyProvider from uipath.runtime.governance.native import loader as loader_mod from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.governance.native.models import PolicyIndex @@ -47,12 +46,7 @@ def _ok_response() -> PolicyResponse: return PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) -@pytest.fixture(autouse=True) -def _clean_enforcement_mode() -> Any: - """Each test starts from a clean enforcement-mode slate.""" - reset_enforcement_mode() - yield - reset_enforcement_mode() +# Each test constructs a fresh ``PolicyLoader`` — no shared state to reset. # ---------------------------------------------------------------------------