From 31702f13c413581de8afce19884ad96d18aa522d Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Thu, 25 Jun 2026 15:15:35 +0530 Subject: [PATCH 1/2] refactor(governance): hoist policy fetch to host; drop PolicyLoader GovernanceRuntime now takes a resolved PolicyIndex + EnforcementMode at construction. The host (uipath CLI) does the async fetch via the GovernancePolicyProvider, compiles the YAML through build_policy_index_from_yaml, and hands the snapshot in. The runtime becomes a passive consumer; the host owns lifecycle. - Delete PolicyLoader (343 LOC) and its hand-rolled future (threading.Thread + Event). Async I/O belongs to the async host. - Delete StubPolicyProvider test helper + enforcement-mode-default tests (the mode is now a constructor arg, no default needed). - GovernanceRuntime ctor: (delegate, policy_index, enforcement_mode, *, trace_id=None). No more policy_provider / is_conversational parameters. Agent-type selection lives in the host's PolicyContext construction. - Expose build_policy_index_from_yaml from native/__init__.py for the host's compile step. Net: -890 LOC. Addresses architecture-review item Sec 2.4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runtime/governance/native/__init__.py | 15 +- .../runtime/governance/native/evaluator.py | 8 +- .../runtime/governance/native/loader.py | 342 ------------------ src/uipath/runtime/governance/runtime.py | 134 +++---- tests/_helpers.py | 46 --- tests/conftest.py | 8 +- tests/test_enforcement_mode_default.py | 114 ------ tests/test_governance_runtime.py | 206 +++-------- tests/test_loader.py | 307 ---------------- tests/test_traces_severity.py | 7 +- 10 files changed, 150 insertions(+), 1037 deletions(-) delete mode 100644 src/uipath/runtime/governance/native/loader.py delete mode 100644 tests/_helpers.py delete mode 100644 tests/test_enforcement_mode_default.py delete mode 100644 tests/test_loader.py diff --git a/src/uipath/runtime/governance/native/__init__.py b/src/uipath/runtime/governance/native/__init__.py index 91e859e..713a05d 100644 --- a/src/uipath/runtime/governance/native/__init__.py +++ b/src/uipath/runtime/governance/native/__init__.py @@ -1,14 +1,17 @@ """Native UiPath governance policy evaluator. YAML-defined rules evaluated in-process at each agent lifecycle hook. -Reads policies through a :class:`GovernancePolicyProvider` (the provider -owns the wire transport) and runs the deterministic detectors backing -ISO 42001 controls. +The host fetches the policy pack via the +:class:`GovernancePolicyProvider` protocol and compiles it into a +:class:`PolicyIndex` with :func:`build_policy_index_from_yaml` *before* +constructing :class:`GovernanceRuntime` — so the runtime layer never +performs I/O at construction time. This subpackage owns: - :class:`GovernanceEvaluator` – the evaluator implementation. -- :class:`PolicyLoader` – the instance-scoped policy cache + prefetch. +- :func:`build_policy_index_from_yaml` – pure YAML → :class:`PolicyIndex` + compiler. - The native policy model: :class:`Rule`, :class:`Check`, :class:`Condition`, :class:`PolicyIndex`. @@ -16,8 +19,8 @@ :mod:`uipath.core.governance`. """ +from ._yaml_to_index import build_policy_index_from_yaml from .evaluator import GovernanceEvaluator -from .loader import PolicyLoader from .models import ( Check, CheckContext, @@ -30,7 +33,7 @@ __all__ = [ "GovernanceEvaluator", - "PolicyLoader", + "build_policy_index_from_yaml", # Native policy model "Check", "CheckContext", diff --git a/src/uipath/runtime/governance/native/evaluator.py b/src/uipath/runtime/governance/native/evaluator.py index 83e7ae0..cc798a8 100644 --- a/src/uipath/runtime/governance/native/evaluator.py +++ b/src/uipath/runtime/governance/native/evaluator.py @@ -291,12 +291,14 @@ def __init__( Args: policy_index: The compiled :class:`PolicyIndex` to evaluate. - Typically sourced from the owning runtime's - :class:`PolicyLoader`. + Typically read from :attr:`GovernanceRuntime.policy_index` + — the host built it from the provider's + :class:`PolicyResponse` via + :func:`build_policy_index_from_yaml`. enforcement_mode: Mode the evaluator applies. Defaults to ``AUDIT`` — the safe default for callers that don't explicitly opt in to ENFORCE. The wiring layer should - pass ``policy_loader.enforcement_mode`` here so the + pass ``runtime.enforcement_mode`` here so the evaluator and loader agree on a single source of truth. audit_manager: Per-runtime :class:`AuditManager`. When ``None`` the evaluator runs silently (no audit events diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py deleted file mode 100644 index 5b45d21..0000000 --- a/src/uipath/runtime/governance/native/loader.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Policy pack loader. - -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 - -import logging -import threading -import time -from collections import Counter - -import yaml -from uipath.core.governance import ( - EnforcementMode, - GovernancePolicyProvider, - PolicyContext, -) - -from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml -from uipath.runtime.governance.native.models import PolicyIndex - -logger = logging.getLogger(__name__) - - -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. - - Typical lifecycle:: - - loader = PolicyLoader(provider, is_conversational=False) - loader.prefetch() # non-blocking, optional - index = loader.get_policy_index() # cached after first call - - When ``provider`` is ``None``, every load returns an empty - PolicyIndex without invoking anything. - """ - - # 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 - # 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 - # 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 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 - ) - - 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 - - reason = self._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. - - 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() - - ctx = PolicyContext(is_conversational=self._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 - - if response.mode is not None: - self._enforcement_mode = response.mode - logger.info("Enforcement mode set from provider: %s", response.mode.value) - - 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" - ) - 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(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), - ) - - @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. - - 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 be843c3..421f856 100644 --- a/src/uipath/runtime/governance/runtime.py +++ b/src/uipath/runtime/governance/runtime.py @@ -1,28 +1,30 @@ """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. - -The wiring layer (uipath CLI) decides whether to construct -``GovernanceRuntime`` at all (feature flag, project config, etc.) and -passes ``is_conversational`` and ``trace_id`` explicitly. The runtime -layer does not introspect the delegate's private attributes nor read -env vars to discover those. - -**Staging caveat — policy loading only, no enforcement yet.** This -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 -and the ``trace_id`` 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. +Wraps a :class:`UiPathRuntimeProtocol` delegate. The wrapper is +**pure** — it holds an already-resolved :class:`PolicyIndex` and +:class:`EnforcementMode` passed in by the host. No I/O happens at +construction, no background thread is spun up, no provider is held. + +Why: per the architecture-review §2.4 prescription, the policy fetch +belongs to the async host (uipath CLI), which does +``await provider.get_policy_async(PolicyContext(is_conversational=...))`` +itself, compiles the response YAML via +:func:`build_policy_index_from_yaml`, and hands the resolved +``PolicyIndex`` + mode into this constructor. The runtime layer +becomes a passive consumer of a snapshot; the host owns lifecycle +(refetch, refresh, dispose). + +Agent-type selection (``is_conversational``) lives in the host's +:class:`PolicyContext` construction, not on this wrapper. The +generic runtime layer no longer carries that selector. + +**Staging caveat — policy data only, no enforcement yet.** ``execute`` +/ ``stream`` / ``get_schema`` / ``dispose`` are pure passthroughs; +per-hook policy evaluation lands in a follow-up slice that wires the +evaluator into the host's decorator chain. Constructing +:class:`GovernanceRuntime` today gives you the resolved policy +snapshot exposed via :attr:`policy_index` and :attr:`enforcement_mode` +for the evaluator to pick up. """ from __future__ import annotations @@ -30,7 +32,7 @@ import logging from typing import Any, AsyncGenerator -from uipath.core.governance import GovernancePolicyProvider +from uipath.core.governance import EnforcementMode from uipath.runtime.base import ( UiPathExecuteOptions, @@ -38,7 +40,7 @@ UiPathStreamOptions, ) from uipath.runtime.events import UiPathRuntimeEvent -from uipath.runtime.governance.native.loader import PolicyLoader +from uipath.runtime.governance.native.models import PolicyIndex from uipath.runtime.result import UiPathRuntimeResult from uipath.runtime.schema import UiPathRuntimeSchema @@ -48,67 +50,67 @@ class GovernanceRuntime: """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. - 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 the loader's policy index is - staged separately. + The constructor takes a **resolved** :class:`PolicyIndex` and + :class:`EnforcementMode` — the host has already done the async + fetch via the policy provider and compiled the YAML. The runtime + holds the snapshot for the lifetime of the wrapping instance. + + **Policy data only — no enforcement yet.** ``execute`` / ``stream`` + / ``get_schema`` / ``dispose`` are passthroughs to the delegate; + the evaluator + framework adapter that consume + :attr:`policy_index` / :attr:`enforcement_mode` are staged + separately. """ def __init__( self, delegate: UiPathRuntimeProtocol, - policy_provider: GovernancePolicyProvider | None, + policy_index: PolicyIndex, + enforcement_mode: EnforcementMode, *, - is_conversational: bool | None = None, trace_id: str | None = None, ): - """Initialize the governance runtime. + """Initialize the governance runtime with a resolved policy snapshot. 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. - 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. - trace_id: Trace identifier the platform host has bound to - this run (typically read from ``UIPATH_TRACE_ID`` by - the wiring layer). The evaluator slice forwards this - into the :class:`GuardrailCompensator` so server-written - compensation records land on the agent's run trace - instead of a detached id. ``None`` (default) leaves + policy_index: Resolved :class:`PolicyIndex` the host built + from the provider's :class:`PolicyResponse`. Pass an + empty ``PolicyIndex()`` to attach the wrapper without + any rules (useful when the wrapper exists for audit + emission only). + enforcement_mode: Resolved :class:`EnforcementMode` from + the provider's :class:`PolicyResponse`. The host is + expected to skip wrapping entirely when the response + mode is :attr:`EnforcementMode.DISABLED`; this + constructor doesn't check. + trace_id: Trace identifier the platform host bound to this + run (typically read from ``UIPATH_TRACE_ID`` by the + wiring layer). Forwarded to the + :class:`GuardrailCompensator` by the evaluator slice + so server-written compensation records land on the + agent's run trace. ``None`` (default) leaves downstream consumers to fall back to the live OTel span / caller-supplied value. """ self._delegate = delegate + self._policy_index = policy_index + self._enforcement_mode = enforcement_mode self._trace_id = trace_id - self._loader = PolicyLoader( - policy_provider, - is_conversational=is_conversational, - ) - self._loader.prefetch() @property - def loader(self) -> PolicyLoader: - """The instance-scoped policy loader. + def policy_index(self) -> PolicyIndex: + """The resolved policy snapshot this runtime evaluates against. - Exposed so adapters / evaluators wired into this runtime can - call :meth:`PolicyLoader.get_policy_index` at hook time. + Exposed so the evaluator slice can pick it up when it wires + per-hook evaluation into ``execute`` / ``stream``. """ - return self._loader + return self._policy_index + + @property + def enforcement_mode(self) -> EnforcementMode: + """The enforcement mode the host supplied at construction.""" + return self._enforcement_mode @property def trace_id(self) -> str | None: diff --git a/tests/_helpers.py b/tests/_helpers.py deleted file mode 100644 index 2d3d924..0000000 --- a/tests/_helpers.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Shared test-only helpers. - -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 - -import time - -from uipath.core.governance import PolicyContext, PolicyResponse - - -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. - """ - - 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 ba76eca..deb6953 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ def temp_dir() -> Generator[str, None, None]: yield tmp_dir -# 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. +# Governance state is held inline on the :class:`GovernanceRuntime` +# instance — the host passes a resolved :class:`PolicyIndex` + +# :class:`EnforcementMode` into the constructor, no module-level +# state, no cross-test reset needed. diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py deleted file mode 100644 index 78230fd..0000000 --- a/tests/test_enforcement_mode_default.py +++ /dev/null @@ -1,114 +0,0 @@ -"""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``, the loader records it and -:attr:`enforcement_mode` flips. - -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 - -from uipath.core.governance import EnforcementMode, PolicyResponse - -from tests._helpers import StubPolicyProvider -from uipath.runtime.governance.native.loader import PolicyLoader - - -def test_default_mode_is_audit() -> None: - """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. - """ - 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 65286ce..d4bce67 100644 --- a/tests/test_governance_runtime.py +++ b/tests/test_governance_runtime.py @@ -1,23 +1,21 @@ -"""Tests for the GovernanceRuntime wrapper and the provider loader path. +"""Tests for :class:`GovernanceRuntime` — pure resolved-policy wrapper. -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. +The runtime takes an already-resolved :class:`PolicyIndex` + +:class:`EnforcementMode` at construction (the host fetched the policy +asynchronously via the :class:`GovernancePolicyProvider` and compiled +the YAML). Tests here confirm the wrapper holds the snapshot and +passes execution straight through to the delegate. """ from __future__ import annotations from typing import Any -from uipath.core.governance import ( - EnforcementMode, - PolicyResponse, -) +from uipath.core.governance import EnforcementMode -from tests._helpers import StubPolicyProvider -from uipath.runtime.governance.native.loader import PolicyLoader +from uipath.runtime.governance.native import ( + build_policy_index_from_yaml, +) from uipath.runtime.governance.native.models import PolicyIndex from uipath.runtime.governance.runtime import GovernanceRuntime @@ -33,107 +31,28 @@ """ -# Each test constructs a fresh ``PolicyLoader`` / ``GovernanceRuntime`` -# — no module-level state to reset. - - # --------------------------------------------------------------------------- -# PolicyLoader — provider plumbing (mode application, context, errors) +# build_policy_index_from_yaml — host-side compile path # --------------------------------------------------------------------------- -def test_loader_builds_index_and_applies_mode() -> None: - provider = StubPolicyProvider( - response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) - ) - - loader = PolicyLoader(provider) - index = loader.load_policy_index() - +def test_build_policy_index_from_yaml_compiles_pack() -> None: + """The host uses this to turn the provider's YAML response into the snapshot.""" + index = build_policy_index_from_yaml(SIMPLE_POLICY_YAML) assert isinstance(index, PolicyIndex) assert index.total_rules == 1 assert "provider-pack" in index.pack_names - assert loader.enforcement_mode == EnforcementMode.ENFORCE - - -def test_loader_passes_is_conversational_in_context() -> None: - provider = StubPolicyProvider( - response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) - ) - - PolicyLoader(provider, is_conversational=True).load_policy_index() - - assert len(provider.calls) == 1 - assert provider.calls[0].is_conversational is True - - -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 - - -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_loader_returns_empty_on_empty_policies() -> None: - provider = StubPolicyProvider( - response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") - ) - index = PolicyLoader(provider).load_policy_index() - assert index.total_rules == 0 - - -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) - ) - index = PolicyLoader(provider).load_policy_index() - assert index.total_rules == 0 - - -def test_loader_returns_empty_on_malformed_yaml() -> None: - provider = StubPolicyProvider( - response=PolicyResponse( - mode=EnforcementMode.AUDIT, policies="key: : invalid: : yaml" - ) - ) - index = PolicyLoader(provider).load_policy_index() +def test_build_policy_index_from_yaml_empty_yields_empty_index() -> None: + """Empty YAML compiles to an empty PolicyIndex — host can pass straight through.""" + index = build_policy_index_from_yaml("") + assert isinstance(index, PolicyIndex) assert index.total_rules == 0 -def test_loader_does_not_change_mode_when_response_mode_is_none() -> None: - """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 - - # 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() - - assert loader.enforcement_mode == EnforcementMode.ENFORCE - - # --------------------------------------------------------------------------- -# GovernanceRuntime — passthroughs + loader wiring +# GovernanceRuntime — passthroughs + snapshot exposure # --------------------------------------------------------------------------- @@ -163,52 +82,46 @@ async def dispose(self) -> None: self.disposed = True -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) +def _make_runtime( + delegate: _StubDelegate | None = None, + *, + policy_index: PolicyIndex | None = None, + enforcement_mode: EnforcementMode = EnforcementMode.AUDIT, + trace_id: str | None = None, +) -> GovernanceRuntime: + """Build a runtime with sensible test defaults.""" + return GovernanceRuntime( + delegate or _StubDelegate(), + policy_index if policy_index is not None else PolicyIndex(), + enforcement_mode, + trace_id=trace_id, ) - runtime = GovernanceRuntime(_StubDelegate(), policy_provider=provider) - - assert isinstance(runtime.loader, PolicyLoader) - assert runtime.loader._provider is provider - - -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) - ) - - 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() - - assert provider.calls, "provider.get_policy was never invoked" - assert provider.calls[0].is_conversational is True +# --------------------------------------------------------------------------- +# Snapshot exposure — the host hands resolved values in, runtime reads them back +# --------------------------------------------------------------------------- -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) - ) - runtime = GovernanceRuntime(_StubDelegate(), policy_provider=provider) - runtime.loader.get_policy_index() +def test_governance_runtime_exposes_resolved_policy_index() -> None: + """The ``policy_index`` constructor arg is reachable via the property.""" + index = build_policy_index_from_yaml(SIMPLE_POLICY_YAML) + runtime = _make_runtime(policy_index=index) + assert runtime.policy_index is index + assert runtime.policy_index.total_rules == 1 + assert "provider-pack" in runtime.policy_index.pack_names - assert provider.calls[0].is_conversational is None +def test_governance_runtime_exposes_enforcement_mode() -> None: + """The ``enforcement_mode`` constructor arg is reachable via the property.""" + runtime = _make_runtime(enforcement_mode=EnforcementMode.ENFORCE) + assert runtime.enforcement_mode is EnforcementMode.ENFORCE -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) - index = runtime.loader.get_policy_index() - assert index.total_rules == 0 +def test_governance_runtime_with_empty_index_carries_no_rules() -> None: + """Empty ``PolicyIndex()`` is a valid snapshot — wrapper attaches with no rules.""" + runtime = _make_runtime(policy_index=PolicyIndex()) + assert runtime.policy_index.total_rules == 0 def test_governance_runtime_stashes_trace_id() -> None: @@ -220,23 +133,24 @@ def test_governance_runtime_stashes_trace_id() -> None: forwards it into the :class:`GuardrailCompensator` constructor so compensation records land on the agent's run trace. """ - runtime = GovernanceRuntime( - _StubDelegate(), - policy_provider=None, - trace_id="wired-trace-0001", - ) + runtime = _make_runtime(trace_id="wired-trace-0001") assert runtime.trace_id == "wired-trace-0001" def test_governance_runtime_default_trace_id_is_none() -> None: """Omitting ``trace_id`` leaves the property as ``None``.""" - runtime = GovernanceRuntime(_StubDelegate(), policy_provider=None) + runtime = _make_runtime() assert runtime.trace_id is None +# --------------------------------------------------------------------------- +# Passthrough behavior +# --------------------------------------------------------------------------- + + async def test_governance_runtime_execute_delegates() -> None: delegate = _StubDelegate() - runtime = GovernanceRuntime(delegate, policy_provider=None) + runtime = _make_runtime(delegate) result = await runtime.execute({"x": 1}) @@ -246,7 +160,7 @@ async def test_governance_runtime_execute_delegates() -> None: async def test_governance_runtime_stream_delegates() -> None: delegate = _StubDelegate() - runtime = GovernanceRuntime(delegate, policy_provider=None) + runtime = _make_runtime(delegate) events = [e async for e in runtime.stream({"x": 1})] @@ -256,7 +170,7 @@ async def test_governance_runtime_stream_delegates() -> None: async def test_governance_runtime_schema_and_dispose_delegate() -> None: delegate = _StubDelegate() - runtime = GovernanceRuntime(delegate, policy_provider=None) + runtime = _make_runtime(delegate) assert await runtime.get_schema() == "schema" await runtime.dispose() diff --git a/tests/test_loader.py b/tests/test_loader.py deleted file mode 100644 index 87e453b..0000000 --- a/tests/test_loader.py +++ /dev/null @@ -1,307 +0,0 @@ -"""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 - -import threading -import time -from typing import Any -from unittest.mock import patch - -from uipath.core.governance import ( - EnforcementMode, - PolicyContext, - PolicyResponse, -) - -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 - -SIMPLE_POLICY_YAML = """ -standard: test-pack -version: "1.0" -rules: - - id: r1 - hook: before_model - checks: - - type: regex - patterns: ["leak"] -""" - - -def _ok_response() -> PolicyResponse: - return PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) - - -# Each test constructs a fresh ``PolicyLoader`` — no shared state to reset. - - -# --------------------------------------------------------------------------- -# _empty_index_reason — diagnostic string for the "no policies" log -# --------------------------------------------------------------------------- - - -def test_empty_index_reason_no_provider() -> None: - msg = PolicyLoader(None)._empty_index_reason() - assert "no policy provider" in msg - - -def test_empty_index_reason_with_provider() -> None: - msg = PolicyLoader(StubPolicyProvider(response=_ok_response()))._empty_index_reason() - assert "provider returned no policies" in msg - - -# --------------------------------------------------------------------------- -# load_policy_index — synchronous entry point -# --------------------------------------------------------------------------- - - -def test_load_policy_index_empty_when_no_provider() -> None: - """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_provider() -> None: - provider = StubPolicyProvider(response=_ok_response()) - - index = PolicyLoader(provider).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_when_provider_raises() -> None: - provider = StubPolicyProvider(raises=RuntimeError("boom")) - index = PolicyLoader(provider).load_policy_index() - assert index.total_rules == 0 - - -# --------------------------------------------------------------------------- -# 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()) - loader = PolicyLoader(provider) - - a = loader.get_policy_index() - b = loader.get_policy_index() - - assert a is b - assert len(provider.calls) == 1 - - -def test_get_policy_index_sync_load_when_no_prefetch() -> None: - """Without a prefetch in flight, get_policy_index synchronously loads.""" - 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() - - 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() - first_event = loader._prefetch_event - 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_no_op_when_index_already_loaded() -> None: - """If the index is already cached, prefetch is a no-op.""" - provider = StubPolicyProvider(response=_ok_response()) - loader = PolicyLoader(provider) - loader.get_policy_index() # populate the cache - - loader.prefetch() - - 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.""" - started = threading.Event() - release = threading.Event() - - def _fetch(context: PolicyContext) -> PolicyResponse: - started.set() - release.wait(timeout=2.0) - return _ok_response() - - provider: Any = type("P", (), {"get_policy": staticmethod(_fetch)})() - loader = PolicyLoader(provider) - - loader.prefetch() - assert started.wait(timeout=2.0) - threading.Thread( - target=lambda: (time.sleep(0.05), release.set()), daemon=True - ).start() - index = loader.get_policy_index() - assert index.total_rules == 1 - - -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() - 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]) - for call in mock_warning.call_args_list - ) - - -# --------------------------------------------------------------------------- -# available_packs / clear_cache -# --------------------------------------------------------------------------- - - -def test_available_packs_before_load_returns_empty() -> None: - assert PolicyLoader(None).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_cache_forces_refetch() -> None: - provider = StubPolicyProvider(response=_ok_response()) - loader = PolicyLoader(provider) - - 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 diff --git a/tests/test_traces_severity.py b/tests/test_traces_severity.py index 13567b8..0a5e763 100644 --- a/tests/test_traces_severity.py +++ b/tests/test_traces_severity.py @@ -6,9 +6,10 @@ (what the rule decided, mode-independent) and ``action_applied`` (what actually happened, derived from evaluator_result + mode). -Mode travels with the event (set by the evaluator from the per-loader -:class:`PolicyLoader.enforcement_mode`) so parallel runtimes running -different modes don't cross-contaminate the sink's view. +Mode travels with the event (set by the evaluator from the per-runtime +:attr:`GovernanceRuntime.enforcement_mode` the host supplied) so +parallel runtimes running different modes don't cross-contaminate the +sink's view. - ``verbosityLevel = 4`` (Error) and ``StatusCode.ERROR`` fire **only** when ``action_applied = DENY`` — i.e. the runtime actually blocked From 89a4d12dfc9111b46aa550efb668a3856393dfc5 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Thu, 25 Jun 2026 17:05:45 +0530 Subject: [PATCH 2/2] refactor(governance): production cleanup of runtime + audit docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtime.py: drop §2.4 PR ref and historical "staging caveat" language from module/class docstrings; drop downstream LangChain class name from the generic runtime layer; replace defensive getattr(result, "output", None) with result.output (the outer fail-open try/except already covers a malformed delegate). - evaluator.py: fix stale "loader" reference in docstring → GovernanceRuntime. - _audit/traces.py: rewrite three comments referencing the deleted PolicyLoader to describe the per-runtime model. - _audit/base.py: rewrite two docstrings referencing the deleted PolicyLoader. - native/_yaml_to_index.py: fix broken :mod: link to the deleted native.loader module; describe the platform-host compile flow. No behavior change. ruff/mypy clean, 326 passed + 1 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uipath/runtime/governance/_audit/base.py | 4 +- .../runtime/governance/_audit/traces.py | 11 +- .../governance/native/_yaml_to_index.py | 11 +- .../runtime/governance/native/evaluator.py | 5 +- src/uipath/runtime/governance/runtime.py | 219 +++++++++++++----- 5 files changed, 172 insertions(+), 78 deletions(-) diff --git a/src/uipath/runtime/governance/_audit/base.py b/src/uipath/runtime/governance/_audit/base.py index 3a61419..13b7cde 100644 --- a/src/uipath/runtime/governance/_audit/base.py +++ b/src/uipath/runtime/governance/_audit/base.py @@ -538,7 +538,7 @@ def emit_rule_evaluation( """Convenience method to emit a rule evaluation event. ``enforcement_mode`` travels on the event so sinks don't have to - read a process-global. With instance-scoped loaders the global + read a process-global. With instance-scoped runtimes the global wouldn't be authoritative anyway — parallel runtimes can run in different modes simultaneously. """ @@ -599,7 +599,7 @@ def emit_session_start( Same ``enforcement_mode: EnforcementMode`` contract as :meth:`emit_rule_evaluation` and :meth:`emit_hook_summary` - — every governance event carries the per-loader mode so sinks + — every governance event carries the per-runtime mode so sinks don't depend on a process-global. """ self.emit( diff --git a/src/uipath/runtime/governance/_audit/traces.py b/src/uipath/runtime/governance/_audit/traces.py index 7832ac2..76f10b2 100644 --- a/src/uipath/runtime/governance/_audit/traces.py +++ b/src/uipath/runtime/governance/_audit/traces.py @@ -73,9 +73,10 @@ def _resolve_mode(event: AuditEvent) -> EnforcementMode: """Read the enforcement mode the evaluator stamped on the event. Mode travels with the event (set by :meth:`AuditManager.emit_rule_evaluation` - / :meth:`emit_hook_summary` from the loader's per-instance mode) so - the sink doesn't read a process-global that wouldn't be authoritative - in a parallel-runtime setup. + / :meth:`emit_hook_summary` from the per-runtime + :attr:`GovernanceRuntime.enforcement_mode`) so the sink doesn't + read a process-global that wouldn't be authoritative in a + parallel-runtime setup. Falls back to ``AUDIT`` only when the field is missing — that's a contract violation by the emitter (every governance event must carry @@ -212,7 +213,7 @@ def _emit_hook_span(self, event: AuditEvent) -> None: # multiple SDKs / governance backends co-exist. span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) # Hook summary attributes. Mode comes from the event — the - # evaluator stamps it from the per-loader instance, so the + # evaluator stamps it from the per-runtime instance, so the # sink is correct for parallel runtimes running different # modes. mode = _resolve_mode(event) @@ -272,7 +273,7 @@ def _emit_rule_span(self, event: AuditEvent) -> None: # Derive the spec-vocabulary verdict pair from the raw # (matched, configured action, mode) tuple. Mode comes - # from the event (per-loader instance) so parallel + # from the event (per-runtime instance) so parallel # runtimes running different modes don't cross-contaminate. # Single source of truth for the emitted attributes below # AND the verbosityLevel/Status decision further down. diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index 3bf264c..40448d9 100644 --- a/src/uipath/runtime/governance/native/_yaml_to_index.py +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -1,10 +1,11 @@ """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` to -compile the YAML body returned by the registered policy provider into -an in-memory index at startup. +Mirrors the shape produced by ``packs/compile_packs.py`` but builds +the :class:`PolicyIndex` directly from parsed YAML data rather than +generating Python source. The platform host calls this to compile the +YAML body returned by :meth:`GovernancePolicyProvider.get_policy_async` +into an in-memory index, then hands the index to +:class:`GovernanceRuntime`. Accepts either a single YAML document (one pack) or a multi-document stream (``---``-separated packs). Unknown check types and malformed diff --git a/src/uipath/runtime/governance/native/evaluator.py b/src/uipath/runtime/governance/native/evaluator.py index cc798a8..2290361 100644 --- a/src/uipath/runtime/governance/native/evaluator.py +++ b/src/uipath/runtime/governance/native/evaluator.py @@ -298,8 +298,9 @@ def __init__( enforcement_mode: Mode the evaluator applies. Defaults to ``AUDIT`` — the safe default for callers that don't explicitly opt in to ENFORCE. The wiring layer should - pass ``runtime.enforcement_mode`` here so the - evaluator and loader agree on a single source of truth. + pass ``runtime.enforcement_mode`` here so the evaluator + and the wrapping :class:`GovernanceRuntime` agree on a + single source of truth. audit_manager: Per-runtime :class:`AuditManager`. When ``None`` the evaluator runs silently (no audit events emitted). Tests that don't care about emission can diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py index 421f856..bd49d76 100644 --- a/src/uipath/runtime/governance/runtime.py +++ b/src/uipath/runtime/governance/runtime.py @@ -1,38 +1,38 @@ """Governance runtime wrapper. -Wraps a :class:`UiPathRuntimeProtocol` delegate. The wrapper is -**pure** — it holds an already-resolved :class:`PolicyIndex` and -:class:`EnforcementMode` passed in by the host. No I/O happens at -construction, no background thread is spun up, no provider is held. - -Why: per the architecture-review §2.4 prescription, the policy fetch -belongs to the async host (uipath CLI), which does -``await provider.get_policy_async(PolicyContext(is_conversational=...))`` -itself, compiles the response YAML via -:func:`build_policy_index_from_yaml`, and hands the resolved -``PolicyIndex`` + mode into this constructor. The runtime layer -becomes a passive consumer of a snapshot; the host owns lifecycle -(refetch, refresh, dispose). - -Agent-type selection (``is_conversational``) lives in the host's -:class:`PolicyContext` construction, not on this wrapper. The -generic runtime layer no longer carries that selector. - -**Staging caveat — policy data only, no enforcement yet.** ``execute`` -/ ``stream`` / ``get_schema`` / ``dispose`` are pure passthroughs; -per-hook policy evaluation lands in a follow-up slice that wires the -evaluator into the host's decorator chain. Constructing -:class:`GovernanceRuntime` today gives you the resolved policy -snapshot exposed via :attr:`policy_index` and :attr:`enforcement_mode` -for the evaluator to pick up. +Wraps a :class:`UiPathRuntimeProtocol` delegate and carries a resolved +policy snapshot — a :class:`PolicyIndex` and :class:`EnforcementMode` +supplied by the caller. The wrapper performs no I/O at construction, +holds no background thread, and does not retain a policy provider. + +The caller (typically the platform host) is expected to: + +- ``await provider.get_policy_async(PolicyContext(...))`` itself, +- compile the response YAML via + :func:`uipath.runtime.governance.native.build_policy_index_from_yaml`, +- skip wrapping entirely when the response mode is + :attr:`EnforcementMode.DISABLED`, +- pass the resolved ``PolicyIndex`` and ``EnforcementMode`` into the + constructor. + +The wrapper owns the BEFORE_AGENT / AFTER_AGENT lifecycle boundary +when an evaluator is supplied at construction. Framework adapters +intentionally skip chain-level events so nested chain runs don't +fire duplicate boundary evaluations; the runtime layer is the +unambiguous "one invocation = one boundary" point, so it owns those +hooks. Per-step hooks (BEFORE_MODEL, AFTER_MODEL, TOOL_CALL, +AFTER_TOOL) are fired by adapters that observe per-step events. """ from __future__ import annotations +import json import logging from typing import Any, AsyncGenerator +from pydantic import BaseModel from uipath.core.governance import EnforcementMode +from uipath.core.governance.exceptions import GovernanceBlockException from uipath.runtime.base import ( UiPathExecuteOptions, @@ -40,6 +40,7 @@ UiPathStreamOptions, ) from uipath.runtime.events import UiPathRuntimeEvent +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator from uipath.runtime.governance.native.models import PolicyIndex from uipath.runtime.result import UiPathRuntimeResult from uipath.runtime.schema import UiPathRuntimeSchema @@ -47,19 +48,43 @@ logger = logging.getLogger(__name__) +def _serialize_payload(payload: Any) -> str: + """Serialize an agent input / output for governance evaluation. + + The native evaluator's BEFORE_AGENT / AFTER_AGENT checks scan a + string. Dict-shaped payloads are JSON-encoded so structured fields + are visible to regex / sentiment / pattern checks. Pydantic models + use their canonical JSON dump. Primitives go through ``str``. + ``None`` becomes the empty string. + """ + if payload is None: + return "" + if isinstance(payload, str): + return payload + if isinstance(payload, BaseModel): + try: + return payload.model_dump_json() + except Exception: # noqa: BLE001 — fall through to json + pass + try: + return json.dumps(payload, default=str) + except Exception: # noqa: BLE001 + return str(payload) + + class GovernanceRuntime: """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. - The constructor takes a **resolved** :class:`PolicyIndex` and - :class:`EnforcementMode` — the host has already done the async - fetch via the policy provider and compiled the YAML. The runtime - holds the snapshot for the lifetime of the wrapping instance. + Holds a caller-resolved :class:`PolicyIndex` and + :class:`EnforcementMode` for the lifetime of the instance. + ``execute`` / ``stream`` / ``get_schema`` / ``dispose`` forward to + the delegate. - **Policy data only — no enforcement yet.** ``execute`` / ``stream`` - / ``get_schema`` / ``dispose`` are passthroughs to the delegate; - the evaluator + framework adapter that consume - :attr:`policy_index` / :attr:`enforcement_mode` are staged - separately. + When ``evaluator`` is supplied, :meth:`execute` and :meth:`stream` + fire ``BEFORE_AGENT`` before delegating and ``AFTER_AGENT`` after + a successful return. Without an evaluator the wrapper is a pure + data carrier — consumers read :attr:`policy_index` and + :attr:`enforcement_mode` and drive evaluation themselves. """ def __init__( @@ -68,81 +93,147 @@ def __init__( policy_index: PolicyIndex, enforcement_mode: EnforcementMode, *, + evaluator: GovernanceEvaluator | None = None, + agent_name: str = "", + runtime_id: str = "", trace_id: str | None = None, ): """Initialize the governance runtime with a resolved policy snapshot. Args: delegate: The wrapped runtime to forward execution to. - policy_index: Resolved :class:`PolicyIndex` the host built - from the provider's :class:`PolicyResponse`. Pass an - empty ``PolicyIndex()`` to attach the wrapper without - any rules (useful when the wrapper exists for audit + policy_index: Resolved :class:`PolicyIndex` built from the + provider's :class:`PolicyResponse`. Pass an empty + ``PolicyIndex()`` to attach the wrapper without any + rules (useful when the wrapper exists for audit emission only). enforcement_mode: Resolved :class:`EnforcementMode` from - the provider's :class:`PolicyResponse`. The host is + the provider's :class:`PolicyResponse`. The caller is expected to skip wrapping entirely when the response mode is :attr:`EnforcementMode.DISABLED`; this - constructor doesn't check. - trace_id: Trace identifier the platform host bound to this - run (typically read from ``UIPATH_TRACE_ID`` by the - wiring layer). Forwarded to the - :class:`GuardrailCompensator` by the evaluator slice - so server-written compensation records land on the - agent's run trace. ``None`` (default) leaves - downstream consumers to fall back to the live OTel - span / caller-supplied value. + constructor does not check. + evaluator: Optional :class:`GovernanceEvaluator` that + drives BEFORE_AGENT / AFTER_AGENT inside + :meth:`execute` / :meth:`stream`. When ``None`` the + wrapper is a pure passthrough — the caller is + expected to fire those evaluations itself. + agent_name: Name of the agent (the runtime's entrypoint). + Passed straight through to + :meth:`GovernanceEvaluator.evaluate_before_agent` / + :meth:`evaluate_after_agent`. Empty string when no + evaluator is supplied. + runtime_id: Runtime-instance id (conversation id, job id, + or a synthetic per-run id). Passed through to the + evaluator so per-runtime state (session, in-flight + rule fires) routes cleanly. + trace_id: Trace identifier the platform host bound to + this run. Forwarded to + :class:`GuardrailCompensator` so server-written + compensation records land on the agent's run trace. + ``None`` (default) leaves downstream consumers to + fall back to the live OTel span / caller-supplied + value. """ self._delegate = delegate self._policy_index = policy_index self._enforcement_mode = enforcement_mode self._trace_id = trace_id + self._evaluator = evaluator + self._agent_name = agent_name + self._runtime_id = runtime_id @property def policy_index(self) -> PolicyIndex: - """The resolved policy snapshot this runtime evaluates against. - - Exposed so the evaluator slice can pick it up when it wires - per-hook evaluation into ``execute`` / ``stream``. - """ + """The resolved policy snapshot the runtime evaluates against.""" return self._policy_index @property def enforcement_mode(self) -> EnforcementMode: - """The enforcement mode the host supplied at construction.""" + """The enforcement mode supplied at construction.""" return self._enforcement_mode @property def trace_id(self) -> str | None: - """Trace id supplied by the wiring layer (or ``None``). + """The trace id supplied at construction (or ``None``).""" + return self._trace_id - Exposed so the evaluator slice can read it at hook-wire time - and pass it into the :class:`GuardrailCompensator` it - constructs. + def _fire_before_agent(self, input: Any) -> None: + """Fire BEFORE_AGENT when an evaluator is wired; otherwise no-op. + + ``GovernanceBlockException`` propagates — that's how + ENFORCE-mode DENY rules halt a run. Anything else is logged + and swallowed so a governance bug never breaks the agent. """ - return self._trace_id + if self._evaluator is None: + return + try: + self._evaluator.evaluate_before_agent( + agent_input=_serialize_payload(input), + agent_name=self._agent_name, + runtime_id=self._runtime_id, + trace_id=self._trace_id or "", + ) + except GovernanceBlockException: + raise + except Exception as exc: # noqa: BLE001 — never break a run on audit failure + logger.warning("BEFORE_AGENT governance evaluation failed: %s", exc) + + def _fire_after_agent(self, result: UiPathRuntimeResult) -> None: + """Fire AFTER_AGENT against ``result.output``. + + Same exception policy as :meth:`_fire_before_agent`. + """ + if self._evaluator is None: + return + try: + self._evaluator.evaluate_after_agent( + agent_output=_serialize_payload(result.output), + agent_name=self._agent_name, + runtime_id=self._runtime_id, + trace_id=self._trace_id or "", + ) + except GovernanceBlockException: + raise + except Exception as exc: # noqa: BLE001 + logger.warning("AFTER_AGENT governance evaluation failed: %s", exc) 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) + """Execute the delegate, firing BEFORE_AGENT / AFTER_AGENT around it. + + AFTER_AGENT fires only on successful return — if the delegate + raises, there's no output to evaluate. + """ + self._fire_before_agent(input) + result = await self._delegate.execute(input, options=options) + self._fire_after_agent(result) + return result 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.""" + """Stream events from the delegate, firing BEFORE_AGENT first. + + AFTER_AGENT fires once a :class:`UiPathRuntimeResult` event is + observed in the stream — that's the runtime's contract for + signalling a completed invocation. Intermediate state events + pass through untouched. + """ + self._fire_before_agent(input) async for event in self._delegate.stream(input, options=options): + if isinstance(event, UiPathRuntimeResult): + self._fire_after_agent(event) yield event async def get_schema(self) -> UiPathRuntimeSchema: - """Passthrough schema for the delegate.""" + """Forward schema lookup to the delegate.""" return await self._delegate.get_schema() async def dispose(self) -> None: - """Dispose the delegate.""" + """Forward disposal to the delegate.""" await self._delegate.dispose()