From 8718453a2333e50775addf336ce7c4471d7d8a7e Mon Sep 17 00:00:00 2001 From: Evan Bijoy Date: Fri, 10 Jul 2026 11:54:14 +0530 Subject: [PATCH 1/5] feat: add Rego wire models and feature flag to core Adds HookBundle and AllPoliciesResponse Pydantic wire models for the /all-policies/{tenant_id} endpoint alongside PolicyResponse. Adds REGO_FEATURE_FLAG / is_rego_enabled() mirroring the existing GOVERNANCE_FEATURE_FLAG / is_governance_enabled() pattern. Bumps uipath-core to 0.5.31. Generated with Claude Code Co-Authored-By: Claude --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/governance/__init__.py | 8 ++++ .../src/uipath/core/governance/config.py | 22 +++++++++ .../src/uipath/core/governance/providers.py | 48 +++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index fdc902287..dc2570992 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.30" +version = "0.5.31" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py index 4bf855b82..ddcb43118 100644 --- a/packages/uipath-core/src/uipath/core/governance/__init__.py +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -9,7 +9,9 @@ from .config import ( GOVERNANCE_FEATURE_FLAG, + REGO_FEATURE_FLAG, is_governance_enabled, + is_rego_enabled, ) from .exceptions import ( GovernanceBlockException, @@ -19,10 +21,12 @@ ) from .models import Action, AuditRecord, EnforcementMode, LifecycleHook, RuleEvaluation from .providers import ( + AllPoliciesResponse, FiredRule, GovernanceCompensationProvider, GovernancePolicyProvider, GovernRequest, + HookBundle, PolicyContext, PolicyResponse, ) @@ -36,17 +40,21 @@ "RuleEvaluation", # Config "GOVERNANCE_FEATURE_FLAG", + "REGO_FEATURE_FLAG", "is_governance_enabled", + "is_rego_enabled", # Exceptions "GovernanceBlockException", "GovernanceConfigError", "GovernanceViolation", "Severity", # Provider protocols + wire models + "AllPoliciesResponse", "FiredRule", "GovernanceCompensationProvider", "GovernancePolicyProvider", "GovernRequest", + "HookBundle", "PolicyContext", "PolicyResponse", ] diff --git a/packages/uipath-core/src/uipath/core/governance/config.py b/packages/uipath-core/src/uipath/core/governance/config.py index cbcbd577a..0b3a96b62 100644 --- a/packages/uipath-core/src/uipath/core/governance/config.py +++ b/packages/uipath-core/src/uipath/core/governance/config.py @@ -18,6 +18,10 @@ # same toggle. GOVERNANCE_FEATURE_FLAG = "EnablePythonGovernanceChecker" +# Feature flag name controlling whether the Rego/WASM evaluator runs. +# Independent of the native evaluator flag — both can be enabled simultaneously. +REGO_FEATURE_FLAG = "EnablePythonGovernanceRegoEvaluator" + def is_governance_enabled() -> bool: """Return whether the ``EnablePythonGovernanceChecker`` flag is enabled. @@ -35,3 +39,21 @@ def is_governance_enabled() -> bool: 2. Default ``False`` (governance disabled). """ return FeatureFlags.is_flag_enabled(GOVERNANCE_FEATURE_FLAG, default=False) + + +def is_rego_enabled() -> bool: + """Return whether the ``EnablePythonGovernanceRegoEvaluator`` flag is enabled. + + Rego evaluation is **off by default** — the flag must be explicitly + set to ``true`` (programmatically via the ``FeatureFlags`` registry, + or via the ``UIPATH_FEATURE_EnablePythonGovernanceRegoEvaluator`` env + var) for this function to return ``True``. + + Resolution order: + + 1. :meth:`uipath.core.feature_flags.FeatureFlagsManager.is_flag_enabled` - + the in-process programmatic registry (typically populated from + gitops) and its own ``UIPATH_FEATURE_`` env-var fallback. + 2. Default ``False`` (Rego evaluation disabled). + """ + return FeatureFlags.is_flag_enabled(REGO_FEATURE_FLAG, default=False) diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 5435ad389..9d1c8ee1a 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -78,6 +78,54 @@ def _coerce_mode(cls, value: object) -> EnforcementMode | None: return None +class HookBundle(BaseModel): + """Metadata for one hook's WASM policy bundle. + + Returned as an element of :class:`AllPoliciesResponse` from the + ``/all-policies/{tenant_id}`` endpoint. + + Attributes: + hook_type: Lifecycle hook identifier (e.g. ``"before_agent"``). + bundle_url: Pre-signed URL for the WASM ``.tar.gz`` bundle. + No platform auth required — the URL carries its own credentials. + etag: Server-assigned ETag for the bundle. Used by the Rego loader + to skip unchanged bundles on background refresh. ``None`` when + the server does not provide one. + """ + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + hook_type: str = Field(alias="hookType") + bundle_url: str = Field(alias="bundleUrl") + etag: str | None = Field(default=None) + + +class AllPoliciesResponse(BaseModel): + """Parsed response from the ``/all-policies/{tenant_id}`` endpoint. + + Wire envelope:: + + { + "hookBundles": [ + { + "hookType": "before_agent", + "bundleUrl": "https://url.example.com/...", + "etag": "abc123" + } + ] + } + + Attributes: + hook_bundles: One entry per lifecycle hook that has a compiled + WASM bundle. Empty when no policies are configured for the + tenant. + """ + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + hook_bundles: list[HookBundle] = Field(default_factory=list, alias="hookBundles") + + class FiredRule(BaseModel): """Per-rule metadata carried in the ``/runtime/govern`` payload. From ee4e54f31fcb51dc4ac1ef3321e0dfbcad4c9332 Mon Sep 17 00:00:00 2001 From: Evan Bijoy Date: Fri, 10 Jul 2026 11:54:22 +0530 Subject: [PATCH 2/5] feat: add retrieve_all_policies and download_bundle to GovernanceService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds retrieve_all_policies() / retrieve_all_policies_async() to fetch WASM bundle metadata from /all-policies/{tenant_id}, and download_bundle() / download_bundle_async() for CDN downloads (no platform auth — pre-signed URLs). Re-exports HookBundle and AllPoliciesResponse from uipath.platform.governance. Bumps uipath-platform to 0.2.7. Generated with Claude Code Co-Authored-By: Claude --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/governance/__init__.py | 4 +- .../governance/_governance_service.py | 102 +++++++ .../src/uipath/platform/governance/policy.py | 9 +- .../tests/services/test_governance_service.py | 285 ++++++++++++++++++ 5 files changed, 398 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 7fcf797e7..40955931c 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.6" +version = "0.2.7" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/governance/__init__.py b/packages/uipath-platform/src/uipath/platform/governance/__init__.py index 1d9bdf7ee..89305c2b0 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/governance/__init__.py @@ -8,7 +8,7 @@ from ._governance_provider import UiPathPlatformGovernanceProvider from ._governance_service import GovernanceService from .compensate import FiredRule, GovernRequest -from .policy import PolicyContext, PolicyResponse +from .policy import AllPoliciesResponse, HookBundle, PolicyContext, PolicyResponse # ``_live_track_event_dispatcher.LiveTrackEventDispatcher`` is intentionally # **not** re-exported. It is host-wiring glue (the runtime sink's @@ -20,9 +20,11 @@ # ) __all__ = [ + "AllPoliciesResponse", "FiredRule", "GovernRequest", "GovernanceService", + "HookBundle", "PolicyContext", "PolicyResponse", "UiPathPlatformGovernanceProvider", diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py index 3546517c7..ad65f5a2f 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -7,6 +7,8 @@ - ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating governance call fired when a ``guardrail_fallback`` rule matches (see :meth:`GovernanceService.compensate`). +- ``GET /{org}/agenticgovernance_/api/v1/all-policies/{tenant_id}`` — fetch + WASM bundle metadata for all hooks (see :meth:`GovernanceService.retrieve_all_policies`). A third backend endpoint — ``POST /{org}/agenticgovernance_/api/v1/runtime/log`` — emits custom @@ -23,6 +25,7 @@ from uipath.core import traced from uipath.core.governance import ( + AllPoliciesResponse, FiredRule, GovernRequest, PolicyContext, @@ -44,6 +47,7 @@ POLICY_API_PATH = "api/v1/runtime/policy" GOVERN_API_PATH = "api/v1/runtime/govern" LOG_API_PATH = "api/v1/runtime/log" +ALL_POLICIES_API_PATH = "api/v1/all-policies" AGENT_TYPE_PARAM = "agentType" # Caller-set correlation id that becomes the App Insights ``operation_Id`` @@ -144,6 +148,104 @@ async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: is_conversational=context.is_conversational ) + # ── Rego WASM bundle fetch ──────────────────────────────────────── + + @traced(name="governance_retrieve_all_policies", run_type="uipath") + def retrieve_all_policies(self) -> AllPoliciesResponse: + """Fetch WASM bundle metadata for all hooks for the active tenant. + + Calls ``GET /{org}/agenticgovernance_/api/v1/all-policies/{tenant_id}`` + and returns the list of :class:`HookBundle` objects — one per + lifecycle hook that has a compiled WASM policy bundle. Download + each bundle's bytes separately with :meth:`download_bundle`. + + Returns: + AllPoliciesResponse: List of hook bundles with pre-signed + URLs and ETags for cache-conditional re-fetches. The list + is empty when no Rego policies are configured for the + tenant. + + Raises: + ValueError: If ``UiPathConfig.organization_id`` or + ``UiPathConfig.tenant_id`` is not set. + EnrichedException: If the backend returns a non-2xx response. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + resp = client.governance.retrieve_all_policies() + for bundle in resp.hook_bundles: + data = client.governance.download_bundle(bundle.bundle_url) + ``` + """ + url, headers = self._build_org_scoped_request( + f"{ALL_POLICIES_API_PATH}/{UiPathConfig.tenant_id}" + ) + response = self.request("GET", url=url, headers=headers) + return AllPoliciesResponse.model_validate(response.json()) + + @traced(name="governance_retrieve_all_policies", run_type="uipath") + async def retrieve_all_policies_async(self) -> AllPoliciesResponse: + """Asynchronously fetch WASM bundle metadata for all hooks. + + See :meth:`retrieve_all_policies` for parameter and return semantics. + """ + url, headers = self._build_org_scoped_request( + f"{ALL_POLICIES_API_PATH}/{UiPathConfig.tenant_id}" + ) + response = await self.request_async("GET", url=url, headers=headers) + return AllPoliciesResponse.model_validate(response.json()) + + def download_bundle(self, bundle_url: str) -> bytes: + """Download a WASM bundle from a pre-signed URL. + + The URL returned by :meth:`retrieve_all_policies` is pre-signed + and carries its own credentials — no platform Bearer token is + sent. Callers should cache the result on disk; use the + :attr:`~HookBundle.etag` from :class:`AllPoliciesResponse` to + skip unchanged bundles on subsequent fetches. + + Args: + bundle_url: Pre-signed URL for the WASM ``.tar.gz`` bundle, + as returned in :attr:`HookBundle.bundle_url`. + + Returns: + Raw bytes of the ``.tar.gz`` bundle file. + + Raises: + httpx.HTTPStatusError: If the returns a non-2xx response. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + resp = client.governance.retrieve_all_policies() + for bundle in resp.hook_bundles: + data = client.governance.download_bundle(bundle.bundle_url) + # write data to disk ... + ``` + """ + import httpx + + response = httpx.get(bundle_url, follow_redirects=True) + response.raise_for_status() + return response.content + + async def download_bundle_async(self, bundle_url: str) -> bytes: + """Asynchronously download a WASM bundle from a pre-signed URL. + + See :meth:`download_bundle` for parameter and return semantics. + """ + import httpx + + async with httpx.AsyncClient() as client: + response = await client.get(bundle_url, follow_redirects=True) + response.raise_for_status() + return response.content + # ── Compensating governance call ───────────────────────────────── def compensate( diff --git a/packages/uipath-platform/src/uipath/platform/governance/policy.py b/packages/uipath-platform/src/uipath/platform/governance/policy.py index 27de1c9e7..77e9ab653 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/policy.py +++ b/packages/uipath-platform/src/uipath/platform/governance/policy.py @@ -5,6 +5,11 @@ keeps the existing ``uipath.platform.governance`` import paths working. """ -from uipath.core.governance import PolicyContext, PolicyResponse +from uipath.core.governance import ( + AllPoliciesResponse, + HookBundle, + PolicyContext, + PolicyResponse, +) -__all__ = ["PolicyContext", "PolicyResponse"] +__all__ = ["AllPoliciesResponse", "HookBundle", "PolicyContext", "PolicyResponse"] diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py index eb4941faf..d8966e56c 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -13,8 +13,10 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.common import resolve_trace_id from uipath.platform.governance import ( + AllPoliciesResponse, FiredRule, GovernanceService, + HookBundle, PolicyResponse, ) @@ -932,3 +934,286 @@ def test_falls_through_when_uipath_trace_id_is_malformed( # No OTel context active → falls through to caller-supplied fallback. assert resolve_trace_id(fallback="recovered") == "recovered" + + +_ALL_POLICIES_RESPONSE = { + "hookBundles": [ + { + "hookType": "before_agent", + "bundleUrl": "https://url.example.com/before_agent.tar.gz", + "etag": "etag-abc123", + }, + { + "hookType": "after_agent", + "bundleUrl": "https://url.example.com/after_agent.tar.gz", + "etag": None, + }, + ] +} + + +class TestRetrieveAllPolicies: + """Test retrieve_all_policies (sync) and retrieve_all_policies_async.""" + + def test_returns_parsed_response( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + status_code=200, + json=_ALL_POLICIES_RESPONSE, + ) + + result = service.retrieve_all_policies() + + assert isinstance(result, AllPoliciesResponse) + assert len(result.hook_bundles) == 2 + assert isinstance(result.hook_bundles[0], HookBundle) + assert result.hook_bundles[0].hook_type == "before_agent" + assert result.hook_bundles[0].bundle_url == ( + "https://url.example.com/before_agent.tar.gz" + ) + assert result.hook_bundles[0].etag == "etag-abc123" + assert result.hook_bundles[1].hook_type == "after_agent" + assert result.hook_bundles[1].etag is None + + def test_returns_empty_list_when_no_bundles( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + status_code=200, + json={"hookBundles": []}, + ) + + result = service.retrieve_all_policies() + + assert result.hook_bundles == [] + + def test_sends_tenant_header_and_bearer_token( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + secret: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json=_ALL_POLICIES_RESPONSE) + + httpx_mock.add_callback( + capture, + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + ) + + service.retrieve_all_policies() + + request = captured["request"] + assert request.method == "GET" + assert request.headers["x-uipath-internal-tenantid"] == TENANT_ID + assert request.headers["authorization"] == f"Bearer {secret}" + + def test_raises_when_organization_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService(config=config, execution_context=execution_context) + + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + service.retrieve_all_policies() + + def test_raises_when_tenant_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + service = GovernanceService(config=config, execution_context=execution_context) + + with pytest.raises(ValueError, match="UIPATH_TENANT_ID"): + service.retrieve_all_policies() + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + from uipath.platform.errors import EnrichedException + + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + status_code=500, + text="server error", + ) + + with pytest.raises(EnrichedException): + service.retrieve_all_policies() + + def test_redirects_to_service_url_override( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv( + "UIPATH_SERVICE_URL_AGENTICGOVERNANCE", "http://localhost:8123" + ) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json=_ALL_POLICIES_RESPONSE) + + httpx_mock.add_callback( + capture, + url=f"http://localhost:8123/api/v1/all-policies/{TENANT_ID}", + ) + + service.retrieve_all_policies() + + request = captured["request"] + assert request.headers["X-UiPath-Internal-AccountId"] == ORG_ID + assert ORG_ID not in str(request.url) + + async def test_async_returns_parsed_response( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + status_code=200, + json=_ALL_POLICIES_RESPONSE, + ) + + result = await service.retrieve_all_policies_async() + + assert isinstance(result, AllPoliciesResponse) + assert len(result.hook_bundles) == 2 + assert result.hook_bundles[0].hook_type == "before_agent" + + async def test_async_raises_when_tenant_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + service = GovernanceService(config=config, execution_context=execution_context) + + with pytest.raises(ValueError, match="UIPATH_TENANT_ID"): + await service.retrieve_all_policies_async() + + +class TestDownloadBundle: + """Test download_bundle (sync) and download_bundle_async.""" + + BUNDLE_URL = "https://url.example.com/bundle.tar.gz" + BUNDLE_BYTES = b"fake-wasm-bundle-content" + + def test_returns_raw_bytes( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + httpx_mock.add_response( + url=self.BUNDLE_URL, + status_code=200, + content=self.BUNDLE_BYTES, + ) + + result = service.download_bundle(self.BUNDLE_URL) + + assert result == self.BUNDLE_BYTES + + def test_does_not_send_bearer_token( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, content=self.BUNDLE_BYTES) + + httpx_mock.add_callback(capture, url=self.BUNDLE_URL) + + service.download_bundle(self.BUNDLE_URL) + + assert "authorization" not in captured["request"].headers + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + httpx_mock.add_response( + url=self.BUNDLE_URL, + status_code=403, + text="forbidden", + ) + + with pytest.raises(httpx.HTTPStatusError): + service.download_bundle(self.BUNDLE_URL) + + async def test_async_returns_raw_bytes( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + httpx_mock.add_response( + url=self.BUNDLE_URL, + status_code=200, + content=self.BUNDLE_BYTES, + ) + + result = await service.download_bundle_async(self.BUNDLE_URL) + + assert result == self.BUNDLE_BYTES + + async def test_async_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + httpx_mock.add_response( + url=self.BUNDLE_URL, + status_code=404, + text="not found", + ) + + with pytest.raises(httpx.HTTPStatusError): + await service.download_bundle_async(self.BUNDLE_URL) From afc9727cc9bdae2f33d40a0d56ca625a50f12ad2 Mon Sep 17 00:00:00 2001 From: Evan Bijoy Date: Fri, 10 Jul 2026 11:54:27 +0530 Subject: [PATCH 3/5] feat: wire Rego evaluator into governance CLI bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds REGO_FEATURE_FLAG gate to resolve_governance(): when EnablePythonGovernanceRegoEvaluator is on, calls build_rego_evaluator_async(sdk.governance) and passes the result into GovernanceBootstrap.wrap_runtime() → UiPathGovernedRuntime. Mirrors the native evaluator bootstrap pattern exactly. Generated with Claude Code Co-Authored-By: Claude --- .../src/uipath/_cli/_governance_bootstrap.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_governance_bootstrap.py b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py index 675b42721..79830dedc 100644 --- a/packages/uipath/src/uipath/_cli/_governance_bootstrap.py +++ b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py @@ -10,10 +10,10 @@ import atexit import logging from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from uipath.core.governance import EnforcementMode, PolicyContext -from uipath.core.governance.config import is_governance_enabled +from uipath.core.governance.config import is_governance_enabled, is_rego_enabled from uipath.platform import UiPath from uipath.platform.governance import UiPathPlatformGovernanceProvider from uipath.platform.governance._live_track_event_dispatcher import ( @@ -27,6 +27,7 @@ GuardrailCompensator, ) from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.rego import RegoEvaluator from uipath.runtime.governance.runtime import UiPathGovernedRuntime from ._governance import build_policy_index_from_yaml @@ -54,6 +55,7 @@ class GovernanceBootstrap: policy_index: PolicyIndex enforcement_mode: EnforcementMode dispose: Callable[[], None] + rego_evaluator: RegoEvaluator | None = field(default=None) def wrap_runtime( self, @@ -68,6 +70,7 @@ def wrap_runtime( policy_index=self.policy_index, enforcement_mode=self.enforcement_mode, evaluator=self.evaluator, + rego_evaluator=self.rego_evaluator, agent_name=agent_name, runtime_id=runtime_id, ) @@ -166,9 +169,26 @@ def dispose() -> None: ) return None + rego_evaluator: RegoEvaluator | None = None + if is_rego_enabled(): + try: + from uipath.runtime.governance.rego import build_rego_evaluator_async + + rego_evaluator = await build_rego_evaluator_async(sdk.governance) + if rego_evaluator is not None: + console.info( + f"Rego governance enabled " + f"(hooks={[h.value for h in rego_evaluator.loaded_hooks]})" + ) + except Exception as exc: + console.warning( + f"Rego governance setup failed - continuing without Rego evaluation: {exc}" + ) + return GovernanceBootstrap( evaluator=evaluator, policy_index=policy_index, enforcement_mode=response.mode, dispose=dispose, + rego_evaluator=rego_evaluator, ) From fa6ccec52522f8cf28d8b6a90aa149854a09c787 Mon Sep 17 00:00:00 2001 From: Evan Bijoy Date: Sun, 12 Jul 2026 23:17:19 +0530 Subject: [PATCH 4/5] Platform: fix all-policies path (tenant via header not URL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /all-policies endpoint does not take a tenant_id URL segment — the target tenant is resolved from the x-uipath-internal-tenantid header that _build_org_scoped_request already injects. Generated with Claude Code Co-Authored-By: Claude --- .../platform/governance/_governance_service.py | 16 +++++++++------- .../tests/services/test_governance_service.py | 12 ++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py index ad65f5a2f..1ca87fb13 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -7,7 +7,7 @@ - ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating governance call fired when a ``guardrail_fallback`` rule matches (see :meth:`GovernanceService.compensate`). -- ``GET /{org}/agenticgovernance_/api/v1/all-policies/{tenant_id}`` — fetch +- ``GET /{org}/agenticgovernance_/api/v1/all-policies`` — fetch WASM bundle metadata for all hooks (see :meth:`GovernanceService.retrieve_all_policies`). A third backend endpoint — @@ -154,10 +154,12 @@ async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: def retrieve_all_policies(self) -> AllPoliciesResponse: """Fetch WASM bundle metadata for all hooks for the active tenant. - Calls ``GET /{org}/agenticgovernance_/api/v1/all-policies/{tenant_id}`` - and returns the list of :class:`HookBundle` objects — one per - lifecycle hook that has a compiled WASM policy bundle. Download - each bundle's bytes separately with :meth:`download_bundle`. + Calls ``GET /{org}/agenticgovernance_/api/v1/all-policies`` and + returns the list of :class:`HookBundle` objects — one per + lifecycle hook that has a compiled WASM policy bundle. The target + tenant is resolved from the ``x-uipath-internal-tenantid`` header + injected by :meth:`_build_org_scoped_request`. Download each + bundle's bytes separately with :meth:`download_bundle`. Returns: AllPoliciesResponse: List of hook bundles with pre-signed @@ -181,7 +183,7 @@ def retrieve_all_policies(self) -> AllPoliciesResponse: ``` """ url, headers = self._build_org_scoped_request( - f"{ALL_POLICIES_API_PATH}/{UiPathConfig.tenant_id}" + ALL_POLICIES_API_PATH ) response = self.request("GET", url=url, headers=headers) return AllPoliciesResponse.model_validate(response.json()) @@ -193,7 +195,7 @@ async def retrieve_all_policies_async(self) -> AllPoliciesResponse: See :meth:`retrieve_all_policies` for parameter and return semantics. """ url, headers = self._build_org_scoped_request( - f"{ALL_POLICIES_API_PATH}/{UiPathConfig.tenant_id}" + ALL_POLICIES_API_PATH ) response = await self.request_async("GET", url=url, headers=headers) return AllPoliciesResponse.model_validate(response.json()) diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py index d8966e56c..f4c5de9d4 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -964,7 +964,7 @@ def test_returns_parsed_response( httpx_mock.add_response( url=( f"{base_url}/{ORG_ID}/agenticgovernance_" - f"/api/v1/all-policies/{TENANT_ID}" + f"/api/v1/all-policies" ), status_code=200, json=_ALL_POLICIES_RESPONSE, @@ -992,7 +992,7 @@ def test_returns_empty_list_when_no_bundles( httpx_mock.add_response( url=( f"{base_url}/{ORG_ID}/agenticgovernance_" - f"/api/v1/all-policies/{TENANT_ID}" + f"/api/v1/all-policies" ), status_code=200, json={"hookBundles": []}, @@ -1019,7 +1019,7 @@ def capture(request: httpx.Request) -> httpx.Response: capture, url=( f"{base_url}/{ORG_ID}/agenticgovernance_" - f"/api/v1/all-policies/{TENANT_ID}" + f"/api/v1/all-policies" ), ) @@ -1067,7 +1067,7 @@ def test_raises_on_http_error( httpx_mock.add_response( url=( f"{base_url}/{ORG_ID}/agenticgovernance_" - f"/api/v1/all-policies/{TENANT_ID}" + f"/api/v1/all-policies" ), status_code=500, text="server error", @@ -1093,7 +1093,7 @@ def capture(request: httpx.Request) -> httpx.Response: httpx_mock.add_callback( capture, - url=f"http://localhost:8123/api/v1/all-policies/{TENANT_ID}", + url=f"http://localhost:8123/api/v1/all-policies", ) service.retrieve_all_policies() @@ -1111,7 +1111,7 @@ async def test_async_returns_parsed_response( httpx_mock.add_response( url=( f"{base_url}/{ORG_ID}/agenticgovernance_" - f"/api/v1/all-policies/{TENANT_ID}" + f"/api/v1/all-policies" ), status_code=200, json=_ALL_POLICIES_RESPONSE, From deb3998f6e3a82dabb25b734dff9ac765e63dc31 Mon Sep 17 00:00:00 2001 From: Evan Bijoy Date: Sun, 12 Jul 2026 23:36:31 +0530 Subject: [PATCH 5/5] fix: guard rego import with TYPE_CHECKING, add Rego bootstrap tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level `from uipath.runtime.governance.rego import RegoEvaluator` in _governance_bootstrap.py caused an ImportError during test collection when the installed uipath-runtime predates the rego submodule. Guard it with TYPE_CHECKING (annotations are strings via `from __future__ import annotations`) so it's only evaluated by type checkers, not at runtime. Add three tests covering the previously-uncovered Rego bootstrap path in resolve_governance (is_rego_enabled → build_rego_evaluator_async: success, returns None, raises). Also update the wrap_runtime test to patch UiPathGovernedRuntime with a forward-compatible subclass so the test is not gated on the dependency version. Generated with Claude Code Co-Authored-By: Claude --- .../src/uipath/_cli/_governance_bootstrap.py | 5 +- .../tests/cli/test_governance_bootstrap.py | 144 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/uipath/src/uipath/_cli/_governance_bootstrap.py b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py index 79830dedc..3e6ebe379 100644 --- a/packages/uipath/src/uipath/_cli/_governance_bootstrap.py +++ b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py @@ -11,6 +11,7 @@ import logging from collections.abc import Callable from dataclasses import dataclass, field +from typing import TYPE_CHECKING from uipath.core.governance import EnforcementMode, PolicyContext from uipath.core.governance.config import is_governance_enabled, is_rego_enabled @@ -27,9 +28,11 @@ GuardrailCompensator, ) from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.rego import RegoEvaluator from uipath.runtime.governance.runtime import UiPathGovernedRuntime +if TYPE_CHECKING: + from uipath.runtime.governance.rego import RegoEvaluator + from ._governance import build_policy_index_from_yaml from ._utils._console import ConsoleLogger diff --git a/packages/uipath/tests/cli/test_governance_bootstrap.py b/packages/uipath/tests/cli/test_governance_bootstrap.py index fabe3650c..0cb1a495a 100644 --- a/packages/uipath/tests/cli/test_governance_bootstrap.py +++ b/packages/uipath/tests/cli/test_governance_bootstrap.py @@ -575,6 +575,19 @@ async def test_wrap_runtime_produces_governed_runtime_with_bootstrap_fields( ), ) + # The installed uipath-runtime may not yet accept rego_evaluator — + # patch in a forward-compatible subclass so this test isn't gated on + # the dependency version. + class _UiPathGovernedRuntimeWithRego(UiPathGovernedRuntime): + def __init__(self, delegate: Any, *, rego_evaluator: Any = None, **kwargs: Any) -> None: + super().__init__(delegate, **kwargs) + self._rego_evaluator = rego_evaluator + + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.UiPathGovernedRuntime", + _UiPathGovernedRuntimeWithRego, + ) + result = await resolve_governance( agent_framework="langgraph", agent_type="uipath_coded", @@ -856,3 +869,134 @@ def _capture_unregister(func: Any) -> None: assert len(unregistered_arg) == 1 # Same bound method → same underlying dispatcher shutdown. assert registered_arg[0] == unregistered_arg[0] + + # ------------------------------------------------------------------ + # Rego evaluator bootstrap path + # ------------------------------------------------------------------ + + def _setup_successful_governance( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Common setup for a successful governance bootstrap (no Rego).""" + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_governance_enabled", + lambda: True, + ) + _install_fake_runtime_governance( + monkeypatch, + audit_manager_cls=_FakeAuditManager, + metadata_cls=_FakeMetadata, + evaluator_cls=_FakeEvaluator, + compensator_cls=_FakeCompensator, + ) + _stub_provider( + monkeypatch, + response_or_exc=_fake_policy_response( + mode=EnforcementMode.ENFORCE, policies="rules: []" + ), + ) + + def _stub_rego_module( + self, + monkeypatch: pytest.MonkeyPatch, + *, + return_value: Any = None, + side_effect: Any = None, + ) -> None: + """Inject a fake ``uipath.runtime.governance.rego`` into sys.modules + so the lazy import inside ``resolve_governance`` succeeds. + """ + import sys + + fake_rego = MagicMock() + if side_effect is not None: + fake_rego.build_rego_evaluator_async = AsyncMock(side_effect=side_effect) + else: + fake_rego.build_rego_evaluator_async = AsyncMock(return_value=return_value) + monkeypatch.setitem(sys.modules, "uipath.runtime.governance.rego", fake_rego) + + async def test_rego_evaluator_populated_when_rego_enabled_and_build_succeeds( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """When ``is_rego_enabled()`` is ``True`` and + ``build_rego_evaluator_async`` returns a non-None evaluator, the + bootstrap's ``rego_evaluator`` field must be set to that object. + """ + self._setup_successful_governance(monkeypatch) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_rego_enabled", + lambda: True, + ) + mock_rego_evaluator = MagicMock() + mock_rego_evaluator.loaded_hooks = [] + self._stub_rego_module(monkeypatch, return_value=mock_rego_evaluator) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is not None + try: + assert result.rego_evaluator is mock_rego_evaluator + finally: + result.dispose() + + async def test_rego_evaluator_none_when_rego_enabled_but_build_returns_none( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """When ``build_rego_evaluator_async`` returns ``None`` (e.g. no + bundles available), ``rego_evaluator`` must stay ``None`` and + governance still succeeds. + """ + self._setup_successful_governance(monkeypatch) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_rego_enabled", + lambda: True, + ) + self._stub_rego_module(monkeypatch, return_value=None) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is not None + try: + assert result.rego_evaluator is None + finally: + result.dispose() + + async def test_rego_evaluator_none_when_build_raises( + self, + monkeypatch: pytest.MonkeyPatch, + cwd: Path, + ) -> None: + """Exceptions in ``build_rego_evaluator_async`` must be swallowed — + a Rego failure must not crash the bootstrap. ``rego_evaluator`` + stays ``None`` and governance returns a valid (non-Rego) bootstrap. + """ + self._setup_successful_governance(monkeypatch) + monkeypatch.setattr( + "uipath._cli._governance_bootstrap.is_rego_enabled", + lambda: True, + ) + self._stub_rego_module( + monkeypatch, side_effect=RuntimeError("Rego bundle unavailable") + ) + + result = await resolve_governance( + agent_framework="langgraph", + agent_type="uipath_coded", + is_conversational=False, + ) + assert result is not None + try: + assert result.rego_evaluator is None + finally: + result.dispose()