From 79fd38f21637977e342a6dcfcfa6f772c7ec50d7 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Fri, 12 Jun 2026 16:27:47 +0530 Subject: [PATCH 01/18] feat(governance): enforcement-mode config, policy models, deps Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 26 +++ src/uipath/runtime/governance/config.py | 78 ++++++++ .../runtime/governance/native/models.py | 153 +++++++++++++++ tests/conftest.py | 22 +++ tests/test_enforcement_mode_default.py | 87 +++++++++ uv.lock | 183 +++++++++++++++++- 6 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 src/uipath/runtime/governance/config.py create mode 100644 src/uipath/runtime/governance/native/models.py create mode 100644 tests/test_enforcement_mode_default.py diff --git a/pyproject.toml b/pyproject.toml index ec055aa3..6699f8b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,12 @@ readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.22,<0.6.0", + # Governance native-evaluator deps. Live here because the native + # evaluator implementation lives in uipath.runtime.governance.native; + # uipath-core only carries the small governance contracts. + "pyyaml>=6.0", + "vaderSentiment>=3.3.2", # sentiment_concern (A.3.3) + "chardet>=5.2.0", # encoding_concern (A.7.4) ] classifiers = [ "Intended Audience :: Developers", @@ -40,6 +46,7 @@ dev = [ "pytest-cov>=4.1.0", "pytest-mock>=3.11.1", "pre-commit>=4.1.0", + "types-PyYAML>=6.0", ] [tool.hatch.build.targets.wheel] @@ -83,6 +90,25 @@ no_implicit_reexport = true disallow_untyped_defs = false +# Third-party governance-evaluator libs have no type stubs / py.typed marker +[[tool.mypy.overrides]] +module = [ + "yaml", + "vaderSentiment.*", + "chardet", + "price_parser", + # uipath.platform.common is imported lazily from traces.py / audit + # sinks to read UiPathConfig context attributes. It's first-party but + # not a uipath-runtime dep, so its stubs aren't installable here. + "uipath.platform.*", + # Optional framework adapters; the absence of the framework simply + # means the adapter no-ops at import time. + "agents", + "langchain_core.*", + "langgraph.*", +] +ignore_missing_imports = true + [tool.pydantic-mypy] init_forbid_extra = true init_typed = true diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py new file mode 100644 index 00000000..51644fbe --- /dev/null +++ b/src/uipath/runtime/governance/config.py @@ -0,0 +1,78 @@ +"""Runtime-level governance enforcement-mode state. + +The feature-flag gate (``is_governance_enabled``) lives in +:mod:`uipath.core.governance.config` because it is process-level and +must be resolvable by callers that do not depend on +``uipath-runtime``. The enforcement mode is *per-policy* — set by the +backend on each policy fetch via the ``/runtime/policy`` endpoint — +and therefore lives here in the runtime package alongside the policy +loader that applies it. +""" + +from __future__ import annotations + +import logging +import os +from enum import Enum + +logger = logging.getLogger(__name__) + +ENV_ENFORCEMENT_MODE = "UIPATH_GOVERNANCE_MODE" + + +class EnforcementMode(str, Enum): + """Governance enforcement modes.""" + + AUDIT = "audit" # Evaluate and log; never block. + ENFORCE = "enforce" # Block on DENY rules. + DISABLED = "disabled" # Skip evaluation entirely. + + +_enforcement_mode: EnforcementMode | None = None + + +def get_enforcement_mode() -> EnforcementMode: + """Return the current enforcement mode. + + The mode is cached after first read. Resolution order: + + 1. A value previously set via :func:`set_enforcement_mode` (the + policy loader calls this with the backend-supplied mode on every + successful policy fetch — that's the canonical source). + 2. ``UIPATH_GOVERNANCE_MODE`` env var (developer override). + 3. Default :attr:`EnforcementMode.AUDIT` — evaluate and log without + blocking. The wrapper attaches at runtime construction so the + background policy fetch can run; if the backend returns + ``disabled``, ``set_enforcement_mode`` flips the cache and + subsequent ``evaluate()`` calls short-circuit at evaluator.py:332. + Defaulting to AUDIT avoids the chicken-and-egg where a DISABLED + default would short-circuit before the policy fetch could ever + opt the tenant in. + """ + global _enforcement_mode + if _enforcement_mode is not None: + return _enforcement_mode + + mode_str = os.getenv(ENV_ENFORCEMENT_MODE, "audit").lower() + try: + _enforcement_mode = EnforcementMode(mode_str) + except ValueError: + _enforcement_mode = EnforcementMode.AUDIT + + return _enforcement_mode + + +def set_enforcement_mode(mode: EnforcementMode) -> None: + """Set the enforcement mode programmatically. + + The policy loader calls this with the backend-supplied mode on each + fetch so the evaluator picks up the platform-controlled value. + """ + global _enforcement_mode + _enforcement_mode = mode + + +def reset_enforcement_mode() -> None: + """Clear cached enforcement mode (intended for tests).""" + global _enforcement_mode + _enforcement_mode = None diff --git a/src/uipath/runtime/governance/native/models.py b/src/uipath/runtime/governance/native/models.py new file mode 100644 index 00000000..d021d816 --- /dev/null +++ b/src/uipath/runtime/governance/native/models.py @@ -0,0 +1,153 @@ +"""Native policy model. + +Rules, checks, conditions and pack indexes consumed by +:class:`uipath.runtime.governance.native.evaluator.GovernanceEvaluator`. + +These are the inputs of the native evaluator. The evaluator-agnostic +*output* types (``Action``, ``AuditRecord``, …) live in +:mod:`uipath.core.governance.models`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from uipath.core.governance.models import Action, LifecycleHook + + +class Severity(Enum): + """Rule severity levels.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +@dataclass +class Condition: + """A single condition within a rule check.""" + + operator: str + field: str + value: Any + negate: bool = False + + +@dataclass +class Check: + """A check within a rule - contains conditions and action.""" + + conditions: list[Condition] + action: Action = Action.DENY + message: str = "" + logic: str = "all" # "all" (AND) or "any" (OR) + + +@dataclass +class Rule: + """A compliance rule with checks evaluated at a specific lifecycle hook.""" + + rule_id: str + name: str + clause: str + hook: LifecycleHook + action: Action + severity: Severity = Severity.HIGH + checks: list[Check] = field(default_factory=list) + enabled: bool = True + description: str = "" + pack_name: str = "" + + # Approval configuration (for ESCALATE action) + approval_config: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class CheckContext: + """Context passed to rule evaluation.""" + + hook: LifecycleHook + agent_name: str + runtime_id: str + trace_id: str + + # Content fields (populated based on hook) + agent_input: str = "" + agent_output: str = "" + model_input: str = "" + model_output: str = "" + model_name: str = ( + "" # LLM model name (e.g., "gpt-4", "claude-3-opus") - available at agent start + ) + tool_name: str = "" + tool_args: dict[str, Any] = field(default_factory=dict) + tool_result: str = "" + messages: list[dict[str, Any]] = field(default_factory=list) + + # Session state + session_state: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + # Ring level (privilege level: 0=system, 1=admin, 2=user, 3=untrusted) + ring: int = 2 + + +@dataclass +class PolicyPack: + """A collection of rules for a compliance standard.""" + + name: str + version: str + description: str + rules: list[Rule] + enabled: bool = True + + +@dataclass +class PolicyIndex: + """Index of all loaded policy packs and rules.""" + + packs: dict[str, PolicyPack] = field(default_factory=dict) + _rules_by_id: dict[str, Rule] = field(default_factory=dict) + _rules_by_hook: dict[LifecycleHook, list[Rule]] = field(default_factory=dict) + + def add_pack(self, pack: PolicyPack) -> None: + """Add a policy pack to the index.""" + self.packs[pack.name] = pack + for rule in pack.rules: + rule.pack_name = pack.name + self._rules_by_id[rule.rule_id] = rule + if rule.hook not in self._rules_by_hook: + self._rules_by_hook[rule.hook] = [] + self._rules_by_hook[rule.hook].append(rule) + + def get_rule(self, rule_id: str) -> Rule | None: + """Get a rule by ID.""" + return self._rules_by_id.get(rule_id) + + def get_rules_for_hook(self, hook: LifecycleHook) -> list[Rule]: + """Get all rules for a lifecycle hook.""" + return self._rules_by_hook.get(hook, []) + + def get_rules_for_pack(self, pack_name: str) -> list[Rule]: + """Get all rules for a pack.""" + pack = self.packs.get(pack_name) + return pack.rules if pack else [] + + @property + def pack_names(self) -> list[str]: + """Get all pack names.""" + return list(self.packs.keys()) + + @property + def total_rules(self) -> int: + """Get total number of rules.""" + return len(self._rules_by_id) + + @property + def all_rules(self) -> list[Rule]: + """Get all rules.""" + return list(self._rules_by_id.values()) diff --git a/tests/conftest.py b/tests/conftest.py index 2556e753..6f1d1462 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,25 @@ def temp_dir() -> Generator[str, None, None]: """Provide a temporary directory for test files.""" with tempfile.TemporaryDirectory() as tmp_dir: yield tmp_dir + + +@pytest.fixture(autouse=True) +def _reset_governance_process_state() -> Generator[None, None, None]: + """Clear process-level governance state around every test. + + The native governance layer keeps two pieces of state at module scope: + the conversational/autonomous selector consumed by the policy fetch, + and the memoized job-context. Both are stable per process in + production but leak across tests when not reset, masking ordering + bugs and producing flakes. + """ + from uipath.runtime.governance.native.backend_client import ( + resolve_job_context, + set_agent_conversational, + ) + + set_agent_conversational(None) + resolve_job_context.cache_clear() + yield + set_agent_conversational(None) + resolve_job_context.cache_clear() diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py new file mode 100644 index 00000000..53350a10 --- /dev/null +++ b/tests/test_enforcement_mode_default.py @@ -0,0 +1,87 @@ +"""Tests for the default enforcement-mode resolution. + +The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at +runtime construction and the background policy fetch can run. If the +backend later returns ``disabled``, ``set_enforcement_mode`` flips the +cache and ``evaluate()`` short-circuits per-call. + +Resolution order (per :func:`get_enforcement_mode`): +1. Previously-cached programmatic value (set via ``set_enforcement_mode``). +2. ``UIPATH_GOVERNANCE_MODE`` env var. +3. Default ``AUDIT``. +""" + +from __future__ import annotations + +import pytest + +from uipath.runtime.governance.config import ( + EnforcementMode, + get_enforcement_mode, + reset_enforcement_mode, + set_enforcement_mode, +) + + +@pytest.fixture(autouse=True) +def _isolate_mode(monkeypatch: pytest.MonkeyPatch): + """Each test starts from a clean module-state slate.""" + monkeypatch.delenv("UIPATH_GOVERNANCE_MODE", raising=False) + reset_enforcement_mode() + yield + reset_enforcement_mode() + + +def test_default_mode_is_audit() -> None: + """No programmatic mode + no env var → AUDIT. + + AUDIT is the default so the wrapper attaches and the background + policy fetch can run. The backend can flip the cache to DISABLED + on fetch when the tenant has no policies. + """ + assert get_enforcement_mode() is EnforcementMode.AUDIT + + +def test_env_var_disabled_wins_over_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Developer override via env var still works.""" + monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "disabled") + reset_enforcement_mode() # clear cached default + assert get_enforcement_mode() is EnforcementMode.DISABLED + + +def test_env_var_enforce_wins_over_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "enforce") + reset_enforcement_mode() + assert get_enforcement_mode() is EnforcementMode.ENFORCE + + +def test_invalid_env_var_falls_back_to_audit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "garbage-value") + reset_enforcement_mode() + assert get_enforcement_mode() is EnforcementMode.AUDIT + + +def test_programmatic_set_wins_over_env_and_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The policy loader's ``set_enforcement_mode`` call is canonical.""" + monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "audit") + set_enforcement_mode(EnforcementMode.ENFORCE) + assert get_enforcement_mode() is EnforcementMode.ENFORCE + + +def test_reset_returns_to_default() -> None: + """``reset_enforcement_mode`` clears the cache so the default re-applies.""" + set_enforcement_mode(EnforcementMode.ENFORCE) + assert get_enforcement_mode() is EnforcementMode.ENFORCE + reset_enforcement_mode() + assert get_enforcement_mode() is EnforcementMode.AUDIT + + +def test_audit_mode_is_cached_after_first_read() -> None: + """First call computes; subsequent calls hit the cache.""" + assert get_enforcement_mode() is EnforcementMode.AUDIT + # A second call returns the same instance — the cache survives. + assert get_enforcement_mode() is EnforcementMode.AUDIT diff --git a/uv.lock b/uv.lock index 0a07a221..3525e2fc 100644 --- a/uv.lock +++ b/uv.lock @@ -99,6 +99,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -821,6 +947,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -975,6 +1116,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1015,7 +1165,10 @@ name = "uipath-runtime" version = "0.11.4" source = { editable = "." } dependencies = [ + { name = "chardet" }, + { name = "pyyaml" }, { name = "uipath-core" }, + { name = "vadersentiment" }, ] [package.dev-dependencies] @@ -1031,10 +1184,16 @@ dev = [ { name = "pytest-trio" }, { name = "ruff" }, { name = "rust-just" }, + { name = "types-pyyaml" }, ] [package.metadata] -requires-dist = [{ name = "uipath-core", specifier = ">=0.5.22,<0.6.0" }] +requires-dist = [ + { name = "chardet", specifier = ">=5.2.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "uipath-core", specifier = ">=0.5.18,<0.6.0" }, + { name = "vadersentiment", specifier = ">=3.3.2" }, +] [package.metadata.requires-dev] dev = [ @@ -1049,6 +1208,28 @@ dev = [ { name = "pytest-trio", specifier = ">=0.8.0" }, { name = "ruff", specifier = ">=0.9.4" }, { name = "rust-just", specifier = ">=1.39.0" }, + { name = "types-pyyaml", specifier = ">=6.0" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "vadersentiment" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" }, ] [[package]] From bb7729067189618991297e1b1e3a12ee6ca94b75 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Tue, 16 Jun 2026 13:12:12 +0530 Subject: [PATCH 02/18] =?UTF-8?q?fix(governance):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20use=20logger=20on=20invalid=20mode,=20drop=20for?= =?UTF-8?q?ward=20refs=20in=20docstrings,=20guard=20backend=5Fclient=20imp?= =?UTF-8?q?ort=20in=20conftest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/uipath/runtime/governance/config.py | 11 +++++++++-- src/uipath/runtime/governance/native/models.py | 4 ++-- tests/conftest.py | 16 ++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py index 51644fbe..d2bbfc92 100644 --- a/src/uipath/runtime/governance/config.py +++ b/src/uipath/runtime/governance/config.py @@ -44,8 +44,9 @@ def get_enforcement_mode() -> EnforcementMode: blocking. The wrapper attaches at runtime construction so the background policy fetch can run; if the backend returns ``disabled``, ``set_enforcement_mode`` flips the cache and - subsequent ``evaluate()`` calls short-circuit at evaluator.py:332. - Defaulting to AUDIT avoids the chicken-and-egg where a DISABLED + subsequent ``evaluate()`` calls short-circuit (the evaluator + skips evaluation in disabled mode). Defaulting to AUDIT avoids + the chicken-and-egg where a DISABLED default would short-circuit before the policy fetch could ever opt the tenant in. """ @@ -57,6 +58,12 @@ def get_enforcement_mode() -> EnforcementMode: try: _enforcement_mode = EnforcementMode(mode_str) except ValueError: + logger.warning( + "Invalid %s=%r; defaulting to %s", + ENV_ENFORCEMENT_MODE, + mode_str, + EnforcementMode.AUDIT.value, + ) _enforcement_mode = EnforcementMode.AUDIT return _enforcement_mode diff --git a/src/uipath/runtime/governance/native/models.py b/src/uipath/runtime/governance/native/models.py index d021d816..9145d9ee 100644 --- a/src/uipath/runtime/governance/native/models.py +++ b/src/uipath/runtime/governance/native/models.py @@ -1,7 +1,7 @@ """Native policy model. -Rules, checks, conditions and pack indexes consumed by -:class:`uipath.runtime.governance.native.evaluator.GovernanceEvaluator`. +Rules, checks, conditions and pack indexes consumed by the native +governance evaluator. These are the inputs of the native evaluator. The evaluator-agnostic *output* types (``Action``, ``AuditRecord``, …) live in diff --git a/tests/conftest.py b/tests/conftest.py index 6f1d1462..e337e968 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,11 +28,19 @@ def _reset_governance_process_state() -> Generator[None, None, None]: and the memoized job-context. Both are stable per process in production but leak across tests when not reset, masking ordering bugs and producing flakes. + + ``backend_client`` is imported lazily and guarded: this shared + conftest ships alongside the foundation slice, where that module may + not exist yet, and the reset is simply a no-op until it does. """ - from uipath.runtime.governance.native.backend_client import ( - resolve_job_context, - set_agent_conversational, - ) + try: + from uipath.runtime.governance.native.backend_client import ( + resolve_job_context, + set_agent_conversational, + ) + except ImportError: + yield + return set_agent_conversational(None) resolve_job_context.cache_clear() From 319c5fb523833e3fd1e8d5b5d60415261d42cd22 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Tue, 16 Jun 2026 22:21:47 +0530 Subject: [PATCH 03/18] fix(governance): address review on enforcement-mode config and models - config.py: enforcement mode no longer reads the UIPATH_GOVERNANCE_MODE env var directly (the backend /runtime/policy response is the source); default is AUDIT. Mode state lives in a holder object instead of a module global (no `global` statements). The test-only reset helper moved out to tests/_helpers.py so test concerns stay in the test tree. - models.py: Check.logic is now the Logic(str, Enum) instead of a free-form string; tidy the CheckContext.model_name field. - pyproject: add dependency upper bounds (pyyaml<7, vaderSentiment<4, chardet<8); remove the [[tool.mypy.overrides]] block. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 30 +------ src/uipath/runtime/governance/config.py | 81 +++++++------------ .../runtime/governance/native/models.py | 13 ++- tests/_helpers.py | 19 +++++ tests/test_enforcement_mode_default.py | 57 ++++--------- uv.lock | 6 +- 6 files changed, 80 insertions(+), 126 deletions(-) create mode 100644 tests/_helpers.py diff --git a/pyproject.toml b/pyproject.toml index 6699f8b6..48f7483c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,13 +5,10 @@ description = "Runtime abstractions and interfaces for building agents and autom readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.22,<0.6.0", - # Governance native-evaluator deps. Live here because the native - # evaluator implementation lives in uipath.runtime.governance.native; - # uipath-core only carries the small governance contracts. - "pyyaml>=6.0", - "vaderSentiment>=3.3.2", # sentiment_concern (A.3.3) - "chardet>=5.2.0", # encoding_concern (A.7.4) + "uipath-core>=0.5.22, <0.6.0", + "pyyaml>=6.0, <7.0", + "vaderSentiment>=3.3.2, <4.0", + "chardet>=5.2.0, <8.0", ] classifiers = [ "Intended Audience :: Developers", @@ -90,25 +87,6 @@ no_implicit_reexport = true disallow_untyped_defs = false -# Third-party governance-evaluator libs have no type stubs / py.typed marker -[[tool.mypy.overrides]] -module = [ - "yaml", - "vaderSentiment.*", - "chardet", - "price_parser", - # uipath.platform.common is imported lazily from traces.py / audit - # sinks to read UiPathConfig context attributes. It's first-party but - # not a uipath-runtime dep, so its stubs aren't installable here. - "uipath.platform.*", - # Optional framework adapters; the absence of the framework simply - # means the adapter no-ops at import time. - "agents", - "langchain_core.*", - "langgraph.*", -] -ignore_missing_imports = true - [tool.pydantic-mypy] init_forbid_extra = true init_typed = true diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py index d2bbfc92..6196db7a 100644 --- a/src/uipath/runtime/governance/config.py +++ b/src/uipath/runtime/governance/config.py @@ -3,22 +3,16 @@ The feature-flag gate (``is_governance_enabled``) lives in :mod:`uipath.core.governance.config` because it is process-level and must be resolvable by callers that do not depend on -``uipath-runtime``. The enforcement mode is *per-policy* — set by the -backend on each policy fetch via the ``/runtime/policy`` endpoint — -and therefore lives here in the runtime package alongside the policy -loader that applies it. +``uipath-runtime``. The enforcement mode is *per-policy* — owned by the +backend and delivered on each policy fetch via the ``/runtime/policy`` +endpoint — and therefore lives here in the runtime package alongside the +policy loader that applies it. """ from __future__ import annotations -import logging -import os from enum import Enum -logger = logging.getLogger(__name__) - -ENV_ENFORCEMENT_MODE = "UIPATH_GOVERNANCE_MODE" - class EnforcementMode(str, Enum): """Governance enforcement modes.""" @@ -28,45 +22,37 @@ class EnforcementMode(str, Enum): DISABLED = "disabled" # Skip evaluation entirely. -_enforcement_mode: EnforcementMode | None = None +class _EnforcementModeState: + """Holds the active enforcement mode. + + A single module-level instance backs the get/set/reset helpers, so the + mode is updated by mutating an attribute rather than rebinding a module + global. ``mode is None`` means "not yet set by the backend" — until + then (and if the backend omits a mode) governance defaults to AUDIT. + """ + + def __init__(self) -> None: + self.mode: EnforcementMode | None = None + + +# The enforcement mode is owned by the backend: the policy loader applies +# the mode from the ``/runtime/policy`` response via +# :func:`set_enforcement_mode`. +_state = _EnforcementModeState() def get_enforcement_mode() -> EnforcementMode: """Return the current enforcement mode. - The mode is cached after first read. Resolution order: - - 1. A value previously set via :func:`set_enforcement_mode` (the - policy loader calls this with the backend-supplied mode on every - successful policy fetch — that's the canonical source). - 2. ``UIPATH_GOVERNANCE_MODE`` env var (developer override). - 3. Default :attr:`EnforcementMode.AUDIT` — evaluate and log without - blocking. The wrapper attaches at runtime construction so the - background policy fetch can run; if the backend returns - ``disabled``, ``set_enforcement_mode`` flips the cache and - subsequent ``evaluate()`` calls short-circuit (the evaluator - skips evaluation in disabled mode). Defaulting to AUDIT avoids - the chicken-and-egg where a DISABLED - default would short-circuit before the policy fetch could ever - opt the tenant in. + The canonical source is the backend ``/runtime/policy`` response, + applied by the policy loader via :func:`set_enforcement_mode`. Until + that fetch lands (or if the backend returns no mode), the default is + :attr:`EnforcementMode.AUDIT` — evaluate and log without blocking. + Defaulting to AUDIT avoids the chicken-and-egg where a DISABLED + default would short-circuit evaluation before the background policy + fetch could ever opt the tenant in. """ - global _enforcement_mode - if _enforcement_mode is not None: - return _enforcement_mode - - mode_str = os.getenv(ENV_ENFORCEMENT_MODE, "audit").lower() - try: - _enforcement_mode = EnforcementMode(mode_str) - except ValueError: - logger.warning( - "Invalid %s=%r; defaulting to %s", - ENV_ENFORCEMENT_MODE, - mode_str, - EnforcementMode.AUDIT.value, - ) - _enforcement_mode = EnforcementMode.AUDIT - - return _enforcement_mode + return _state.mode if _state.mode is not None else EnforcementMode.AUDIT def set_enforcement_mode(mode: EnforcementMode) -> None: @@ -75,11 +61,4 @@ def set_enforcement_mode(mode: EnforcementMode) -> None: The policy loader calls this with the backend-supplied mode on each fetch so the evaluator picks up the platform-controlled value. """ - global _enforcement_mode - _enforcement_mode = mode - - -def reset_enforcement_mode() -> None: - """Clear cached enforcement mode (intended for tests).""" - global _enforcement_mode - _enforcement_mode = None + _state.mode = mode \ No newline at end of file diff --git a/src/uipath/runtime/governance/native/models.py b/src/uipath/runtime/governance/native/models.py index 9145d9ee..125e75e0 100644 --- a/src/uipath/runtime/governance/native/models.py +++ b/src/uipath/runtime/governance/native/models.py @@ -26,6 +26,13 @@ class Severity(Enum): CRITICAL = "critical" +class Logic(str, Enum): + """How a check combines its conditions.""" + + ALL = "all" # AND — every condition must hold. + ANY = "any" # OR — any matching condition is a hit. + + @dataclass class Condition: """A single condition within a rule check.""" @@ -43,7 +50,7 @@ class Check: conditions: list[Condition] action: Action = Action.DENY message: str = "" - logic: str = "all" # "all" (AND) or "any" (OR) + logic: Logic = Logic.ALL @dataclass @@ -79,9 +86,7 @@ class CheckContext: agent_output: str = "" model_input: str = "" model_output: str = "" - model_name: str = ( - "" # LLM model name (e.g., "gpt-4", "claude-3-opus") - available at agent start - ) + model_name: str = "" tool_name: str = "" tool_args: dict[str, Any] = field(default_factory=dict) tool_result: str = "" diff --git a/tests/_helpers.py b/tests/_helpers.py new file mode 100644 index 00000000..7d839ea5 --- /dev/null +++ b/tests/_helpers.py @@ -0,0 +1,19 @@ +"""Shared test-only helpers. + +Keeps test concerns out of the production governance package: the +enforcement-mode reset used for per-test isolation lives here rather than +in :mod:`uipath.runtime.governance.config`. +""" + +from __future__ import annotations + +from uipath.runtime.governance import config + + +def reset_enforcement_mode() -> None: + """Clear the process-wide enforcement mode so the AUDIT default re-applies. + + Test isolation only — production code never resets the mode; the policy + loader sets it from the backend ``/runtime/policy`` response. + """ + config._state.mode = None \ No newline at end of file diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py index 53350a10..992641aa 100644 --- a/tests/test_enforcement_mode_default.py +++ b/tests/test_enforcement_mode_default.py @@ -3,85 +3,58 @@ The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at runtime construction and the background policy fetch can run. If the backend later returns ``disabled``, ``set_enforcement_mode`` flips the -cache and ``evaluate()`` short-circuits per-call. +mode and ``evaluate()`` short-circuits per-call. -Resolution order (per :func:`get_enforcement_mode`): -1. Previously-cached programmatic value (set via ``set_enforcement_mode``). -2. ``UIPATH_GOVERNANCE_MODE`` env var. -3. Default ``AUDIT``. +Resolution (per :func:`get_enforcement_mode`): +1. The backend-supplied value set via ``set_enforcement_mode`` (the + ``/runtime/policy`` response, applied by the policy loader). +2. Default ``AUDIT``. """ from __future__ import annotations import pytest +from tests._helpers import reset_enforcement_mode from uipath.runtime.governance.config import ( EnforcementMode, get_enforcement_mode, - reset_enforcement_mode, set_enforcement_mode, ) @pytest.fixture(autouse=True) -def _isolate_mode(monkeypatch: pytest.MonkeyPatch): +def _isolate_mode(): """Each test starts from a clean module-state slate.""" - monkeypatch.delenv("UIPATH_GOVERNANCE_MODE", raising=False) reset_enforcement_mode() yield reset_enforcement_mode() def test_default_mode_is_audit() -> None: - """No programmatic mode + no env var → AUDIT. + """No backend-supplied mode → AUDIT. AUDIT is the default so the wrapper attaches and the background - policy fetch can run. The backend can flip the cache to DISABLED + policy fetch can run. The backend can flip the mode to DISABLED on fetch when the tenant has no policies. """ assert get_enforcement_mode() is EnforcementMode.AUDIT -def test_env_var_disabled_wins_over_default(monkeypatch: pytest.MonkeyPatch) -> None: - """Developer override via env var still works.""" - monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "disabled") - reset_enforcement_mode() # clear cached default +def test_backend_disabled_wins_over_default() -> None: + """The backend mode (via ``set_enforcement_mode``) overrides the default.""" + set_enforcement_mode(EnforcementMode.DISABLED) assert get_enforcement_mode() is EnforcementMode.DISABLED -def test_env_var_enforce_wins_over_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "enforce") - reset_enforcement_mode() - assert get_enforcement_mode() is EnforcementMode.ENFORCE - - -def test_invalid_env_var_falls_back_to_audit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "garbage-value") - reset_enforcement_mode() - assert get_enforcement_mode() is EnforcementMode.AUDIT - - -def test_programmatic_set_wins_over_env_and_default( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The policy loader's ``set_enforcement_mode`` call is canonical.""" - monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "audit") +def test_backend_enforce_wins_over_default() -> None: set_enforcement_mode(EnforcementMode.ENFORCE) assert get_enforcement_mode() is EnforcementMode.ENFORCE def test_reset_returns_to_default() -> None: - """``reset_enforcement_mode`` clears the cache so the default re-applies.""" + """``reset_enforcement_mode`` clears the mode so the default re-applies.""" set_enforcement_mode(EnforcementMode.ENFORCE) assert get_enforcement_mode() is EnforcementMode.ENFORCE reset_enforcement_mode() - assert get_enforcement_mode() is EnforcementMode.AUDIT - - -def test_audit_mode_is_cached_after_first_read() -> None: - """First call computes; subsequent calls hit the cache.""" - assert get_enforcement_mode() is EnforcementMode.AUDIT - # A second call returns the same instance — the cache survives. - assert get_enforcement_mode() is EnforcementMode.AUDIT + assert get_enforcement_mode() is EnforcementMode.AUDIT \ No newline at end of file diff --git a/uv.lock b/uv.lock index 3525e2fc..260bad30 100644 --- a/uv.lock +++ b/uv.lock @@ -1189,10 +1189,10 @@ dev = [ [package.metadata] requires-dist = [ - { name = "chardet", specifier = ">=5.2.0" }, - { name = "pyyaml", specifier = ">=6.0" }, + { name = "chardet", specifier = ">=5.2.0,<8.0" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, { name = "uipath-core", specifier = ">=0.5.18,<0.6.0" }, - { name = "vadersentiment", specifier = ">=3.3.2" }, + { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] [package.metadata.requires-dev] From 2a34075f2304ae6642ec7b12d490e5598baf4189 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 17 Jun 2026 14:02:08 +0530 Subject: [PATCH 04/18] fix(governance): consume EnforcementMode from uipath-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnforcementMode is a shared governance contract, so it now lives in uipath.core.governance (uipath-core 0.5.19) and is re-exported from config.py — runtime callers keep importing it from one place, but the value type is owned by the lower abstraction level (per radu's review). Bumps the uipath-core floor to 0.5.19. Co-Authored-By: Claude Opus 4.8 --- src/uipath/runtime/governance/config.py | 14 +++++--------- uv.lock | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py index 6196db7a..f74d51d1 100644 --- a/src/uipath/runtime/governance/config.py +++ b/src/uipath/runtime/governance/config.py @@ -11,15 +11,11 @@ from __future__ import annotations -from enum import Enum - - -class EnforcementMode(str, Enum): - """Governance enforcement modes.""" - - AUDIT = "audit" # Evaluate and log; never block. - ENFORCE = "enforce" # Block on DENY rules. - DISABLED = "disabled" # Skip evaluation entirely. +# ``EnforcementMode`` is the shared governance value type; it's defined in +# uipath.core.governance (a lower abstraction level) and re-exported here so +# runtime callers keep a single import site. The per-process mode *state* +# below is runtime-owned and applied by the policy loader. +from uipath.core.governance import EnforcementMode as EnforcementMode class _EnforcementModeState: diff --git a/uv.lock b/uv.lock index 260bad30..a6564c90 100644 --- a/uv.lock +++ b/uv.lock @@ -1191,7 +1191,7 @@ dev = [ requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "uipath-core", specifier = ">=0.5.18,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.19,<0.6.0" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] From f05dfaf97e47918f3a2c2705f902cd1bd67985fe Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Fri, 12 Jun 2026 16:27:58 +0530 Subject: [PATCH 05/18] feat(governance): policy backend client, YAML compiler, loader Co-Authored-By: Claude Opus 4.8 --- .../governance/native/_yaml_to_index.py | 459 ++++++++++ .../governance/native/backend_client.py | 383 +++++++++ .../runtime/governance/native/loader.py | 340 ++++++++ .../governance/native/policy_api_client.py | 227 +++++ tests/test_loader.py | 379 +++++++++ tests/test_policy_agent_type.py | 99 +++ tests/test_policy_api_client.py | 258 ++++++ tests/test_yaml_to_index.py | 795 ++++++++++++++++++ 8 files changed, 2940 insertions(+) create mode 100644 src/uipath/runtime/governance/native/_yaml_to_index.py create mode 100644 src/uipath/runtime/governance/native/backend_client.py create mode 100644 src/uipath/runtime/governance/native/loader.py create mode 100644 src/uipath/runtime/governance/native/policy_api_client.py create mode 100644 tests/test_loader.py create mode 100644 tests/test_policy_agent_type.py create mode 100644 tests/test_policy_api_client.py create mode 100644 tests/test_yaml_to_index.py diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py new file mode 100644 index 00000000..2deb4631 --- /dev/null +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -0,0 +1,459 @@ +"""Runtime YAML → PolicyIndex parser. + +Mirrors the shape produced by ``packs/compile_packs.py`` but builds the +PolicyIndex directly from parsed YAML data rather than generating Python +source. Used by :mod:`uipath.runtime.governance.native.loader` when policies are fetched +from the governance backend at startup. + +Accepts either a single YAML document (one pack) or a multi-document +stream (``---``-separated packs). Unknown check types and malformed +rules are skipped with a warning — partial packs are preferred over +failing the whole load. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import yaml +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.native.models import ( + Check, + Condition, + PolicyIndex, + PolicyPack, + Rule, + Severity, +) + +logger = logging.getLogger(__name__) + + +_HOOK_MAP: dict[str, LifecycleHook] = { + "before_agent": LifecycleHook.BEFORE_AGENT, + "after_agent": LifecycleHook.AFTER_AGENT, + "before_model": LifecycleHook.BEFORE_MODEL, + "after_model": LifecycleHook.AFTER_MODEL, + "wrap_tool_call": LifecycleHook.TOOL_CALL, + "tool_call": LifecycleHook.TOOL_CALL, + "after_tool": LifecycleHook.AFTER_TOOL, +} + +_ACTION_MAP: dict[str, Action] = { + "block": Action.DENY, + "deny": Action.DENY, + "log": Action.AUDIT, + "audit": Action.AUDIT, + "allow": Action.ALLOW, + "require_approval": Action.ESCALATE, + "escalate": Action.ESCALATE, +} + +_SEVERITY_MAP: dict[str, Severity] = { + "low": Severity.LOW, + "medium": Severity.MEDIUM, + "high": Severity.HIGH, + "critical": Severity.CRITICAL, +} + + +def build_policy_index_from_yaml(yaml_text: str) -> PolicyIndex: + """Parse YAML policy packs into a PolicyIndex. + + Args: + yaml_text: YAML body, either a single document or ``---``-separated + multi-document stream. Each document is one pack. + + Returns: + PolicyIndex with all successfully parsed packs added. Empty when + the input has no parseable packs. + + Raises: + yaml.YAMLError: If the YAML itself is malformed. Callers are + expected to fall back to the compiled index on this error. + """ + index = PolicyIndex() + documents = list(yaml.safe_load_all(yaml_text)) + + for doc in documents: + if not isinstance(doc, dict): + continue + pack = _build_pack(doc) + if pack is not None and pack.rules: + index.add_pack(pack) + + logger.debug( + "Built PolicyIndex from YAML: packs=%s, rules=%d", + index.pack_names, + index.total_rules, + ) + return index + + +def _build_pack(data: dict[str, Any]) -> PolicyPack | None: + """Build a PolicyPack from one YAML document.""" + name = data.get("standard") or data.get("name") + if not name: + logger.warning("Skipping pack: missing 'standard'/'name' field") + return None + + default_action_str = data.get("default_action", "block") + default_action = _ACTION_MAP.get(default_action_str, Action.DENY) + + rules: list[Rule] = [] + for i, rule_data in enumerate(data.get("rules", []) or []): + if not isinstance(rule_data, dict): + continue + rule = _build_rule(rule_data, default_action, i) + if rule is not None: + rules.append(rule) + + return PolicyPack( + name=str(name), + version=str(data.get("version", "1.0.0")), + description=str(data.get("description", "")), + rules=rules, + ) + + +def _build_rule( + data: dict[str, Any], default_action: Action, index: int +) -> Rule | None: + """Build a single Rule from a YAML rule entry.""" + hook = _HOOK_MAP.get(data.get("hook", "before_model")) + if hook is None: + logger.warning( + "Skipping rule %s: unknown hook %r", data.get("id"), data.get("hook") + ) + return None + + action_str = data.get("action") + action = ( + _ACTION_MAP.get(action_str, default_action) if action_str else default_action + ) + + default_sev = "high" if action == Action.DENY else "medium" + severity = _SEVERITY_MAP.get(data.get("severity", default_sev), Severity.HIGH) + + checks = _build_checks( + data.get("checks", []) or [], + action, + mapped_to_uipath=bool(data.get("mapped_to_uipath", False)), + policy_enabled=bool(data.get("policy_enabled", True)), + ) + + # If checks were declared but none could be parsed (e.g. all unknown + # types), skip the rule. A rule with zero checks "always matches" in + # the evaluator, so keeping it would make it fire on every request. + declared = data.get("checks", []) or [] + if declared and not checks: + logger.warning( + "Skipping rule %s: none of its %d declared check(s) could be parsed", + data.get("id"), + len(declared), + ) + return None + + return Rule( + rule_id=str(data.get("id", f"RULE-{index}")), + name=str(data.get("name", data.get("id", f"RULE-{index}"))), + clause=str(data.get("clause", data.get("owasp_ref", ""))), + hook=hook, + action=action, + severity=severity, + checks=checks, + enabled=bool(data.get("enabled", True)), + description=str(data.get("description", "")), + ) + + +def _build_checks( + checks_data: list[dict[str, Any]], + default_action: Action, + *, + mapped_to_uipath: bool = False, + policy_enabled: bool = True, +) -> list[Check]: + """Build the checks list for a rule. + + ``mapped_to_uipath`` / ``policy_enabled`` are rule-level flags read + by ``guardrail_fallback`` checks so the per-check condition can + decide whether to fire the compensating governance call. + """ + checks: list[Check] = [] + for check_data in checks_data: + if not isinstance(check_data, dict): + continue + check = _build_check( + check_data, + default_action, + mapped_to_uipath=mapped_to_uipath, + policy_enabled=policy_enabled, + ) + if check is not None: + checks.append(check) + return checks + + +def _build_check( + data: dict[str, Any], + default_action: Action, + *, + mapped_to_uipath: bool = False, + policy_enabled: bool = True, +) -> Check | None: + """Build one Check from a YAML check entry. + + Supports the same check types as ``compile_packs.py``: explicit + conditions, regex, budget, tool_allowlist, parameter_validation, + rate_limit, field_regex, sentiment_concern, data_quality_score, + incident_taxonomy, commitment_extractor, plus ``guardrail_fallback`` + (reads the rule-level ``mapped_to_uipath`` / ``policy_enabled`` flags + threaded in from ``_build_rule``). + """ + conditions: list[Condition] = [] + message = "" + + raw_conditions = data.get("conditions") + has_explicit_conditions = ( + isinstance(raw_conditions, list) + and raw_conditions + and isinstance(raw_conditions[0], dict) + and "operator" in raw_conditions[0] + ) + + check_type = data.get("type", "regex") + + if has_explicit_conditions: + assert isinstance(raw_conditions, list) # narrowed by has_explicit_conditions + conditions.extend(_make_conditions(raw_conditions)) + message = str(data.get("message", "")) + + elif check_type == "regex": + patterns = data.get("patterns", []) or [] + scope = data.get("scope", ["human", "ai"]) + field = _field_for_scope(scope) + for pattern in patterns: + conditions.append(Condition(operator="regex", field=field, value=pattern)) + message = f"Pattern matched in {scope}" + + elif check_type == "budget": + if "max_tool_calls_per_session" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.tool_calls", + value=data["max_tool_calls_per_session"], + ) + ) + if "max_tool_calls_per_minute" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.tool_calls_per_minute", + value=data["max_tool_calls_per_minute"], + ) + ) + if "max_consecutive_tool_calls" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.consecutive_tool_calls", + value=data["max_consecutive_tool_calls"], + ) + ) + message = "Tool budget exceeded" + + elif check_type == "tool_allowlist": + blocked_tools = data.get("blocked_tools", []) or [] + if blocked_tools: + conditions.append( + Condition(operator="in_list", field="tool_name", value=blocked_tools) + ) + message = "Tool not allowed" + + elif check_type == "parameter_validation": + for pattern in data.get("additional_patterns", []) or []: + conditions.append( + Condition(operator="regex", field="tool_args", value=pattern) + ) + message = "Suspicious pattern in tool parameters" + + elif check_type == "rate_limit": + if "max_llm_calls_per_session" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.llm_calls", + value=data["max_llm_calls_per_session"], + ) + ) + if "max_llm_calls_per_minute" in data: + conditions.append( + Condition( + operator="gt", + field="session_state.llm_calls_per_minute", + value=data["max_llm_calls_per_minute"], + ) + ) + message = "Rate limit exceeded" + + elif check_type == "field_regex": + conditions.extend(_make_conditions(data.get("conditions", []) or [])) + message = str(data.get("message", "Field regex check failed")) + + elif check_type == "data_quality_score": + field = data.get("field", "tool_result") + if data.get("check_encoding", True): + conditions.append( + Condition( + operator="encoding_concern", + field=field, + value={ + "min_confidence": float(data.get("min_confidence", 0.5)), + "max_replacement_ratio": float( + data.get("max_replacement_ratio", 0.05) + ), + "min_corruption_events": int( + data.get("min_corruption_events", 2) + ), + }, + ) + ) + if data.get("check_entropy", True): + conditions.append( + Condition( + operator="entropy_concern", + field=field, + value={ + "min": float(data.get("entropy_min", 1.5)), + "max": float(data.get("entropy_max", 7.5)), + }, + ) + ) + message = str( + data.get("message", "A.7.4: Data quality signal (encoding or entropy)") + ) + + elif check_type == "incident_taxonomy": + field = data.get("field", "model_output") + categories = data.get("categories") + value: dict[str, Any] = {} + if categories: + value["categories"] = list(categories) + conditions.append( + Condition(operator="incident_concern", field=field, value=value) + ) + message = str(data.get("message", "A.8.4: Incident signal detected")) + + elif check_type == "commitment_extractor": + field = data.get("field", "model_output") + conditions.append( + Condition( + operator="commitment_concern", + field=field, + value={ + "require_amount": bool(data.get("require_amount", True)), + "require_deadline": bool(data.get("require_deadline", False)), + }, + ) + ) + message = str( + data.get("message", "A.10.4: Customer commitment language detected") + ) + + elif check_type == "sentiment_concern": + field = data.get("field", "model_input") + threshold = float(data.get("threshold", -0.3)) + conditions.append( + Condition( + operator="vader_concern", + field=field, + value={"threshold": threshold}, + ) + ) + message = str( + data.get( + "message", + f"Negative sentiment detected (VADER compound <= {threshold})", + ) + ) + + elif check_type == "guardrail_fallback": + # Centralized guardrail compensating control. The on/off state + # lives at the RULE level (mapped_to_uipath / policy_enabled), + # threaded in from ``_build_rule``; ``validator`` names which + # guardrail check the server should run on behalf of the agent. + # The condition matches only when the guardrail is mapped to + # UiPath but disabled — see the ``guardrail_fallback`` operator + # in :class:`GovernanceEvaluator`. + conditions.append( + Condition( + operator="guardrail_fallback", + field="", + value={ + "validator": str(data.get("validator", "")), + "mapped_to_uipath": mapped_to_uipath, + "policy_enabled": policy_enabled, + }, + ) + ) + message = str( + data.get("message", "Guardrail disabled — compensating check needed.") + ) + + else: + logger.debug("Skipping check: unknown type %r", check_type) + return None + + if not conditions: + return None + + action_str = data.get("action") + action = ( + _ACTION_MAP.get(action_str, default_action) if action_str else default_action + ) + + message = str(data.get("message", message)) + + # Multi-pattern regex/parameter_validation defaults to OR semantics + # (any pattern indicates a hit); explicit `logic` in YAML wins. + if check_type in ("parameter_validation", "regex") and len(conditions) > 1: + default_logic = "any" + else: + default_logic = "all" + logic = str(data.get("logic", default_logic)) + + return Check(conditions=conditions, action=action, message=message, logic=logic) + + +def _make_conditions(raw: list[dict[str, Any]]) -> list[Condition]: + """Translate a list of YAML condition dicts into Condition objects.""" + out: list[Condition] = [] + for cond in raw: + if not isinstance(cond, dict): + continue + out.append( + Condition( + operator=str(cond.get("operator", "regex")), + field=str(cond.get("field", "model_input")), + value=cond.get("value", ""), + negate=bool(cond.get("negate", False)), + ) + ) + return out + + +def _field_for_scope(scope: list[str] | str) -> str: + """Map a YAML `scope` value to the CheckContext field it targets.""" + if isinstance(scope, str): + scope = [scope] + if "system" in scope or "human" in scope: + return "model_input" + if "ai" in scope: + return "model_output" + if "tool_result" in scope: + return "tool_result" + return "model_input" diff --git a/src/uipath/runtime/governance/native/backend_client.py b/src/uipath/runtime/governance/native/backend_client.py new file mode 100644 index 00000000..8269ea73 --- /dev/null +++ b/src/uipath/runtime/governance/native/backend_client.py @@ -0,0 +1,383 @@ +"""Governance backend client. + +Hosts the shared infrastructure used by every governance-backend call: + +- :func:`get_backend_base_url` — resolves the cloud host (with the + org/tenant path segments stripped) so each endpoint builder can + append its own scoped path. +- :func:`governance_request_headers` — composes the headers shared by + the policy fetch and the ``/runtime/govern`` compensating POST + (Accept, User-Agent, optional Content-Type, optional Bearer auth). +- :func:`build_governance_url` — composes an org-scoped URL against + the ``agenticgovernance_`` ingress. +- :func:`resolve_organization_id` / :func:`resolve_tenant_id` — read + the active org/tenant from ``UiPathConfig`` with an env-var fallback + for installations that don't have ``uipath-platform``. +- :func:`safe_call` — fail-open helper that catches every non-block + exception so governance hooks never crash an agent run. +- Module-level constants — request timeout, service path prefix, + compensation pool size — all the tunables an operator might care + about. Defined once here so the policy fetch, the compensating + ``/runtime/govern`` call, and the loader share one definition. + +The endpoint clients live next door: + +- :mod:`uipath.runtime.governance.native.policy_api_client` — policy fetch +- :mod:`uipath.runtime.governance.native.guardrail_compensation` — /runtime/govern +""" + +from __future__ import annotations + +import logging +import os +from functools import lru_cache +from typing import Callable +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# ---------------------------------------------------------------------------- +# Env-var names (consumed by the helpers below + diagnostic messages) +# ---------------------------------------------------------------------------- + +# Explicit dev/test override — used verbatim, no path-stripping. +ENV_BACKEND_BASE_URL = "UIPATH_GOVERNANCE_BACKEND_URL" +# The canonical platform URL env var (also backs ``UiPathConfig.base_url``). +ENV_PLATFORM_BASE_URL = "UIPATH_URL" +# Bearer token; missing means the policy fetch and compensating call are +# skipped (and that fact is logged) rather than producing 401s on every call. +ENV_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" +# Org / tenant scoping for the agenticgovernance_ ingress. +ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" +ENV_TENANT_ID = "UIPATH_TENANT_ID" +# Job-execution context forwarded in the /runtime/govern payload so the +# server can populate the LLMOps trace record (Doc-2 audit structure). +# Each falls back to the named env var when uipath-platform isn't present. +ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" +ENV_JOB_KEY = "UIPATH_JOB_KEY" +ENV_PROCESS_KEY = "UIPATH_PROCESS_UUID" +ENV_REFERENCE_ID = "UIPATH_AGENT_ID" +ENV_AGENT_VERSION = "UIPATH_PROCESS_VERSION" + +# ---------------------------------------------------------------------------- +# Endpoint shape — all governance calls hit the org-scoped agenticgovernance_ +# service. Centralised so adding a third endpoint is "one new path constant" +# instead of "a new path template that someone forgets to keep in sync." +# ---------------------------------------------------------------------------- + +GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" +POLICY_API_PATH = "api/v1/runtime/policy" +GOVERN_API_PATH = "api/v1/runtime/govern" +TENANT_HEADER = "x-uipath-internal-tenantid" +# Query param on the policy fetch that selects the agent-type view of the +# policy: the server's clause-resolver reads the matching container key +# (``*-in-flight-conversational-agents`` vs ``*-in-flight-agents``). It's a +# representation selector (it changes the returned policy), so it travels as a +# query param — cache-correct and part of resource identification — not a +# header. Values: "conversational" | "autonomous". +AGENT_TYPE_PARAM = "agentType" +AGENT_TYPE_CONVERSATIONAL = "conversational" +AGENT_TYPE_AUTONOMOUS = "autonomous" + +# Default base URL when no override and no UiPathConfig / UIPATH_URL value is +# available. Used only on developer machines doing fully-offline work; real +# deployments always have UIPATH_URL injected by the host. +_DEFAULT_BACKEND_BASE_URL = "https://alpha.uipath.com" + +# ---------------------------------------------------------------------------- +# Tunables — one place so an ops change is one edit. The values that bound +# how long a single agent run can spend on governance traffic. +# ---------------------------------------------------------------------------- + +# Per-request timeout for any governance backend HTTP call (policy fetch, +# /runtime/govern compensating POST). Same value used everywhere so an agent +# can't accidentally end up with a "long" timeout on one call and "short" on +# another. +BACKEND_REQUEST_TIMEOUT_SECONDS = 10.0 + +# Bound on concurrent /runtime/govern requests in flight. A misbehaving +# agent that fires `before_model` 100 times in a session with three matched +# fallback rules each would otherwise spawn 100 daemon threads; this pool +# caps the concurrency. Saturated submissions are logged and dropped — the +# server still receives traces from the requests that did land. +COMPENSATION_MAX_WORKERS = 4 + +# Browser-shaped User-Agent. Required because the alpha/production +# governance ingress runs a WAF whose default scanner rule set blocks +# ``Python-urllib/``. Identifying as a real browser keeps the +# request from being rejected before any auth/tenant logic runs. +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/148.0.0.0 Safari/537.36" +) + + +# ---------------------------------------------------------------------------- +# Headers +# ---------------------------------------------------------------------------- + + +def governance_request_headers(*, json_body: bool = False) -> dict[str, str]: + """Return the common HTTP headers for governance backend requests. + + Centralises the headers shared between the policy fetch and the + compensating ``/runtime/govern`` POST so the UA and auth shape are + declared once. + + Args: + json_body: When ``True`` (POST/PATCH/etc. with a JSON payload), + adds ``Content-Type: application/json``. GETs leave it off + so origin servers that 415 on unexpected Content-Type stay + happy. + + Returns: + A new dict with: + + - ``Accept: application/json`` + - ``User-Agent`` (the browser-shaped string above) + - ``Content-Type: application/json`` when ``json_body=True`` + - ``Authorization: Bearer `` when the env + var is set; omitted otherwise (caller decides whether the + missing token is fatal). + + Endpoint-specific headers (e.g. ``x-uipath-internal-tenantid``) are + added by the caller after this helper returns. + """ + headers: dict[str, str] = { + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + if json_body: + headers["Content-Type"] = "application/json" + token = os.environ.get(ENV_ACCESS_TOKEN) + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +# ---------------------------------------------------------------------------- +# URL composition +# ---------------------------------------------------------------------------- + + +def _strip_to_origin(raw_url: str) -> str: + """Return ``scheme://host[:port]`` for ``raw_url``, dropping any path. + + Platform URLs are commonly ``https://cloud.uipath.com//``; + the governance endpoints construct their own + ``/{org}/agenticgovernance_/...`` suffix, so the org/tenant segments + in the base must be stripped to avoid a duplicated org path. + """ + parsed = urlparse(raw_url) + if not parsed.scheme or not parsed.netloc: + # Not a parseable absolute URL — leave it to the caller. + return raw_url.rstrip("/") + return f"{parsed.scheme}://{parsed.netloc}" + + +def get_backend_base_url() -> str: + """Resolve the governance backend base URL on each call. + + Resolution order (first hit wins): + + 1. ``UIPATH_GOVERNANCE_BACKEND_URL`` — explicit dev/test override, + used verbatim. + 2. ``UiPathConfig.base_url`` from ``uipath-platform`` — the + canonical platform URL. Org/tenant path segments are stripped + so the caller can append its own org-scoped path. + 3. ``UIPATH_URL`` env var — same as (2) but works when + ``uipath-platform`` is not installed. + 4. ``https://alpha.uipath.com`` — last-resort default for offline + development; real deployments always have ``UIPATH_URL`` set. + + Reading on each call (not at import) lets the runtime entrypoint + configure the env vars after this module is already loaded. + """ + explicit_override = os.environ.get(ENV_BACKEND_BASE_URL) + if explicit_override: + return explicit_override.rstrip("/") + + # Lazy import — uipath-platform is optional; falls through to the + # env-var path when only uipath-core / uipath-runtime are installed. + platform_url: str | None = None + try: + from uipath.platform.common import UiPathConfig + + platform_url = UiPathConfig.base_url + except (ImportError, AttributeError): + pass + + raw = platform_url or os.environ.get(ENV_PLATFORM_BASE_URL) + if raw: + return _strip_to_origin(raw) + + return _DEFAULT_BACKEND_BASE_URL + + +def build_governance_url(org_id: str, path: str) -> str: + """Compose an org-scoped governance backend URL. + + Final shape: ``{backend_base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}``. + + Args: + org_id: Active organization id; the URL is meaningless without it. + path: API suffix WITHOUT the org/service prefix + (e.g. :data:`POLICY_API_PATH` or :data:`GOVERN_API_PATH`). + """ + base = get_backend_base_url() + return f"{base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}" + + +# ---------------------------------------------------------------------------- +# Org / tenant resolution +# ---------------------------------------------------------------------------- + + +def _resolve_uipath_config_field(attr: str, env_var: str) -> str | None: + """Read a single ``UiPathConfig`` attribute with an env-var fallback. + + Lazy-imports ``UiPathConfig`` so ``uipath-runtime`` doesn't require + ``uipath-platform`` at install time. When the platform package is + missing (``ImportError``) or the attribute isn't yet exposed + (``AttributeError``), falls back to reading the named env var. + """ + try: + from uipath.platform.common import UiPathConfig + + return getattr(UiPathConfig, attr, None) or os.environ.get(env_var) + except ImportError: + return os.environ.get(env_var) + + +# ---------------------------------------------------------------------------- +# Agent-type selector (conversational vs autonomous) +# +# Set once by the governance wrapper at runtime init (before the background +# policy prefetch is kicked off) and read by the policy fetch when composing +# the request URL. A process-level holder — not a ContextVar — because the +# prefetch runs on a separate thread that wouldn't inherit a ContextVar, and a +# coded-agent process hosts a single agent so the value is stable per process. +# ---------------------------------------------------------------------------- + +_agent_is_conversational: bool | None = None + + +def set_agent_conversational(value: bool | None) -> None: + """Record whether the hosted agent is conversational. + + ``None`` clears the selector (used by tests / direct callers); the policy + fetch then omits the param and the server applies its default. + """ + global _agent_is_conversational + _agent_is_conversational = value + + +def agent_type_param() -> str | None: + """Return the ``agentType`` query value, or ``None`` when unknown. + + ``"conversational"`` / ``"autonomous"`` map to the server's + conversational-vs-autonomous container keys; ``None`` (selector never set) + omits the param so the server's default applies. + """ + if _agent_is_conversational is None: + return None + return AGENT_TYPE_CONVERSATIONAL if _agent_is_conversational else AGENT_TYPE_AUTONOMOUS + + +def resolve_organization_id() -> str | None: + """Return the current organization id from ``UiPathConfig`` / env. + + Returns ``None`` when neither source yields a value — callers skip + the backend interaction (no URL can be built without an org id) + and the agent runs with no policies / no compensation. + """ + return _resolve_uipath_config_field("organization_id", ENV_ORGANIZATION_ID) + + +def resolve_tenant_id() -> str | None: + """Return the current tenant id from ``UiPathConfig`` / env. + + Returns ``None`` when neither source yields a value — callers skip + the backend interaction since the ``x-uipath-internal-tenantid`` + header would be missing. + """ + return _resolve_uipath_config_field("tenant_id", ENV_TENANT_ID) + + +@lru_cache(maxsize=1) +def _resolved_job_context() -> tuple[tuple[str, str], ...]: + """Resolve and freeze the job context once per process. + + Returned as a tuple of ``(key, value)`` pairs so the cached value is + immutable — callers materialize a fresh dict each call. Tests that + mutate env vars can invalidate via ``resolve_job_context.cache_clear()``. + """ + candidates = { + "folderKey": _resolve_uipath_config_field("folder_key", ENV_FOLDER_KEY), + "jobKey": _resolve_uipath_config_field("job_key", ENV_JOB_KEY), + "processKey": _resolve_uipath_config_field("process_uuid", ENV_PROCESS_KEY), + "referenceId": _resolve_uipath_config_field("agent_id", ENV_REFERENCE_ID), + "agentVersion": _resolve_uipath_config_field( + "process_version", ENV_AGENT_VERSION + ), + } + return tuple((k, v) for k, v in candidates.items() if v) + + +def resolve_job_context() -> dict[str, str]: + """Return the agent's job-execution context for the govern payload. + + Each field is read from ``UiPathConfig`` (env-var fallback) and only + included when it resolves to a truthy value, so the server receives + exactly the keys the agent actually knows. Cached per-process — the + underlying values are immutable for the agent's lifetime. The server + maps these onto the LLMOps trace record: + + - ``folderKey`` → ``FolderKey`` / ``uipath.folder_key`` + - ``jobKey`` → ``JobKey`` / ``uipath.job_key`` + - ``processKey`` → ``ProcessKey`` + - ``referenceId`` → ``ReferenceId`` (typically the agent id) + - ``agentVersion`` → ``AgentVersion`` + """ + return dict(_resolved_job_context()) + + +resolve_job_context.cache_clear = _resolved_job_context.cache_clear # type: ignore[attr-defined] + + +# ---------------------------------------------------------------------------- +# Generic safe-call helper. Used by callers that want "log and continue" on +# any unexpected failure path without spelling out the same try/except every +# time. The intentional GovernanceBlockException ALWAYS propagates — only +# this exception type carries policy intent; anything else is a bug. +# ---------------------------------------------------------------------------- + + +def safe_call( + fn: Callable[..., None], + *args: object, + what: str, + **kwargs: object, +) -> None: + """Call ``fn(*args, **kwargs)`` and swallow any non-block exception. + + ``GovernanceBlockException`` propagates (intentional policy block); + everything else is logged at WARNING with the ``what`` label and + swallowed so the agent can continue. Designed for fire-and-forget + governance paths that should never fail an agent run. + + Args: + fn: Callable to invoke. + what: Short label used in the log line on failure + (e.g. ``"BEFORE_AGENT governance check"``). + """ + # Lazy import to avoid pulling uipath-core into module load. + from uipath.core.governance.exceptions import GovernanceBlockException + + try: + fn(*args, **kwargs) + except GovernanceBlockException: + raise + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning("%s failed (continuing): %s", what, exc) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py new file mode 100644 index 00000000..e2fd138e --- /dev/null +++ b/src/uipath/runtime/governance/native/loader.py @@ -0,0 +1,340 @@ +"""Policy pack loader. + +Resolves the active PolicyIndex at startup. Policies are fetched +exclusively from the governance backend (``api/v1/policy``); there is +no local compiled fallback. When the backend is unavailable, the +access token is unset, or the fetch times out, the loader returns an +empty PolicyIndex and the agent runs without any rules. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from collections import Counter + +import yaml +from uipath.core.governance.config import is_governance_enabled + +from uipath.runtime.governance.config import EnforcementMode, set_enforcement_mode +from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml +from uipath.runtime.governance.native.backend_client import ENV_ACCESS_TOKEN +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.native.policy_api_client import ( + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + POLICY_API_TIMEOUT_SECONDS, + fetch_policy_response, + resolve_organization_id, + resolve_tenant_id, +) + +logger = logging.getLogger(__name__) + +# Pack name aliases for backward compatibility +PACK_ALIASES: dict[str, str] = { + "owasp": "owasp_agentic", + "hipaa": "hipaa_runtime", + "soc2": "soc2_runtime", + "nist": "nist_ai_rmf_runtime", + "eu_ai": "eu_ai_act_runtime", + "iso": "iso42001_runtime", +} + + +# Module-level cache +_policy_index: PolicyIndex | None = None + +# Background-prefetch coordination. ``_prefetch_event`` is set once the +# background load_policy_index() call finishes (success OR failure); +# callers of ``get_policy_index()`` wait on it. ``_prefetch_lock`` +# protects the start-once semantics so concurrent ``prefetch`` calls +# don't kick off duplicate threads. +_prefetch_event: threading.Event | None = None +_prefetch_lock = threading.Lock() + +# Default wait when ``get_policy_index()`` blocks on an in-flight +# prefetch. Matched to the policy-API HTTP timeout so a stuck backend +# bounds the total time spent waiting at first hook fire to +# ~POLICY_API_TIMEOUT_SECONDS. If the wait expires we return an empty +# PolicyIndex — the agent runs without any policies rather than +# blocking further or retrying. +_PREFETCH_WAIT_SECONDS = POLICY_API_TIMEOUT_SECONDS + + +def prefetch_policy_index() -> None: + """Kick off a background load of the policy index. + + Non-blocking. Designed to be called as early as possible (at + ``GovernanceRuntime.__init__``) so the HTTP call to the governance + backend overlaps with the rest of agent setup. The result lands in + the same module cache that ``get_policy_index()`` reads from; + ``get_policy_index()`` waits on this prefetch when it's in flight. + + Idempotent: subsequent calls while the first is running are no-ops, + and calls after completion are no-ops. Skipped entirely when the + governance feature flag is OFF so no network call is made. + """ + global _prefetch_event + + if not is_governance_enabled(): + return + + with _prefetch_lock: + if _policy_index is not None: + return # already loaded + if _prefetch_event is not None: + return # already in flight + event = threading.Event() + _prefetch_event = event + + def _worker() -> None: + global _policy_index + try: + loaded = load_policy_index() + except Exception as exc: # noqa: BLE001 - logged; first hook will retry sync + logger.warning("Policy prefetch failed: %s", exc) + else: + with _prefetch_lock: + _policy_index = loaded + finally: + event.set() + + threading.Thread( + target=_worker, + name="governance-policy-prefetch", + daemon=True, + ).start() + + +def get_policy_index() -> PolicyIndex: + """Get the cached policy index, loading if necessary. + + Resolution order on first call: + 1. If the governance feature flag is OFF, return an empty + PolicyIndex (cached). No network call. + 2. If a prefetch (see :func:`prefetch_policy_index`) is in flight, + wait for it to complete (bounded by ``_PREFETCH_WAIT_SECONDS``). + 3. Governance backend at ``api/v1/policy`` (one HTTP GET, cached). + 4. Empty PolicyIndex when the backend is unavailable or times out. + + Result is cached for the process lifetime; per-hook evaluation never + touches the network. Call :func:`clear_policy_cache` to force a + refetch (mainly for tests). + """ + global _policy_index + + if _policy_index is not None: + return _policy_index + + if not is_governance_enabled(): + logger.info( + "Governance feature flag is OFF; returning empty PolicyIndex. " + "No rules will fire. Set EnablePythonGovernanceChecker=True to enable." + ) + _policy_index = PolicyIndex() + return _policy_index + + event = _prefetch_event + if event is not None: + completed = event.wait(timeout=_PREFETCH_WAIT_SECONDS) + if completed and _policy_index is not None: + return _policy_index + if not completed: + logger.warning( + "Policy prefetch did not complete in %.1fs; " + "agent will run without any policies", + _PREFETCH_WAIT_SECONDS, + ) + else: + # Distinguish from the timeout path so production triage + # can tell "prefetch hung" from "prefetch returned empty" + # (auth failure, server error, parse failure). + logger.warning( + "Policy prefetch completed but produced no PolicyIndex " + "(see prior WARN for the root cause); agent will run " + "without any policies" + ) + _policy_index = PolicyIndex() + return _policy_index + + # No prefetch was started (direct callers / tests). Sync load — bounded + # by the HTTP timeout in the API client. + _policy_index = load_policy_index() + return _policy_index + + +def load_policy_index(pack_name: str | None = None) -> PolicyIndex: + """Load the active PolicyIndex from the governance backend. + + Args: + pack_name: Ignored. Pack selection is controlled entirely by the + backend. + + Returns: + PolicyIndex parsed from the backend response. Empty PolicyIndex + when the backend is unavailable, the token is unset, the YAML + is malformed, or the response yields zero rules. + """ + start = time.perf_counter() + + api_index = _load_from_api() + if api_index is not None: + _log_index_summary(api_index) + logger.info( + "Policy index ready: source=backend, total_ms=%.1f", + (time.perf_counter() - start) * 1000, + ) + return api_index + + reason = _empty_index_reason() + logger.info( + "Policy index ready: source=empty (%s), total_ms=%.1f", + reason, + (time.perf_counter() - start) * 1000, + ) + return PolicyIndex() + + +def _empty_index_reason() -> str: + """Diagnose why the policy fetch produced nothing.""" + if not resolve_organization_id(): + return ( + f"UiPathConfig.organization_id unavailable — set {ENV_ORGANIZATION_ID} " + "or install uipath-platform; backend API not contacted" + ) + if not resolve_tenant_id(): + return ( + f"UiPathConfig.tenant_id unavailable — set {ENV_TENANT_ID} " + "or install uipath-platform; backend API not contacted" + ) + if not os.environ.get(ENV_ACCESS_TOKEN): + return f"{ENV_ACCESS_TOKEN} unset — backend API not contacted" + return "backend returned no policies (timeout / error / empty body)" + + +def _apply_enforcement_mode(mode_str: str | None) -> None: + """Map a backend-supplied mode string onto :class:`EnforcementMode`. + + Unknown values log a warning and leave the existing mode untouched. + """ + if not mode_str: + return + try: + mode = EnforcementMode(mode_str.lower()) + except ValueError: + logger.warning( + "Backend returned unknown enforcement mode %r; keeping current mode", + mode_str, + ) + return + set_enforcement_mode(mode) + logger.info("Enforcement mode set from backend: %s", mode.value) + + +def _load_from_api() -> PolicyIndex | None: + """Fetch and parse the policy index from the governance backend. + + Applies the backend-supplied enforcement mode as a side effect. + Returns ``None`` when the backend skips/errors, when the YAML is + malformed, or when the resulting index has no rules — caller returns + an empty PolicyIndex in those cases. + """ + start = time.perf_counter() + response = fetch_policy_response() + if response is None: + return None + + # Apply the platform-controlled enforcement mode before building the + # index, so anything that reads ``get_enforcement_mode()`` during + # index compilation already sees the right value. + _apply_enforcement_mode(response.mode) + + if not response.policy: + logger.warning( + "Policy fetch returned empty policy field; " + "agent will run without any policies" + ) + return None + + try: + index = build_policy_index_from_yaml(response.policy) + except yaml.YAMLError as exc: + logger.warning("Policy YAML from backend was malformed: %s", exc) + return None + except Exception as exc: # noqa: BLE001 - never let load break agent startup + logger.warning("Failed to build PolicyIndex from backend YAML: %s", exc) + return None + + if index.total_rules == 0: + logger.warning( + "Policy YAML from backend yielded zero rules; " + "agent will run without any policies" + ) + return None + + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info( + "Loaded policy index from backend: packs=%s, rules=%d, elapsed_ms=%.1f", + index.pack_names, + index.total_rules, + elapsed_ms, + ) + return index + + +def _backend_base_url() -> str: + """Return the backend base URL for logging; imported lazily to avoid cycles.""" + try: + from uipath.runtime.governance.native.backend_client import ( + get_backend_base_url, + ) + + return get_backend_base_url() + except Exception: # noqa: BLE001 + return "backend" + + +def _log_index_summary(index: PolicyIndex) -> None: + """Log summary of loaded policy index.""" + # Count rules by hook + hook_counts: Counter[str] = Counter() + for rule in index.all_rules: + hook_counts[rule.hook.value] += 1 + + logger.debug( + "Policy packs: %s, total rules: %d, by hook: %s", + index.pack_names, + index.total_rules, + dict(hook_counts), + ) + + +def get_available_packs() -> list[str]: + """Get list of pack names from the currently loaded policy index. + + Returns whatever the backend supplied on the most recent load. + Empty list if no index has been loaded yet or the backend yielded + no packs. + """ + if _policy_index is None: + return [] + return _policy_index.pack_names + + +def clear_policy_cache() -> None: + """Clear the cached policy index and any in-flight prefetch state. + + Next call to ``get_policy_index()`` will refetch from the backend. + """ + global _policy_index, _prefetch_event + with _prefetch_lock: + _policy_index = None + _prefetch_event = None + logger.debug("Policy index cache cleared") + + +# Backward compatibility alias +reset_policy_index = clear_policy_cache diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py new file mode 100644 index 00000000..325b4e0b --- /dev/null +++ b/src/uipath/runtime/governance/native/policy_api_client.py @@ -0,0 +1,227 @@ +"""Governance policy API client. + +Fetches the governance backend response so policies can be controlled +centrally without redeploying agents. Called once at process startup +from :mod:`uipath.runtime.governance.native.loader`; per-hook evaluation +stays in-process. + +Response shape (JSON):: + + { + "mode": "audit" | "enforce" | "disabled", + "policies": "" + } + +``mode`` is the platform-controlled enforcement mode for the tenant; +the loader applies it via +:func:`uipath.runtime.governance.config.set_enforcement_mode`. ``policies`` +is the YAML the evaluator compiles into a :class:`PolicyIndex`. + +Failure mode is fail-open: when the organization id is unknown, the +access token is missing, the backend errors, or the body can't be +parsed, the caller falls back to an empty PolicyIndex. The fetch is +single-shot (no retry by design — see :func:`_get_once`) so a slow +backend can't extend agent startup beyond +:data:`BACKEND_REQUEST_TIMEOUT_SECONDS`. Nothing in this module ever +raises to the caller. +""" + +from __future__ import annotations + +import json +import logging +import os +import urllib.error +import urllib.request +from dataclasses import dataclass +from urllib.parse import urlencode + +from uipath.runtime.governance.native.backend_client import ( + AGENT_TYPE_PARAM, + BACKEND_REQUEST_TIMEOUT_SECONDS, + ENV_ACCESS_TOKEN, + ENV_ORGANIZATION_ID, + ENV_TENANT_ID, + POLICY_API_PATH, + TENANT_HEADER, + agent_type_param, + build_governance_url, + governance_request_headers, + resolve_organization_id, + resolve_tenant_id, +) + +logger = logging.getLogger(__name__) + +# Re-exported alias kept for callers that imported the old name. +POLICY_API_TIMEOUT_SECONDS = BACKEND_REQUEST_TIMEOUT_SECONDS + + +@dataclass(frozen=True) +class PolicyResponse: + """Parsed governance backend response. + + Attributes: + mode: Enforcement mode string the backend returned + (``"audit"`` / ``"enforce"`` / ``"disabled"``), or ``None`` + when the backend omitted it. Loader applies this via + :func:`uipath.runtime.governance.config.set_enforcement_mode`. + policy: Policy pack YAML to compile into a ``PolicyIndex``. May + be an empty string if the backend returned no rules. + """ + + mode: str | None + policy: str + + +def build_policy_url(org_id: str) -> str: + """Build the policy endpoint URL for the given organization id. + + The tenant id is not part of the URL; it travels in the + ``x-uipath-internal-tenantid`` request header (see + :func:`fetch_policy_response`). + + When the hosted agent's type is known (see + :func:`uipath.runtime.governance.native.backend_client.set_agent_conversational`), + an ``agentType`` query param is appended so the server resolves the + conversational-vs-autonomous container key. Omitted when unknown — the + server then applies its default. + """ + url = build_governance_url(org_id, POLICY_API_PATH) + agent_type = agent_type_param() + if agent_type: + url = f"{url}?{urlencode({AGENT_TYPE_PARAM: agent_type})}" + return url + + +def fetch_policy_response() -> PolicyResponse | None: + """Fetch the governance backend's policy response. + + Single shot, no retry: a failed fetch (timeout / network error / + HTTP error / malformed body) returns ``None`` and the caller falls + back to an empty PolicyIndex. The agent must not spend time on a + second attempt — keeping governance off the critical path is more + important than maximising policy availability. + + Returns: + :class:`PolicyResponse` on success. ``None`` on any failure + path — caller falls back to an empty PolicyIndex. + + Never raises. + """ + try: + return _fetch_policy_response_inner() + except Exception as exc: # noqa: BLE001 - loader path must never raise + logger.warning("Policy fetch failed unexpectedly: %s", exc) + return None + + +def _fetch_policy_response_inner() -> PolicyResponse | None: + org_id = resolve_organization_id() + if not org_id: + logger.warning( + "Policy fetch skipped: UiPathConfig.organization_id is not " + "available (set %s in the environment, or ensure uipath-platform " + "is installed); governance will run with no policies. The " + "backend API was NOT contacted.", + ENV_ORGANIZATION_ID, + ) + return None + + tenant_id = resolve_tenant_id() + if not tenant_id: + logger.warning( + "Policy fetch skipped: UiPathConfig.tenant_id is not " + "available (set %s in the environment, or ensure uipath-platform " + "is installed); governance will run with no policies. The " + "backend API was NOT contacted.", + ENV_TENANT_ID, + ) + return None + + policy_url = build_policy_url(org_id) + + token = os.environ.get(ENV_ACCESS_TOKEN) + if not token: + logger.warning( + "Policy fetch skipped: %s is not set in the environment; " + "governance will run with no policies.", + ENV_ACCESS_TOKEN, + ) + return None + + # Policy fetch is a GET; ``json_body=False`` so ``Content-Type`` is + # omitted. Strict origin servers may 415 on unexpected Content-Type + # for GETs (see :func:`governance_request_headers` docstring). + headers = governance_request_headers(json_body=False) + headers[TENANT_HEADER] = tenant_id + logger.info("Policy fetch starting (org=%s, tenant=%s)", org_id, tenant_id) + + body = _get_once(policy_url, headers) + if body is None: + return None + return _parse_policy_body(body) + + +def _get_once(url: str, headers: dict[str, str]) -> bytes | None: + """GET ``url`` once. Returns body bytes, or ``None`` on any failure. + + No retry by design — see :func:`fetch_policy_response` for the + rationale. Every failure path logs a single WARNING and returns + ``None`` so the caller (the loader) falls back to an empty + PolicyIndex without delay. + """ + request = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen( # noqa: S310 - URL is built from config + request, timeout=BACKEND_REQUEST_TIMEOUT_SECONDS + ) as response: + return response.read() + except urllib.error.HTTPError as exc: + logger.warning("Policy fetch returned HTTP %d: %s", exc.code, exc) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + logger.warning("Policy fetch failed: %s", exc) + return None + + +def _parse_policy_body(body: bytes) -> PolicyResponse | None: + """Parse the JSON envelope into a :class:`PolicyResponse`.""" + if not body: + logger.warning("Policy fetch returned empty body") + return None + + try: + payload = json.loads(body.decode("utf-8")) + except UnicodeDecodeError as exc: + logger.warning("Policy fetch returned non-UTF8 body: %s", exc) + return None + except json.JSONDecodeError as exc: + logger.warning( + "Policy fetch returned malformed JSON " + "(server may have returned an HTML error page): %s", + exc, + ) + return None + + if not isinstance(payload, dict): + logger.warning( + "Policy fetch returned unexpected JSON shape (expected object, got %s)", + type(payload).__name__, + ) + return None + + raw_mode = payload.get("mode") + mode = raw_mode if isinstance(raw_mode, str) and raw_mode else None + + raw_policy = payload.get("policies", "") + if not isinstance(raw_policy, str): + logger.warning( + "Policy fetch returned non-string 'policies' field (got %s)", + type(raw_policy).__name__, + ) + return None + + logger.info( + "Policy fetch ok: mode=%s, policy_bytes=%d", mode, len(raw_policy) + ) + return PolicyResponse(mode=mode, policy=raw_policy) diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 00000000..1ccc15cf --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,379 @@ +"""Tests for the policy loader module. + +Covers prefetch / get_policy_index / load_policy_index / _apply_enforcement_mode +plus the empty-index reason helper. +""" + +from __future__ import annotations + +import threading +import time +from unittest.mock import patch + +import pytest +import yaml + +from uipath.runtime.governance.config import ( + EnforcementMode, + get_enforcement_mode, + reset_enforcement_mode, +) +from uipath.runtime.governance.native import loader +from uipath.runtime.governance.native.loader import ( + _apply_enforcement_mode, + _empty_index_reason, + _load_from_api, + clear_policy_cache, + get_available_packs, + get_policy_index, + load_policy_index, + prefetch_policy_index, +) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.native.policy_api_client import PolicyResponse + +SIMPLE_POLICY_YAML = """ +standard: test-pack +version: "1.0" +rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["leak"] +""" + + +@pytest.fixture(autouse=True) +def _clean_loader_state(monkeypatch: pytest.MonkeyPatch): + """Each test starts with a fresh loader cache and a known env. + + Without this, tests leak the policy_index module global and + `_prefetch_event` into one another. + """ + clear_policy_cache() + reset_enforcement_mode() + # Enable the FF so the loader doesn't short-circuit immediately. + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + yield + clear_policy_cache() + reset_enforcement_mode() + FeatureFlags.reset_flags() + + +# --------------------------------------------------------------------------- +# _empty_index_reason +# --------------------------------------------------------------------------- + + +def test_empty_index_reason_missing_org_id(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + msg = _empty_index_reason() + assert "organization_id" in msg + + +def test_empty_index_reason_missing_tenant_id(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + msg = _empty_index_reason() + assert "tenant_id" in msg + + +def test_empty_index_reason_missing_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) + msg = _empty_index_reason() + assert "UIPATH_ACCESS_TOKEN" in msg + + +def test_empty_index_reason_backend_returned_nothing() -> None: + """All env present → reason is 'backend returned no policies'.""" + msg = _empty_index_reason() + assert "backend returned no policies" in msg + + +# --------------------------------------------------------------------------- +# _apply_enforcement_mode +# --------------------------------------------------------------------------- + + +def test_apply_enforcement_mode_none_leaves_current() -> None: + """Calling with ``None`` is a no-op — the existing mode is preserved.""" + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.ENFORCE) + _apply_enforcement_mode(None) + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +def test_apply_enforcement_mode_empty_string_leaves_current() -> None: + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.AUDIT) + _apply_enforcement_mode("") + assert get_enforcement_mode() == EnforcementMode.AUDIT + + +@pytest.mark.parametrize( + "mode_str,expected", + [ + ("audit", EnforcementMode.AUDIT), + ("enforce", EnforcementMode.ENFORCE), + ("disabled", EnforcementMode.DISABLED), + ("AUDIT", EnforcementMode.AUDIT), # case-insensitive + ], +) +def test_apply_enforcement_mode_known_values( + mode_str: str, expected: EnforcementMode +) -> None: + _apply_enforcement_mode(mode_str) + assert get_enforcement_mode() == expected + + +def test_apply_enforcement_mode_unknown_value_keeps_current() -> None: + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.AUDIT) + _apply_enforcement_mode("not-a-real-mode") + # Mode is unchanged after the warning. + assert get_enforcement_mode() == EnforcementMode.AUDIT + + +# --------------------------------------------------------------------------- +# _load_from_api +# --------------------------------------------------------------------------- + + +def test_load_from_api_returns_none_when_fetch_returns_none() -> None: + with patch.object(loader, "fetch_policy_response", return_value=None): + assert _load_from_api() is None + + +def test_load_from_api_returns_none_when_policy_is_empty() -> None: + """A response with mode but empty policies field is treated as nothing.""" + response = PolicyResponse(mode="audit", policy="") + with patch.object(loader, "fetch_policy_response", return_value=response): + assert _load_from_api() is None + + +def test_load_from_api_applies_mode_then_parses() -> None: + """The mode is applied BEFORE the YAML is parsed, so downstream sees it.""" + response = PolicyResponse(mode="enforce", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + index = _load_from_api() + assert isinstance(index, PolicyIndex) + assert index.total_rules == 1 + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +def test_load_from_api_swallows_yaml_error() -> None: + """A malformed YAML body produces None, not an exception.""" + response = PolicyResponse(mode="audit", policy="key: : invalid: : yaml") + with patch.object(loader, "fetch_policy_response", return_value=response): + with patch.object( + loader, + "build_policy_index_from_yaml", + side_effect=yaml.YAMLError("bad yaml"), + ): + assert _load_from_api() is None + + +def test_load_from_api_swallows_unexpected_exception() -> None: + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + with patch.object( + loader, + "build_policy_index_from_yaml", + side_effect=RuntimeError("library bug"), + ): + assert _load_from_api() is None + + +def test_load_from_api_returns_none_when_zero_rules() -> None: + """YAML parses cleanly but yields no rules → treated as no-op.""" + empty_pack_yaml = "standard: empty\nrules: []\n" + response = PolicyResponse(mode="audit", policy=empty_pack_yaml) + with patch.object(loader, "fetch_policy_response", return_value=response): + assert _load_from_api() is None + + +# --------------------------------------------------------------------------- +# load_policy_index — public entry +# --------------------------------------------------------------------------- + + +def test_load_policy_index_success_path() -> None: + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + index = load_policy_index() + assert isinstance(index, PolicyIndex) + assert "test-pack" in index.pack_names + + +def test_load_policy_index_returns_empty_on_failure() -> None: + """When the API yields None, the loader returns an empty PolicyIndex.""" + with patch.object(loader, "fetch_policy_response", return_value=None): + index = load_policy_index() + assert isinstance(index, PolicyIndex) + assert index.total_rules == 0 + + +# --------------------------------------------------------------------------- +# get_policy_index — caching + FF gate +# --------------------------------------------------------------------------- + + +def test_get_policy_index_caches_after_first_call() -> None: + """A second call returns the cached index without re-fetching.""" + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object( + loader, "fetch_policy_response", return_value=response + ) as mock_fetch: + a = get_policy_index() + b = get_policy_index() + assert a is b + assert mock_fetch.call_count == 1 + + +def test_get_policy_index_short_circuits_when_ff_off() -> None: + """FF off → return an empty index without contacting the backend.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + with patch.object(loader, "fetch_policy_response") as mock_fetch: + index = get_policy_index() + assert index.total_rules == 0 + assert not mock_fetch.called + + +def test_get_policy_index_sync_load_when_no_prefetch() -> None: + """Without a prefetch in flight, get_policy_index synchronously loads.""" + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + index = get_policy_index() + assert index.total_rules == 1 + + +# --------------------------------------------------------------------------- +# Prefetch — idempotency + completion + timeout +# --------------------------------------------------------------------------- + + +def test_prefetch_is_idempotent() -> None: + """Second call while first is in flight is a no-op (no second thread).""" + block = threading.Event() + + def _slow_fetch(): + block.wait(timeout=2.0) + return None + + with patch.object(loader, "fetch_policy_response", side_effect=_slow_fetch): + prefetch_policy_index() + first_event = loader._prefetch_event + prefetch_policy_index() + assert loader._prefetch_event is first_event + # Let the worker finish so the autouse fixture's clear runs cleanly. + block.set() + if first_event is not None: + first_event.wait(timeout=2.0) + + +def test_prefetch_skipped_when_ff_off() -> None: + """FF off → no prefetch thread started.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + with patch.object(loader, "fetch_policy_response") as mock_fetch: + prefetch_policy_index() + assert not mock_fetch.called + assert loader._prefetch_event is None + + +def test_prefetch_no_op_when_index_already_loaded() -> None: + """If the index is already cached, prefetch is a no-op.""" + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + get_policy_index() # populate the cache + with patch.object(loader, "fetch_policy_response") as mock_fetch: + prefetch_policy_index() + assert not mock_fetch.called + + +def test_get_policy_index_waits_for_prefetch_then_returns() -> None: + """When a prefetch is in flight, get_policy_index waits for completion.""" + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + started = threading.Event() + release = threading.Event() + + def _fetch(): + started.set() + release.wait(timeout=2.0) + return response + + with patch.object(loader, "fetch_policy_response", side_effect=_fetch): + prefetch_policy_index() + assert started.wait(timeout=2.0) + # Release the worker in a side thread so get_policy_index's wait + # actually overlaps with the slow fetch. + threading.Thread( + target=lambda: (time.sleep(0.05), release.set()), daemon=True + ).start() + index = get_policy_index() + assert index.total_rules == 1 + + +def test_get_policy_index_logs_when_prefetch_completes_with_empty_index( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The 'completed but produced no PolicyIndex' branch fires on auth/parse fail. + + Capturing via a logger mock instead of caplog because some + test-isolation paths (other tests installing log interceptors) + can prevent records from reaching caplog's root-attached handler. + """ + event = threading.Event() + event.set() # prefetch already completed + monkeypatch.setattr(loader, "_prefetch_event", event) + # _policy_index stays None — simulating "prefetch completed but produced nothing" + with patch.object(loader.logger, "warning") as mock_warning: + index = get_policy_index() + assert index.total_rules == 0 + assert any( + "completed but produced no PolicyIndex" in str(call.args[0]) + for call in mock_warning.call_args_list + ) + + +# --------------------------------------------------------------------------- +# get_available_packs / clear_policy_cache +# --------------------------------------------------------------------------- + + +def test_get_available_packs_before_load_returns_empty() -> None: + assert get_available_packs() == [] + + +def test_get_available_packs_after_load() -> None: + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object(loader, "fetch_policy_response", return_value=response): + get_policy_index() + assert "test-pack" in get_available_packs() + + +def test_clear_policy_cache_forces_refetch() -> None: + response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) + with patch.object( + loader, "fetch_policy_response", return_value=response + ) as mock_fetch: + get_policy_index() + clear_policy_cache() + get_policy_index() + assert mock_fetch.call_count == 2 + + +def test_reset_policy_index_alias_for_clear() -> None: + """``reset_policy_index`` is the legacy alias for ``clear_policy_cache``.""" + assert loader.reset_policy_index is loader.clear_policy_cache diff --git a/tests/test_policy_agent_type.py b/tests/test_policy_agent_type.py new file mode 100644 index 00000000..4eb30f96 --- /dev/null +++ b/tests/test_policy_agent_type.py @@ -0,0 +1,99 @@ +"""Tests for the conversational-vs-autonomous agent-type selector. + +The governance wrapper records whether the hosted agent is conversational; +the policy fetch then appends an ``agentType`` query param so the server's +clause-resolver reads the matching container key (``*-in-flight-agents`` vs +``*-in-flight-conversational-agents``). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from uipath.runtime.governance.native import backend_client +from uipath.runtime.governance.native.backend_client import ( + agent_type_param, + set_agent_conversational, +) +from uipath.runtime.governance.native.policy_api_client import build_policy_url +from uipath.runtime.governance.wrapper import GovernanceRuntime + + +def _extract(delegate, context=None) -> bool: + """Call _extract_is_conversational without running __init__.""" + runtime = object.__new__(GovernanceRuntime) + return runtime._extract_is_conversational(delegate, context) + + +@pytest.fixture(autouse=True) +def _reset_selector(): + """Clear the process-level selector around each test.""" + set_agent_conversational(None) + yield + set_agent_conversational(None) + + +def test_agent_type_param_unset_is_none(): + assert agent_type_param() is None + + +def test_agent_type_param_conversational(): + set_agent_conversational(True) + assert agent_type_param() == "conversational" + + +def test_agent_type_param_autonomous(): + set_agent_conversational(False) + assert agent_type_param() == "autonomous" + + +def test_build_policy_url_omits_param_when_unset(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + url = build_policy_url("my-org") + assert url == "https://alpha.uipath.com/my-org/agenticgovernance_/api/v1/runtime/policy" + assert "agentType" not in url + + +def test_build_policy_url_appends_conversational(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + set_agent_conversational(True) + assert build_policy_url("my-org").endswith( + "/my-org/agenticgovernance_/api/v1/runtime/policy?agentType=conversational" + ) + + +def test_build_policy_url_appends_autonomous(monkeypatch): + monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") + set_agent_conversational(False) + assert build_policy_url("my-org").endswith("?agentType=autonomous") + + +# ── _extract_is_conversational ────────────────────────────────────────────── + + +def test_extract_conversational_from_agent_definition(): + delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + assert _extract(delegate) is True + + +def test_extract_autonomous_from_agent_definition(): + delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) + assert _extract(delegate) is False + + +def test_extract_unwraps_delegate_chain(): + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + outer = SimpleNamespace(_delegate=inner) # no _agent_definition on the outer + assert _extract(outer) is True + + +def test_extract_falls_back_to_context_conversation_id(): + delegate = SimpleNamespace() # nothing reachable + context = SimpleNamespace(conversation_id="conv-1") + assert _extract(delegate, context) is True + + +def test_extract_defaults_to_autonomous_when_unknown(): + assert _extract(SimpleNamespace(), SimpleNamespace()) is False \ No newline at end of file diff --git a/tests/test_policy_api_client.py b/tests/test_policy_api_client.py new file mode 100644 index 00000000..9ebcdb5f --- /dev/null +++ b/tests/test_policy_api_client.py @@ -0,0 +1,258 @@ +"""Tests for ``fetch_policy_response`` and the body parser. + +Covers the skip paths (missing org / tenant / token), HTTP failures +(HTTPError, URLError, TimeoutError, OSError), and body parsing +(empty body, non-UTF8, malformed JSON, wrong top-level shape, bad +``policies`` type). +""" + +from __future__ import annotations + +import io +import json +import urllib.error +from unittest.mock import MagicMock, patch + +import pytest + +from uipath.runtime.governance.native import policy_api_client +from uipath.runtime.governance.native.policy_api_client import ( + PolicyResponse, + _parse_policy_body, + build_policy_url, + fetch_policy_response, +) + + +@pytest.fixture +def _fresh_env(monkeypatch: pytest.MonkeyPatch): + """Clear the env vars that the fetch path depends on.""" + for var in ( + "UIPATH_ORGANIZATION_ID", + "UIPATH_TENANT_ID", + "UIPATH_ACCESS_TOKEN", + "UIPATH_URL", + ): + monkeypatch.delenv(var, raising=False) + yield + + +@pytest.fixture +def _populated_env(monkeypatch: pytest.MonkeyPatch): + """All three vars present — the fetch path can reach urlopen.""" + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok-abc") + monkeypatch.setenv("UIPATH_URL", "https://alpha.uipath.com") + yield + + +def _ok_response(body: bytes) -> MagicMock: + """urlopen()-compatible context manager that returns ``body``.""" + resp = MagicMock() + resp.read.return_value = body + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return resp + + +# --------------------------------------------------------------------------- +# Skip paths — fail-open without contacting the backend +# --------------------------------------------------------------------------- + + +def test_skip_when_org_id_missing(_fresh_env, monkeypatch) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + with patch.object( + policy_api_client.urllib.request, "urlopen" + ) as mock_urlopen: + assert fetch_policy_response() is None + assert not mock_urlopen.called + + +def test_skip_when_tenant_id_missing(_fresh_env, monkeypatch) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") + with patch.object( + policy_api_client.urllib.request, "urlopen" + ) as mock_urlopen: + assert fetch_policy_response() is None + assert not mock_urlopen.called + + +def test_skip_when_token_missing(_fresh_env, monkeypatch) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + with patch.object( + policy_api_client.urllib.request, "urlopen" + ) as mock_urlopen: + assert fetch_policy_response() is None + assert not mock_urlopen.called + + +# --------------------------------------------------------------------------- +# HTTP failure paths — fail-open with a warning +# --------------------------------------------------------------------------- + + +def test_returns_none_on_http_error(_populated_env) -> None: + err = urllib.error.HTTPError( + url="x", code=500, msg="Server Error", hdrs=None, fp=io.BytesIO(b"") + ) + with patch.object( + policy_api_client.urllib.request, "urlopen", side_effect=err + ): + assert fetch_policy_response() is None + + +def test_returns_none_on_url_error(_populated_env) -> None: + err = urllib.error.URLError("connection refused") + with patch.object( + policy_api_client.urllib.request, "urlopen", side_effect=err + ): + assert fetch_policy_response() is None + + +def test_returns_none_on_timeout(_populated_env) -> None: + with patch.object( + policy_api_client.urllib.request, "urlopen", side_effect=TimeoutError() + ): + assert fetch_policy_response() is None + + +def test_returns_none_on_os_error(_populated_env) -> None: + with patch.object( + policy_api_client.urllib.request, + "urlopen", + side_effect=OSError("disk full"), + ): + assert fetch_policy_response() is None + + +def test_outer_swallows_unexpected_exception(_populated_env) -> None: + """Even non-HTTP exceptions from urlopen don't escape the fetch helper.""" + with patch.object( + policy_api_client.urllib.request, + "urlopen", + side_effect=RuntimeError("library bug"), + ): + assert fetch_policy_response() is None + + +# --------------------------------------------------------------------------- +# Headers / URL composition +# --------------------------------------------------------------------------- + + +def test_sends_no_content_type_on_get(_populated_env) -> None: + """The GET must NOT carry Content-Type — some servers 415 on it.""" + with patch.object( + policy_api_client.urllib.request, + "urlopen", + return_value=_ok_response(b'{"mode": "audit", "policies": ""}'), + ) as mock_urlopen: + fetch_policy_response() + request_arg = mock_urlopen.call_args.args[0] + assert request_arg.get_header("Content-type") is None + assert request_arg.get_header("Accept") == "application/json" + assert request_arg.get_header("Authorization") == "Bearer tok-abc" + assert request_arg.get_header("X-uipath-internal-tenantid") == "tenant-1" + assert request_arg.get_method() == "GET" + + +def test_url_includes_agent_type_when_set(_populated_env, monkeypatch) -> None: + """``build_policy_url`` appends ``?agentType=...`` from the selector.""" + from uipath.runtime.governance.native import backend_client + + monkeypatch.setattr(backend_client, "_agent_is_conversational", True) + url = build_policy_url("org-x") + assert "agentType=conversational" in url + + +def test_url_omits_agent_type_when_unset(_populated_env, monkeypatch) -> None: + from uipath.runtime.governance.native import backend_client + + monkeypatch.setattr(backend_client, "_agent_is_conversational", None) + url = build_policy_url("org-x") + assert "agentType=" not in url + + +# --------------------------------------------------------------------------- +# Body parser — _parse_policy_body +# --------------------------------------------------------------------------- + + +def test_parse_empty_body_returns_none() -> None: + assert _parse_policy_body(b"") is None + + +def test_parse_non_utf8_body_returns_none() -> None: + # 0xff isn't valid UTF-8. + assert _parse_policy_body(b"\xff\xfe") is None + + +def test_parse_malformed_json_returns_none() -> None: + # A common shape: server returns HTML when it should return JSON. + assert _parse_policy_body(b"oops") is None + + +def test_parse_non_object_top_level_returns_none() -> None: + """Server returning a bare JSON array is rejected — expected an object.""" + assert _parse_policy_body(b'["audit", "policies"]') is None + + +def test_parse_non_string_policies_field_returns_none() -> None: + """``policies`` must be a string YAML body, not a number / dict / list.""" + assert _parse_policy_body(b'{"mode": "audit", "policies": 42}') is None + + +def test_parse_ok_yields_policy_response() -> None: + resp = _parse_policy_body( + b'{"mode": "enforce", "policies": "standard: p\\nrules: []"}' + ) + assert resp is not None + assert resp.mode == "enforce" + assert "standard: p" in resp.policy + + +def test_parse_ok_with_missing_mode_yields_none_mode() -> None: + """A response without ``mode`` is still valid — server may not override.""" + resp = _parse_policy_body(b'{"policies": ""}') + assert resp is not None + assert resp.mode is None + assert resp.policy == "" + + +def test_parse_empty_string_mode_treated_as_unset() -> None: + """Empty-string ``mode`` is normalized to ``None`` (don't override default).""" + resp = _parse_policy_body(b'{"mode": "", "policies": ""}') + assert resp is not None + assert resp.mode is None + + +def test_parse_non_string_mode_treated_as_unset() -> None: + """If the server sends mode as a number / null, treat as unset.""" + resp = _parse_policy_body(b'{"mode": 5, "policies": ""}') + assert resp is not None + assert resp.mode is None + + +# --------------------------------------------------------------------------- +# Full happy-path round-trip +# --------------------------------------------------------------------------- + + +def test_full_fetch_round_trip(_populated_env) -> None: + body = json.dumps( + {"mode": "audit", "policies": "standard: p\nrules: []"} + ).encode("utf-8") + with patch.object( + policy_api_client.urllib.request, + "urlopen", + return_value=_ok_response(body), + ): + resp = fetch_policy_response() + assert isinstance(resp, PolicyResponse) + assert resp.mode == "audit" + assert "standard: p" in resp.policy diff --git a/tests/test_yaml_to_index.py b/tests/test_yaml_to_index.py new file mode 100644 index 00000000..5e8d338d --- /dev/null +++ b/tests/test_yaml_to_index.py @@ -0,0 +1,795 @@ +"""Tests for ``build_policy_index_from_yaml``. + +Covers every supported check type plus the pack / rule plumbing +(default action, severity defaults, hook resolution, multi-doc YAML, +malformed input handling). +""" + +from __future__ import annotations + +import pytest +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.native._yaml_to_index import ( + build_policy_index_from_yaml, +) +from uipath.runtime.governance.native.models import Severity + + +def _single_rule(yaml_text: str): + """Compile YAML and return the single rule; fail if not exactly one.""" + idx = build_policy_index_from_yaml(yaml_text) + rules = idx.all_rules + assert len(rules) == 1, f"expected 1 rule, got {len(rules)}" + return rules[0] + + +# --------------------------------------------------------------------------- +# Pack / document handling +# --------------------------------------------------------------------------- + + +def test_empty_yaml_returns_empty_index() -> None: + idx = build_policy_index_from_yaml("") + assert idx.total_rules == 0 + assert idx.pack_names == [] + + +def test_pack_without_rules_is_omitted() -> None: + """Packs with no parseable rules are dropped — never registered.""" + idx = build_policy_index_from_yaml( + """ + standard: empty-pack + version: "1.0" + rules: [] + """ + ) + assert idx.total_rules == 0 + assert "empty-pack" not in idx.pack_names + + +def test_pack_missing_name_is_skipped() -> None: + idx = build_policy_index_from_yaml( + """ + version: "1.0" + rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["foo"] + """ + ) + assert idx.total_rules == 0 + + +def test_pack_uses_standard_or_name_field() -> None: + """Either ``standard:`` or ``name:`` works as the pack identifier.""" + a = build_policy_index_from_yaml( + """ + standard: iso42001 + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + b = build_policy_index_from_yaml( + """ + name: iso42001 + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert "iso42001" in a.pack_names + assert "iso42001" in b.pack_names + + +def test_multi_document_yaml_concatenates_packs() -> None: + # YAML doc separators must be at column 0; dedent inline. + yaml_text = ( + "standard: pack-a\n" + "rules:\n" + " - id: a-r1\n" + " hook: before_model\n" + ' checks: [{type: regex, patterns: ["a"]}]\n' + "---\n" + "standard: pack-b\n" + "rules:\n" + " - id: b-r1\n" + " hook: after_model\n" + ' checks: [{type: regex, patterns: ["b"]}]\n' + ) + idx = build_policy_index_from_yaml(yaml_text) + assert set(idx.pack_names) == {"pack-a", "pack-b"} + assert idx.total_rules == 2 + + +def test_non_dict_top_level_documents_are_ignored() -> None: + """A YAML doc that's a string / list at top level is skipped silently.""" + yaml_text = ( + "just_a_string\n" + "---\n" + "standard: real-pack\n" + "rules:\n" + " - id: r\n" + " hook: before_model\n" + ' checks: [{type: regex, patterns: ["x"]}]\n' + ) + idx = build_policy_index_from_yaml(yaml_text) + assert idx.pack_names == ["real-pack"] + + +# --------------------------------------------------------------------------- +# Rule-level plumbing +# --------------------------------------------------------------------------- + + +def test_unknown_hook_skips_rule() -> None: + """A rule referencing an unknown hook is dropped, the rest survive.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: bad + hook: invented_hook + checks: [{type: regex, patterns: ["x"]}] + - id: good + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + rule_ids = [r.rule_id for r in idx.all_rules] + assert "bad" not in rule_ids + assert "good" in rule_ids + + +def test_non_dict_rule_entry_ignored() -> None: + """Rules entries that aren't dicts (lists, scalars) are skipped.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - "this is a string, not a rule" + - id: good + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert [r.rule_id for r in idx.all_rules] == ["good"] + + +def test_action_resolution_inherits_pack_default() -> None: + """When the rule omits action, the pack's default_action is used.""" + rule = _single_rule( + """ + standard: p + default_action: log + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.action == Action.AUDIT # log -> AUDIT per _ACTION_MAP + + +def test_action_resolution_unknown_falls_back_to_default() -> None: + """Unknown action string falls back to the pack default.""" + rule = _single_rule( + """ + standard: p + default_action: deny + rules: + - id: r + hook: before_model + action: bogus + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.action == Action.DENY + + +def test_severity_resolution_explicit() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + severity: critical + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.CRITICAL + + +def test_severity_default_high_for_deny_action() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + action: deny + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.HIGH + + +def test_severity_default_medium_for_non_deny_action() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + action: log + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.MEDIUM + + +def test_unknown_severity_falls_back_to_high() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + severity: ridiculous + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.HIGH + + +def test_disabled_flag_propagates() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + enabled: false + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.enabled is False + + +def test_rule_without_id_gets_index_based_id() -> None: + """When ``id:`` is missing, a positional fallback ``RULE-N`` is used.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert idx.all_rules[0].rule_id == "RULE-0" + + +def test_rule_with_zero_parsed_checks_is_skipped() -> None: + """A rule whose declared checks all fail to parse is dropped. + + Without this guard, a rule with no checks ``always matches`` in the + evaluator and would fire on every request. + """ + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: junk + hook: before_model + checks: + - type: totally_unknown_check_type + """ + ) + assert idx.total_rules == 0 + + +# --------------------------------------------------------------------------- +# Check types +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "hook_name,expected", + [ + ("before_agent", LifecycleHook.BEFORE_AGENT), + ("after_agent", LifecycleHook.AFTER_AGENT), + ("before_model", LifecycleHook.BEFORE_MODEL), + ("after_model", LifecycleHook.AFTER_MODEL), + ("tool_call", LifecycleHook.TOOL_CALL), + ("wrap_tool_call", LifecycleHook.TOOL_CALL), # alias + ("after_tool", LifecycleHook.AFTER_TOOL), + ], +) +def test_hook_resolution(hook_name: str, expected: LifecycleHook) -> None: + rule = _single_rule( + f""" + standard: p + rules: + - id: r + hook: {hook_name} + checks: [{{type: regex, patterns: ["x"]}}] + """ + ) + assert rule.hook == expected + + +def test_regex_check_multi_pattern_defaults_to_any_logic() -> None: + """Multiple regex patterns default to OR (any) — common case for ASI rules.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["pwn", "ignore_previous"] + """ + ) + assert rule.checks[0].logic == "any" + assert len(rule.checks[0].conditions) == 2 + + +def test_regex_check_single_pattern_defaults_to_all_logic() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["pwn"] + """ + ) + assert rule.checks[0].logic == "all" + + +def test_regex_check_explicit_logic_wins() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["a", "b"] + logic: all + """ + ) + assert rule.checks[0].logic == "all" + + +@pytest.mark.parametrize( + "scope,expected_field", + [ + (["human"], "model_input"), + (["system"], "model_input"), + (["ai"], "model_output"), + ("ai", "model_output"), # string form + (["tool_result"], "tool_result"), + (["unknown_thing"], "model_input"), # fallback + ], +) +def test_regex_scope_maps_to_field(scope, expected_field: str) -> None: + rule = _single_rule( + f""" + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["x"] + scope: {scope!r} + """ + ) + assert rule.checks[0].conditions[0].field == expected_field + + +def test_budget_check_max_per_session() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: budget + max_tool_calls_per_session: 5 + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "gt" + assert cond.field == "session_state.tool_calls" + assert cond.value == 5 + + +def test_budget_check_multiple_thresholds() -> None: + """All three budget knobs become independent conditions.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: budget + max_tool_calls_per_session: 10 + max_tool_calls_per_minute: 5 + max_consecutive_tool_calls: 3 + """ + ) + assert len(rule.checks[0].conditions) == 3 + + +def test_tool_allowlist_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: tool_allowlist + blocked_tools: ["delete_file", "shell"] + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "in_list" + assert cond.field == "tool_name" + assert cond.value == ["delete_file", "shell"] + + +def test_tool_allowlist_empty_blocked_list_skipped() -> None: + """Empty ``blocked_tools`` means there's nothing to enforce — drop the rule.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: tool_allowlist + blocked_tools: [] + """ + ) + assert idx.total_rules == 0 + + +def test_parameter_validation_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: parameter_validation + additional_patterns: ["rm -rf", "/etc/passwd"] + """ + ) + check = rule.checks[0] + assert len(check.conditions) == 2 + assert all(c.field == "tool_args" for c in check.conditions) + # Multi-pattern parameter_validation defaults to OR logic + assert check.logic == "any" + + +def test_rate_limit_check_session_and_minute() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: rate_limit + max_llm_calls_per_session: 20 + max_llm_calls_per_minute: 5 + """ + ) + fields = {c.field for c in rule.checks[0].conditions} + assert fields == { + "session_state.llm_calls", + "session_state.llm_calls_per_minute", + } + + +def test_field_regex_check_threads_through_conditions() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: field_regex + conditions: + - operator: regex + field: model_output + value: "(?i)password" + message: "leaked password" + """ + ) + check = rule.checks[0] + assert check.message == "leaked password" + assert check.conditions[0].operator == "regex" + + +def test_data_quality_score_both_encoding_and_entropy() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_tool + checks: + - type: data_quality_score + field: tool_result + min_confidence: 0.8 + entropy_min: 2.0 + entropy_max: 6.0 + """ + ) + ops = {c.operator for c in rule.checks[0].conditions} + assert ops == {"encoding_concern", "entropy_concern"} + + +def test_data_quality_score_check_encoding_disabled() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_tool + checks: + - type: data_quality_score + check_encoding: false + check_entropy: true + """ + ) + ops = [c.operator for c in rule.checks[0].conditions] + assert "encoding_concern" not in ops + assert "entropy_concern" in ops + + +def test_incident_taxonomy_with_categories() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: incident_taxonomy + field: model_output + categories: [safety_refusal, tool_failure] + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "incident_concern" + assert cond.value == {"categories": ["safety_refusal", "tool_failure"]} + + +def test_incident_taxonomy_without_categories_uses_empty_dict() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: incident_taxonomy + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.value == {} + + +def test_commitment_extractor_default_flags() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: commitment_extractor + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "commitment_concern" + assert cond.value == {"require_amount": True, "require_deadline": False} + + +def test_commitment_extractor_custom_flags() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: commitment_extractor + require_amount: false + require_deadline: true + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.value == {"require_amount": False, "require_deadline": True} + + +def test_sentiment_concern_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: sentiment_concern + threshold: -0.5 + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "vader_concern" + assert cond.value == {"threshold": -0.5} + + +def test_guardrail_fallback_inherits_rule_flags() -> None: + """Rule-level ``mapped_to_uipath`` / ``policy_enabled`` thread into the condition.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + mapped_to_uipath: true + policy_enabled: false + checks: + - type: guardrail_fallback + validator: pii_detection + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "guardrail_fallback" + assert cond.value == { + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": False, + } + + +def test_guardrail_fallback_default_flags_are_unmapped_and_enabled() -> None: + """When the rule omits the flags, the fallback never fires (disabled-only contract).""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: guardrail_fallback + validator: pii_detection + """ + ) + cond = rule.checks[0].conditions[0] + # ``guardrail_fallback`` operator fires only when mapped=True AND + # enabled=False; defaults of False / True ensure it stays silent. + assert cond.value["mapped_to_uipath"] is False + assert cond.value["policy_enabled"] is True + + +def test_explicit_conditions_win_over_check_type() -> None: + """Explicit ``conditions:`` short-circuits the per-type templating.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex # ignored, conditions wins + conditions: + - operator: contains + field: model_input + value: "secret" + message: "no secrets" + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "contains" # not "regex" + assert cond.value == "secret" + assert rule.checks[0].message == "no secrets" + + +def test_explicit_conditions_negate_flag_propagates() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - conditions: + - operator: contains + field: model_input + value: "allowed" + negate: true + """ + ) + assert rule.checks[0].conditions[0].negate is True + + +def test_non_dict_condition_in_explicit_list_is_skipped() -> None: + """A condition entry that isn't a dict is silently dropped. + + The first dict-with-``operator`` entry is what trips the + "explicit conditions" branch in ``_build_check``; out-of-order + scalar entries appear after the leading dict. + """ + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - conditions: + - operator: contains + field: model_input + value: "x" + - "not a dict" + """ + ) + assert len(rule.checks[0].conditions) == 1 + + +def test_unknown_check_type_skipped() -> None: + """Unknown check types are dropped without taking down sibling checks.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: future_check_type + - type: regex + patterns: ["x"] + """ + ) + rule = idx.all_rules[0] + # Only the regex check survived. + assert len(rule.checks) == 1 + assert rule.checks[0].conditions[0].operator == "regex" + + +def test_non_dict_check_entry_skipped() -> None: + """Checks list entries that aren't dicts are silently ignored.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - "scalar instead of mapping" + - type: regex + patterns: ["x"] + """ + ) + assert len(rule.checks) == 1 From 87c4f214b5b7ff9e639cd6f0ee38de4e6db3785c Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Tue, 16 Jun 2026 14:37:25 +0530 Subject: [PATCH 06/18] =?UTF-8?q?fix(governance):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20don't=20cache=20empty=20PolicyIndex=20on=20worke?= =?UTF-8?q?r=20failure,=20default=20explicit=20conditions=20to=20AND,=20po?= =?UTF-8?q?licy=5Fchars=20label,=20importorskip=20wrapper=20in=20agent-typ?= =?UTF-8?q?e=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../governance/native/_yaml_to_index.py | 13 +++++++-- .../runtime/governance/native/loader.py | 28 +++++++++++-------- .../governance/native/policy_api_client.py | 2 +- tests/test_policy_agent_type.py | 8 +++++- 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index 2deb4631..f17664e7 100644 --- a/src/uipath/runtime/governance/native/_yaml_to_index.py +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -418,9 +418,16 @@ def _build_check( message = str(data.get("message", message)) - # Multi-pattern regex/parameter_validation defaults to OR semantics - # (any pattern indicates a hit); explicit `logic` in YAML wins. - if check_type in ("parameter_validation", "regex") and len(conditions) > 1: + # Multi-PATTERN shorthand (regex/parameter_validation expanded from + # several patterns for one concept) defaults to OR — any pattern + # hitting is a match. An explicit `conditions:` list defaults to AND + # (all must hold) and must NOT inherit the pattern-shorthand OR even + # though `check_type` falls back to "regex". Explicit `logic` wins. + if ( + not has_explicit_conditions + and check_type in ("parameter_validation", "regex") + and len(conditions) > 1 + ): default_logic = "any" else: default_logic = "all" diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index e2fd138e..bd9a4a01 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -143,22 +143,28 @@ def get_policy_index() -> PolicyIndex: if completed and _policy_index is not None: return _policy_index if not completed: + # Timeout: deliberately cache an empty index so we don't + # re-wait the full timeout on every subsequent hook. logger.warning( "Policy prefetch did not complete in %.1fs; " "agent will run without any policies", _PREFETCH_WAIT_SECONDS, ) - else: - # Distinguish from the timeout path so production triage - # can tell "prefetch hung" from "prefetch returned empty" - # (auth failure, server error, parse failure). - logger.warning( - "Policy prefetch completed but produced no PolicyIndex " - "(see prior WARN for the root cause); agent will run " - "without any policies" - ) - _policy_index = PolicyIndex() - return _policy_index + _policy_index = PolicyIndex() + return _policy_index + + # Completed but produced no PolicyIndex — the worker hit an + # unexpected error (auth failure, server error, parse failure). + # Do NOT cache the empty result: caching would permanently + # disable governance for the process even though a later + # prefetch / clear_policy_cache could still recover. Return an + # empty index for this call only and leave the cache unset. + logger.warning( + "Policy prefetch completed but produced no PolicyIndex " + "(see prior WARN for the root cause); agent will run " + "without any policies for this call" + ) + return PolicyIndex() # No prefetch was started (direct callers / tests). Sync load — bounded # by the HTTP timeout in the API client. diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py index 325b4e0b..7a9ee96b 100644 --- a/src/uipath/runtime/governance/native/policy_api_client.py +++ b/src/uipath/runtime/governance/native/policy_api_client.py @@ -222,6 +222,6 @@ def _parse_policy_body(body: bytes) -> PolicyResponse | None: return None logger.info( - "Policy fetch ok: mode=%s, policy_bytes=%d", mode, len(raw_policy) + "Policy fetch ok: mode=%s, policy_chars=%d", mode, len(raw_policy) ) return PolicyResponse(mode=mode, policy=raw_policy) diff --git a/tests/test_policy_agent_type.py b/tests/test_policy_agent_type.py index 4eb30f96..f9b9fdb2 100644 --- a/tests/test_policy_agent_type.py +++ b/tests/test_policy_agent_type.py @@ -18,7 +18,13 @@ set_agent_conversational, ) from uipath.runtime.governance.native.policy_api_client import build_policy_url -from uipath.runtime.governance.wrapper import GovernanceRuntime + +# The wrapper lands in a later slice of the governance stack; skip (don't +# error at collection) when it isn't present yet. +GovernanceRuntime = pytest.importorskip( + "uipath.runtime.governance.wrapper", + reason="GovernanceRuntime wrapper not yet present in this slice", +).GovernanceRuntime def _extract(delegate, context=None) -> bool: From 297193ef308fa92da5283a3ced699d6e331d8892 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 17 Jun 2026 10:57:51 +0530 Subject: [PATCH 07/18] fix(governance): decouple backend/policy client from uipath-platform - backend_client/policy_api_client/loader read org/tenant (+ job context) from the environment via runtime-local ENV_* constants instead of importing UiPathConfig. Adds ENV_TRACE_ID. Diagnostic/log messages no longer reference uipath-platform. - _yaml_to_index: convert the parsed logic string to the Logic enum (Check.logic is now typed Logic). - test_loader: assert on env-var names; import reset helper from tests._helpers. Co-Authored-By: Claude Opus 4.8 --- .../governance/native/_yaml_to_index.py | 7 +- .../governance/native/backend_client.py | 87 ++++++++----------- .../runtime/governance/native/loader.py | 8 +- .../governance/native/policy_api_client.py | 14 ++- tests/test_loader.py | 6 +- 5 files changed, 54 insertions(+), 68 deletions(-) diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index f17664e7..c4080f22 100644 --- a/src/uipath/runtime/governance/native/_yaml_to_index.py +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -22,6 +22,7 @@ from uipath.runtime.governance.native.models import ( Check, Condition, + Logic, PolicyIndex, PolicyPack, Rule, @@ -431,7 +432,11 @@ def _build_check( default_logic = "any" else: default_logic = "all" - logic = str(data.get("logic", default_logic)) + logic_str = str(data.get("logic", default_logic)).lower() + try: + logic = Logic(logic_str) + except ValueError: + logic = Logic.ALL return Check(conditions=conditions, action=action, message=message, logic=logic) diff --git a/src/uipath/runtime/governance/native/backend_client.py b/src/uipath/runtime/governance/native/backend_client.py index 8269ea73..a406c354 100644 --- a/src/uipath/runtime/governance/native/backend_client.py +++ b/src/uipath/runtime/governance/native/backend_client.py @@ -11,8 +11,8 @@ - :func:`build_governance_url` — composes an org-scoped URL against the ``agenticgovernance_`` ingress. - :func:`resolve_organization_id` / :func:`resolve_tenant_id` — read - the active org/tenant from ``UiPathConfig`` with an env-var fallback - for installations that don't have ``uipath-platform``. + the active org/tenant from the environment (published by the UiPath + runtime host), keeping runtime independent of ``uipath-platform``. - :func:`safe_call` — fail-open helper that catches every non-block exception so governance hooks never crash an agent run. - Module-level constants — request timeout, service path prefix, @@ -42,7 +42,7 @@ # Explicit dev/test override — used verbatim, no path-stripping. ENV_BACKEND_BASE_URL = "UIPATH_GOVERNANCE_BACKEND_URL" -# The canonical platform URL env var (also backs ``UiPathConfig.base_url``). +# The canonical platform URL env var. ENV_PLATFORM_BASE_URL = "UIPATH_URL" # Bearer token; missing means the policy fetch and compensating call are # skipped (and that fact is logged) rather than producing 401s on every call. @@ -50,9 +50,12 @@ # Org / tenant scoping for the agenticgovernance_ ingress. ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" ENV_TENANT_ID = "UIPATH_TENANT_ID" +# Trace id used to bind governance spans / compensation records to the +# agent's trace. +ENV_TRACE_ID = "UIPATH_TRACE_ID" # Job-execution context forwarded in the /runtime/govern payload so the # server can populate the LLMOps trace record (Doc-2 audit structure). -# Each falls back to the named env var when uipath-platform isn't present. +# Published into the process environment by the UiPath runtime host. ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" ENV_JOB_KEY = "UIPATH_JOB_KEY" ENV_PROCESS_KEY = "UIPATH_PROCESS_UUID" @@ -79,7 +82,7 @@ AGENT_TYPE_CONVERSATIONAL = "conversational" AGENT_TYPE_AUTONOMOUS = "autonomous" -# Default base URL when no override and no UiPathConfig / UIPATH_URL value is +# Default base URL when no override and no UIPATH_URL value is # available. Used only on developer machines doing fully-offline work; real # deployments always have UIPATH_URL injected by the host. _DEFAULT_BACKEND_BASE_URL = "https://alpha.uipath.com" @@ -183,12 +186,10 @@ def get_backend_base_url() -> str: 1. ``UIPATH_GOVERNANCE_BACKEND_URL`` — explicit dev/test override, used verbatim. - 2. ``UiPathConfig.base_url`` from ``uipath-platform`` — the - canonical platform URL. Org/tenant path segments are stripped - so the caller can append its own org-scoped path. - 3. ``UIPATH_URL`` env var — same as (2) but works when - ``uipath-platform`` is not installed. - 4. ``https://alpha.uipath.com`` — last-resort default for offline + 2. ``UIPATH_URL`` env var — the canonical platform URL. Org/tenant + path segments are stripped so the caller can append its own + org-scoped path. + 3. ``https://alpha.uipath.com`` — last-resort default for offline development; real deployments always have ``UIPATH_URL`` set. Reading on each call (not at import) lets the runtime entrypoint @@ -198,17 +199,7 @@ def get_backend_base_url() -> str: if explicit_override: return explicit_override.rstrip("/") - # Lazy import — uipath-platform is optional; falls through to the - # env-var path when only uipath-core / uipath-runtime are installed. - platform_url: str | None = None - try: - from uipath.platform.common import UiPathConfig - - platform_url = UiPathConfig.base_url - except (ImportError, AttributeError): - pass - - raw = platform_url or os.environ.get(ENV_PLATFORM_BASE_URL) + raw = os.environ.get(ENV_PLATFORM_BASE_URL) if raw: return _strip_to_origin(raw) @@ -234,20 +225,15 @@ def build_governance_url(org_id: str, path: str) -> str: # ---------------------------------------------------------------------------- -def _resolve_uipath_config_field(attr: str, env_var: str) -> str | None: - """Read a single ``UiPathConfig`` attribute with an env-var fallback. +def _resolve_env_field(env_var: str) -> str | None: + """Read a runtime-context value from its environment variable. - Lazy-imports ``UiPathConfig`` so ``uipath-runtime`` doesn't require - ``uipath-platform`` at install time. When the platform package is - missing (``ImportError``) or the attribute isn't yet exposed - (``AttributeError``), falls back to reading the named env var. + Org/tenant ids and job context are published into the process + environment by the UiPath runtime host. Reading them directly keeps + ``uipath-runtime`` independent of ``uipath-platform`` (the lower layer + must not import the higher one). """ - try: - from uipath.platform.common import UiPathConfig - - return getattr(UiPathConfig, attr, None) or os.environ.get(env_var) - except ImportError: - return os.environ.get(env_var) + return os.environ.get(env_var) # ---------------------------------------------------------------------------- @@ -286,23 +272,22 @@ def agent_type_param() -> str | None: def resolve_organization_id() -> str | None: - """Return the current organization id from ``UiPathConfig`` / env. + """Return the current organization id from the environment. - Returns ``None`` when neither source yields a value — callers skip - the backend interaction (no URL can be built without an org id) - and the agent runs with no policies / no compensation. + Returns ``None`` when unset — callers skip the backend interaction + (no URL can be built without an org id) and the agent runs with no + policies / no compensation. """ - return _resolve_uipath_config_field("organization_id", ENV_ORGANIZATION_ID) + return _resolve_env_field(ENV_ORGANIZATION_ID) def resolve_tenant_id() -> str | None: - """Return the current tenant id from ``UiPathConfig`` / env. + """Return the current tenant id from the environment. - Returns ``None`` when neither source yields a value — callers skip - the backend interaction since the ``x-uipath-internal-tenantid`` - header would be missing. + Returns ``None`` when unset — callers skip the backend interaction + since the ``x-uipath-internal-tenantid`` header would be missing. """ - return _resolve_uipath_config_field("tenant_id", ENV_TENANT_ID) + return _resolve_env_field(ENV_TENANT_ID) @lru_cache(maxsize=1) @@ -314,13 +299,11 @@ def _resolved_job_context() -> tuple[tuple[str, str], ...]: mutate env vars can invalidate via ``resolve_job_context.cache_clear()``. """ candidates = { - "folderKey": _resolve_uipath_config_field("folder_key", ENV_FOLDER_KEY), - "jobKey": _resolve_uipath_config_field("job_key", ENV_JOB_KEY), - "processKey": _resolve_uipath_config_field("process_uuid", ENV_PROCESS_KEY), - "referenceId": _resolve_uipath_config_field("agent_id", ENV_REFERENCE_ID), - "agentVersion": _resolve_uipath_config_field( - "process_version", ENV_AGENT_VERSION - ), + "folderKey": _resolve_env_field(ENV_FOLDER_KEY), + "jobKey": _resolve_env_field(ENV_JOB_KEY), + "processKey": _resolve_env_field(ENV_PROCESS_KEY), + "referenceId": _resolve_env_field(ENV_REFERENCE_ID), + "agentVersion": _resolve_env_field(ENV_AGENT_VERSION), } return tuple((k, v) for k, v in candidates.items() if v) @@ -328,7 +311,7 @@ def _resolved_job_context() -> tuple[tuple[str, str], ...]: def resolve_job_context() -> dict[str, str]: """Return the agent's job-execution context for the govern payload. - Each field is read from ``UiPathConfig`` (env-var fallback) and only + Each field is read from its environment variable and only included when it resolves to a truthy value, so the server receives exactly the keys the agent actually knows. Cached per-process — the underlying values are immutable for the agent's lifetime. The server diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index bd9a4a01..4d02cff6 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -208,13 +208,13 @@ def _empty_index_reason() -> str: """Diagnose why the policy fetch produced nothing.""" if not resolve_organization_id(): return ( - f"UiPathConfig.organization_id unavailable — set {ENV_ORGANIZATION_ID} " - "or install uipath-platform; backend API not contacted" + f"organization id unavailable — set {ENV_ORGANIZATION_ID}; " + "backend API not contacted" ) if not resolve_tenant_id(): return ( - f"UiPathConfig.tenant_id unavailable — set {ENV_TENANT_ID} " - "or install uipath-platform; backend API not contacted" + f"tenant id unavailable — set {ENV_TENANT_ID}; " + "backend API not contacted" ) if not os.environ.get(ENV_ACCESS_TOKEN): return f"{ENV_ACCESS_TOKEN} unset — backend API not contacted" diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py index 7a9ee96b..0bc428a8 100644 --- a/src/uipath/runtime/governance/native/policy_api_client.py +++ b/src/uipath/runtime/governance/native/policy_api_client.py @@ -120,10 +120,9 @@ def _fetch_policy_response_inner() -> PolicyResponse | None: org_id = resolve_organization_id() if not org_id: logger.warning( - "Policy fetch skipped: UiPathConfig.organization_id is not " - "available (set %s in the environment, or ensure uipath-platform " - "is installed); governance will run with no policies. The " - "backend API was NOT contacted.", + "Policy fetch skipped: organization id is not available " + "(set %s in the environment); governance will run with no " + "policies. The backend API was NOT contacted.", ENV_ORGANIZATION_ID, ) return None @@ -131,10 +130,9 @@ def _fetch_policy_response_inner() -> PolicyResponse | None: tenant_id = resolve_tenant_id() if not tenant_id: logger.warning( - "Policy fetch skipped: UiPathConfig.tenant_id is not " - "available (set %s in the environment, or ensure uipath-platform " - "is installed); governance will run with no policies. The " - "backend API was NOT contacted.", + "Policy fetch skipped: tenant id is not available " + "(set %s in the environment); governance will run with no " + "policies. The backend API was NOT contacted.", ENV_TENANT_ID, ) return None diff --git a/tests/test_loader.py b/tests/test_loader.py index 1ccc15cf..202de394 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -13,10 +13,10 @@ import pytest import yaml +from tests._helpers import reset_enforcement_mode from uipath.runtime.governance.config import ( EnforcementMode, get_enforcement_mode, - reset_enforcement_mode, ) from uipath.runtime.governance.native import loader from uipath.runtime.governance.native.loader import ( @@ -74,13 +74,13 @@ def _clean_loader_state(monkeypatch: pytest.MonkeyPatch): def test_empty_index_reason_missing_org_id(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) msg = _empty_index_reason() - assert "organization_id" in msg + assert "UIPATH_ORGANIZATION_ID" in msg def test_empty_index_reason_missing_tenant_id(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) msg = _empty_index_reason() - assert "tenant_id" in msg + assert "UIPATH_TENANT_ID" in msg def test_empty_index_reason_missing_token(monkeypatch: pytest.MonkeyPatch) -> None: From 400092806abd6d06afc5fb5f078fe179a204df16 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Fri, 19 Jun 2026 13:38:04 +0530 Subject: [PATCH 08/18] fix(governance): import env constants/resolvers from backend_client (their definition site) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loader.py imported ENV_ORGANIZATION_ID/ENV_TENANT_ID/resolve_organization_id/ resolve_tenant_id from policy_api_client, which only re-imports them from backend_client — tripping mypy's no_implicit_reexport (4 attr-defined errors). Import them directly from backend_client where they're defined. No runtime change; clears mypy across the stack. Co-Authored-By: Claude Opus 4.8 --- src/uipath/runtime/governance/native/loader.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index 4d02cff6..6b55022c 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -20,16 +20,18 @@ from uipath.runtime.governance.config import EnforcementMode, set_enforcement_mode from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml -from uipath.runtime.governance.native.backend_client import ENV_ACCESS_TOKEN -from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.native.policy_api_client import ( +from uipath.runtime.governance.native.backend_client import ( + ENV_ACCESS_TOKEN, ENV_ORGANIZATION_ID, ENV_TENANT_ID, - POLICY_API_TIMEOUT_SECONDS, - fetch_policy_response, resolve_organization_id, resolve_tenant_id, ) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.native.policy_api_client import ( + POLICY_API_TIMEOUT_SECONDS, + fetch_policy_response, +) logger = logging.getLogger(__name__) From be6e666e5efc04032dbcb60f6043061f05b9a8b7 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Tue, 23 Jun 2026 19:13:39 +0530 Subject: [PATCH 09/18] feat(governance): provider-only policy loading via GovernancePolicyProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the direct backend HTTP fetch with a GovernancePolicyProvider indirection so the runtime no longer owns transport, auth, or wire format. Adds the GovernanceRuntime wrapper and the architecture doc. - src/uipath/runtime/governance/runtime.py: new GovernanceRuntime(delegate, policy_provider). Extracts delegate._agent_definition.is_conversational (depth-capped chain walk), registers the provider, kicks off prefetch. Passthrough at execute/stream/get_schema/dispose — policy loading only, no enforcement yet (evaluator slice lands separately). - src/uipath/runtime/governance/native/loader.py: provider-only loader. set_policy_provider, set_agent_conversational, prefetch_policy_index, get_policy_index, clear_policy_cache. Cached PolicyIndex; fail-open on every failure path (raise / empty / malformed / zero rules / timeout). - src/uipath/runtime/governance/native/_yaml_to_index.py: drop hardcoded default clause-id messages ("A.7.4" / "A.8.4" / "A.10.4"); messages now come from YAML, defaulting to "". - src/uipath/runtime/governance/config.py: docstrings reworded for the provider-supplied enforcement mode (no endpoint references). - Removed src/uipath/runtime/governance/native/policy_api_client.py and src/uipath/runtime/governance/native/backend_client.py — direct HTTP fetcher and its shared helpers. Selector + timeout moved into loader.py. - pyproject.toml: bump uipath-core to ==0.5.21. - tests: new tests/test_governance_runtime.py (extraction, fail-open, selector-overwrite regression, prefetch integration), rewritten tests/test_loader.py for the provider contract, shared StubPolicyProvider in tests/_helpers.py. - docs/governance-architecture.md: provider-only design with explicit 'policy loading only, no enforcement yet' staging caveat, module map, lifecycle diagram, failure-mode table. ruff / mypy clean, 197 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uipath/runtime/governance/config.py | 38 +- .../governance/native/_yaml_to_index.py | 15 +- .../governance/native/backend_client.py | 366 -------------- .../runtime/governance/native/loader.py | 249 +++++----- .../governance/native/policy_api_client.py | 225 --------- src/uipath/runtime/governance/runtime.py | 162 ++++++ tests/_helpers.py | 45 +- tests/conftest.py | 20 +- tests/test_enforcement_mode_default.py | 10 +- tests/test_governance_runtime.py | 460 ++++++++++++++++++ tests/test_loader.py | 328 +++++-------- tests/test_policy_agent_type.py | 105 ---- tests/test_policy_api_client.py | 258 ---------- uv.lock | 2 +- 14 files changed, 921 insertions(+), 1362 deletions(-) delete mode 100644 src/uipath/runtime/governance/native/backend_client.py delete mode 100644 src/uipath/runtime/governance/native/policy_api_client.py create mode 100644 src/uipath/runtime/governance/runtime.py create mode 100644 tests/test_governance_runtime.py delete mode 100644 tests/test_policy_agent_type.py delete mode 100644 tests/test_policy_api_client.py diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py index f74d51d1..d766dfdb 100644 --- a/src/uipath/runtime/governance/config.py +++ b/src/uipath/runtime/governance/config.py @@ -3,10 +3,10 @@ The feature-flag gate (``is_governance_enabled``) lives in :mod:`uipath.core.governance.config` because it is process-level and must be resolvable by callers that do not depend on -``uipath-runtime``. The enforcement mode is *per-policy* — owned by the -backend and delivered on each policy fetch via the ``/runtime/policy`` -endpoint — and therefore lives here in the runtime package alongside the -policy loader that applies it. +``uipath-runtime``. The enforcement mode is *per-policy* — +provider-supplied on each policy load — and therefore lives here in +the runtime package alongside the policy loader that applies it via +:func:`set_enforcement_mode`. """ from __future__ import annotations @@ -23,30 +23,30 @@ class _EnforcementModeState: A single module-level instance backs the get/set/reset helpers, so the mode is updated by mutating an attribute rather than rebinding a module - global. ``mode is None`` means "not yet set by the backend" — until - then (and if the backend omits a mode) governance defaults to AUDIT. + global. ``mode is None`` means "no provider has supplied a mode yet" — + until then (and if the provider omits a mode) governance defaults to + AUDIT. """ def __init__(self) -> None: self.mode: EnforcementMode | None = None -# The enforcement mode is owned by the backend: the policy loader applies -# the mode from the ``/runtime/policy`` response via -# :func:`set_enforcement_mode`. +# The enforcement mode is supplied by the policy provider on each load; +# the loader applies it via :func:`set_enforcement_mode`. _state = _EnforcementModeState() def get_enforcement_mode() -> EnforcementMode: """Return the current enforcement mode. - The canonical source is the backend ``/runtime/policy`` response, - applied by the policy loader via :func:`set_enforcement_mode`. Until - that fetch lands (or if the backend returns no mode), the default is - :attr:`EnforcementMode.AUDIT` — evaluate and log without blocking. - Defaulting to AUDIT avoids the chicken-and-egg where a DISABLED - default would short-circuit evaluation before the background policy - fetch could ever opt the tenant in. + The canonical source is whatever the policy provider supplied on + the most recent load, applied via :func:`set_enforcement_mode`. + Until that load lands (or if the provider returns no mode), the + default is :attr:`EnforcementMode.AUDIT` — evaluate and log without + blocking. Defaulting to AUDIT avoids the chicken-and-egg where a + DISABLED default would short-circuit evaluation before the + background policy load could ever opt the tenant in. """ return _state.mode if _state.mode is not None else EnforcementMode.AUDIT @@ -54,7 +54,7 @@ def get_enforcement_mode() -> EnforcementMode: def set_enforcement_mode(mode: EnforcementMode) -> None: """Set the enforcement mode programmatically. - The policy loader calls this with the backend-supplied mode on each - fetch so the evaluator picks up the platform-controlled value. + The policy loader calls this with the provider-supplied mode on + each load so the evaluator picks up the platform-controlled value. """ - _state.mode = mode \ No newline at end of file + _state.mode = mode diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index c4080f22..3bf264c7 100644 --- a/src/uipath/runtime/governance/native/_yaml_to_index.py +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -2,8 +2,9 @@ Mirrors the shape produced by ``packs/compile_packs.py`` but builds the PolicyIndex directly from parsed YAML data rather than generating Python -source. Used by :mod:`uipath.runtime.governance.native.loader` when policies are fetched -from the governance backend at startup. +source. Used by :mod:`uipath.runtime.governance.native.loader` to +compile the YAML body returned by the registered policy provider into +an in-memory index at startup. Accepts either a single YAML document (one pack) or a multi-document stream (``---``-separated packs). Unknown check types and malformed @@ -334,9 +335,7 @@ def _build_check( }, ) ) - message = str( - data.get("message", "A.7.4: Data quality signal (encoding or entropy)") - ) + message = str(data.get("message", "")) elif check_type == "incident_taxonomy": field = data.get("field", "model_output") @@ -347,7 +346,7 @@ def _build_check( conditions.append( Condition(operator="incident_concern", field=field, value=value) ) - message = str(data.get("message", "A.8.4: Incident signal detected")) + message = str(data.get("message", "")) elif check_type == "commitment_extractor": field = data.get("field", "model_output") @@ -361,9 +360,7 @@ def _build_check( }, ) ) - message = str( - data.get("message", "A.10.4: Customer commitment language detected") - ) + message = str(data.get("message", "")) elif check_type == "sentiment_concern": field = data.get("field", "model_input") diff --git a/src/uipath/runtime/governance/native/backend_client.py b/src/uipath/runtime/governance/native/backend_client.py deleted file mode 100644 index a406c354..00000000 --- a/src/uipath/runtime/governance/native/backend_client.py +++ /dev/null @@ -1,366 +0,0 @@ -"""Governance backend client. - -Hosts the shared infrastructure used by every governance-backend call: - -- :func:`get_backend_base_url` — resolves the cloud host (with the - org/tenant path segments stripped) so each endpoint builder can - append its own scoped path. -- :func:`governance_request_headers` — composes the headers shared by - the policy fetch and the ``/runtime/govern`` compensating POST - (Accept, User-Agent, optional Content-Type, optional Bearer auth). -- :func:`build_governance_url` — composes an org-scoped URL against - the ``agenticgovernance_`` ingress. -- :func:`resolve_organization_id` / :func:`resolve_tenant_id` — read - the active org/tenant from the environment (published by the UiPath - runtime host), keeping runtime independent of ``uipath-platform``. -- :func:`safe_call` — fail-open helper that catches every non-block - exception so governance hooks never crash an agent run. -- Module-level constants — request timeout, service path prefix, - compensation pool size — all the tunables an operator might care - about. Defined once here so the policy fetch, the compensating - ``/runtime/govern`` call, and the loader share one definition. - -The endpoint clients live next door: - -- :mod:`uipath.runtime.governance.native.policy_api_client` — policy fetch -- :mod:`uipath.runtime.governance.native.guardrail_compensation` — /runtime/govern -""" - -from __future__ import annotations - -import logging -import os -from functools import lru_cache -from typing import Callable -from urllib.parse import urlparse - -logger = logging.getLogger(__name__) - -# ---------------------------------------------------------------------------- -# Env-var names (consumed by the helpers below + diagnostic messages) -# ---------------------------------------------------------------------------- - -# Explicit dev/test override — used verbatim, no path-stripping. -ENV_BACKEND_BASE_URL = "UIPATH_GOVERNANCE_BACKEND_URL" -# The canonical platform URL env var. -ENV_PLATFORM_BASE_URL = "UIPATH_URL" -# Bearer token; missing means the policy fetch and compensating call are -# skipped (and that fact is logged) rather than producing 401s on every call. -ENV_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" -# Org / tenant scoping for the agenticgovernance_ ingress. -ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" -ENV_TENANT_ID = "UIPATH_TENANT_ID" -# Trace id used to bind governance spans / compensation records to the -# agent's trace. -ENV_TRACE_ID = "UIPATH_TRACE_ID" -# Job-execution context forwarded in the /runtime/govern payload so the -# server can populate the LLMOps trace record (Doc-2 audit structure). -# Published into the process environment by the UiPath runtime host. -ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" -ENV_JOB_KEY = "UIPATH_JOB_KEY" -ENV_PROCESS_KEY = "UIPATH_PROCESS_UUID" -ENV_REFERENCE_ID = "UIPATH_AGENT_ID" -ENV_AGENT_VERSION = "UIPATH_PROCESS_VERSION" - -# ---------------------------------------------------------------------------- -# Endpoint shape — all governance calls hit the org-scoped agenticgovernance_ -# service. Centralised so adding a third endpoint is "one new path constant" -# instead of "a new path template that someone forgets to keep in sync." -# ---------------------------------------------------------------------------- - -GOVERNANCE_SERVICE_PREFIX = "agenticgovernance_" -POLICY_API_PATH = "api/v1/runtime/policy" -GOVERN_API_PATH = "api/v1/runtime/govern" -TENANT_HEADER = "x-uipath-internal-tenantid" -# Query param on the policy fetch that selects the agent-type view of the -# policy: the server's clause-resolver reads the matching container key -# (``*-in-flight-conversational-agents`` vs ``*-in-flight-agents``). It's a -# representation selector (it changes the returned policy), so it travels as a -# query param — cache-correct and part of resource identification — not a -# header. Values: "conversational" | "autonomous". -AGENT_TYPE_PARAM = "agentType" -AGENT_TYPE_CONVERSATIONAL = "conversational" -AGENT_TYPE_AUTONOMOUS = "autonomous" - -# Default base URL when no override and no UIPATH_URL value is -# available. Used only on developer machines doing fully-offline work; real -# deployments always have UIPATH_URL injected by the host. -_DEFAULT_BACKEND_BASE_URL = "https://alpha.uipath.com" - -# ---------------------------------------------------------------------------- -# Tunables — one place so an ops change is one edit. The values that bound -# how long a single agent run can spend on governance traffic. -# ---------------------------------------------------------------------------- - -# Per-request timeout for any governance backend HTTP call (policy fetch, -# /runtime/govern compensating POST). Same value used everywhere so an agent -# can't accidentally end up with a "long" timeout on one call and "short" on -# another. -BACKEND_REQUEST_TIMEOUT_SECONDS = 10.0 - -# Bound on concurrent /runtime/govern requests in flight. A misbehaving -# agent that fires `before_model` 100 times in a session with three matched -# fallback rules each would otherwise spawn 100 daemon threads; this pool -# caps the concurrency. Saturated submissions are logged and dropped — the -# server still receives traces from the requests that did land. -COMPENSATION_MAX_WORKERS = 4 - -# Browser-shaped User-Agent. Required because the alpha/production -# governance ingress runs a WAF whose default scanner rule set blocks -# ``Python-urllib/``. Identifying as a real browser keeps the -# request from being rejected before any auth/tenant logic runs. -USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/148.0.0.0 Safari/537.36" -) - - -# ---------------------------------------------------------------------------- -# Headers -# ---------------------------------------------------------------------------- - - -def governance_request_headers(*, json_body: bool = False) -> dict[str, str]: - """Return the common HTTP headers for governance backend requests. - - Centralises the headers shared between the policy fetch and the - compensating ``/runtime/govern`` POST so the UA and auth shape are - declared once. - - Args: - json_body: When ``True`` (POST/PATCH/etc. with a JSON payload), - adds ``Content-Type: application/json``. GETs leave it off - so origin servers that 415 on unexpected Content-Type stay - happy. - - Returns: - A new dict with: - - - ``Accept: application/json`` - - ``User-Agent`` (the browser-shaped string above) - - ``Content-Type: application/json`` when ``json_body=True`` - - ``Authorization: Bearer `` when the env - var is set; omitted otherwise (caller decides whether the - missing token is fatal). - - Endpoint-specific headers (e.g. ``x-uipath-internal-tenantid``) are - added by the caller after this helper returns. - """ - headers: dict[str, str] = { - "Accept": "application/json", - "User-Agent": USER_AGENT, - } - if json_body: - headers["Content-Type"] = "application/json" - token = os.environ.get(ENV_ACCESS_TOKEN) - if token: - headers["Authorization"] = f"Bearer {token}" - return headers - - -# ---------------------------------------------------------------------------- -# URL composition -# ---------------------------------------------------------------------------- - - -def _strip_to_origin(raw_url: str) -> str: - """Return ``scheme://host[:port]`` for ``raw_url``, dropping any path. - - Platform URLs are commonly ``https://cloud.uipath.com//``; - the governance endpoints construct their own - ``/{org}/agenticgovernance_/...`` suffix, so the org/tenant segments - in the base must be stripped to avoid a duplicated org path. - """ - parsed = urlparse(raw_url) - if not parsed.scheme or not parsed.netloc: - # Not a parseable absolute URL — leave it to the caller. - return raw_url.rstrip("/") - return f"{parsed.scheme}://{parsed.netloc}" - - -def get_backend_base_url() -> str: - """Resolve the governance backend base URL on each call. - - Resolution order (first hit wins): - - 1. ``UIPATH_GOVERNANCE_BACKEND_URL`` — explicit dev/test override, - used verbatim. - 2. ``UIPATH_URL`` env var — the canonical platform URL. Org/tenant - path segments are stripped so the caller can append its own - org-scoped path. - 3. ``https://alpha.uipath.com`` — last-resort default for offline - development; real deployments always have ``UIPATH_URL`` set. - - Reading on each call (not at import) lets the runtime entrypoint - configure the env vars after this module is already loaded. - """ - explicit_override = os.environ.get(ENV_BACKEND_BASE_URL) - if explicit_override: - return explicit_override.rstrip("/") - - raw = os.environ.get(ENV_PLATFORM_BASE_URL) - if raw: - return _strip_to_origin(raw) - - return _DEFAULT_BACKEND_BASE_URL - - -def build_governance_url(org_id: str, path: str) -> str: - """Compose an org-scoped governance backend URL. - - Final shape: ``{backend_base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}``. - - Args: - org_id: Active organization id; the URL is meaningless without it. - path: API suffix WITHOUT the org/service prefix - (e.g. :data:`POLICY_API_PATH` or :data:`GOVERN_API_PATH`). - """ - base = get_backend_base_url() - return f"{base}/{org_id}/{GOVERNANCE_SERVICE_PREFIX}/{path}" - - -# ---------------------------------------------------------------------------- -# Org / tenant resolution -# ---------------------------------------------------------------------------- - - -def _resolve_env_field(env_var: str) -> str | None: - """Read a runtime-context value from its environment variable. - - Org/tenant ids and job context are published into the process - environment by the UiPath runtime host. Reading them directly keeps - ``uipath-runtime`` independent of ``uipath-platform`` (the lower layer - must not import the higher one). - """ - return os.environ.get(env_var) - - -# ---------------------------------------------------------------------------- -# Agent-type selector (conversational vs autonomous) -# -# Set once by the governance wrapper at runtime init (before the background -# policy prefetch is kicked off) and read by the policy fetch when composing -# the request URL. A process-level holder — not a ContextVar — because the -# prefetch runs on a separate thread that wouldn't inherit a ContextVar, and a -# coded-agent process hosts a single agent so the value is stable per process. -# ---------------------------------------------------------------------------- - -_agent_is_conversational: bool | None = None - - -def set_agent_conversational(value: bool | None) -> None: - """Record whether the hosted agent is conversational. - - ``None`` clears the selector (used by tests / direct callers); the policy - fetch then omits the param and the server applies its default. - """ - global _agent_is_conversational - _agent_is_conversational = value - - -def agent_type_param() -> str | None: - """Return the ``agentType`` query value, or ``None`` when unknown. - - ``"conversational"`` / ``"autonomous"`` map to the server's - conversational-vs-autonomous container keys; ``None`` (selector never set) - omits the param so the server's default applies. - """ - if _agent_is_conversational is None: - return None - return AGENT_TYPE_CONVERSATIONAL if _agent_is_conversational else AGENT_TYPE_AUTONOMOUS - - -def resolve_organization_id() -> str | None: - """Return the current organization id from the environment. - - Returns ``None`` when unset — callers skip the backend interaction - (no URL can be built without an org id) and the agent runs with no - policies / no compensation. - """ - return _resolve_env_field(ENV_ORGANIZATION_ID) - - -def resolve_tenant_id() -> str | None: - """Return the current tenant id from the environment. - - Returns ``None`` when unset — callers skip the backend interaction - since the ``x-uipath-internal-tenantid`` header would be missing. - """ - return _resolve_env_field(ENV_TENANT_ID) - - -@lru_cache(maxsize=1) -def _resolved_job_context() -> tuple[tuple[str, str], ...]: - """Resolve and freeze the job context once per process. - - Returned as a tuple of ``(key, value)`` pairs so the cached value is - immutable — callers materialize a fresh dict each call. Tests that - mutate env vars can invalidate via ``resolve_job_context.cache_clear()``. - """ - candidates = { - "folderKey": _resolve_env_field(ENV_FOLDER_KEY), - "jobKey": _resolve_env_field(ENV_JOB_KEY), - "processKey": _resolve_env_field(ENV_PROCESS_KEY), - "referenceId": _resolve_env_field(ENV_REFERENCE_ID), - "agentVersion": _resolve_env_field(ENV_AGENT_VERSION), - } - return tuple((k, v) for k, v in candidates.items() if v) - - -def resolve_job_context() -> dict[str, str]: - """Return the agent's job-execution context for the govern payload. - - Each field is read from its environment variable and only - included when it resolves to a truthy value, so the server receives - exactly the keys the agent actually knows. Cached per-process — the - underlying values are immutable for the agent's lifetime. The server - maps these onto the LLMOps trace record: - - - ``folderKey`` → ``FolderKey`` / ``uipath.folder_key`` - - ``jobKey`` → ``JobKey`` / ``uipath.job_key`` - - ``processKey`` → ``ProcessKey`` - - ``referenceId`` → ``ReferenceId`` (typically the agent id) - - ``agentVersion`` → ``AgentVersion`` - """ - return dict(_resolved_job_context()) - - -resolve_job_context.cache_clear = _resolved_job_context.cache_clear # type: ignore[attr-defined] - - -# ---------------------------------------------------------------------------- -# Generic safe-call helper. Used by callers that want "log and continue" on -# any unexpected failure path without spelling out the same try/except every -# time. The intentional GovernanceBlockException ALWAYS propagates — only -# this exception type carries policy intent; anything else is a bug. -# ---------------------------------------------------------------------------- - - -def safe_call( - fn: Callable[..., None], - *args: object, - what: str, - **kwargs: object, -) -> None: - """Call ``fn(*args, **kwargs)`` and swallow any non-block exception. - - ``GovernanceBlockException`` propagates (intentional policy block); - everything else is logged at WARNING with the ``what`` label and - swallowed so the agent can continue. Designed for fire-and-forget - governance paths that should never fail an agent run. - - Args: - fn: Callable to invoke. - what: Short label used in the log line on failure - (e.g. ``"BEFORE_AGENT governance check"``). - """ - # Lazy import to avoid pulling uipath-core into module load. - from uipath.core.governance.exceptions import GovernanceBlockException - - try: - fn(*args, **kwargs) - except GovernanceBlockException: - raise - except Exception as exc: # noqa: BLE001 - fail-open by contract - logger.warning("%s failed (continuing): %s", what, exc) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index 6b55022c..6603a505 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -1,51 +1,31 @@ """Policy pack loader. -Resolves the active PolicyIndex at startup. Policies are fetched -exclusively from the governance backend (``api/v1/policy``); there is -no local compiled fallback. When the backend is unavailable, the -access token is unset, or the fetch times out, the loader returns an -empty PolicyIndex and the agent runs without any rules. +Resolves the active PolicyIndex at startup by calling a registered +:class:`GovernancePolicyProvider`. The runtime never contacts the +governance backend directly; the provider owns the wire / transport +(auth, retries, telemetry). When no provider is registered, or the +provider raises / returns an empty body / yields zero rules, the +loader returns an empty PolicyIndex and the agent runs without any +rules. """ from __future__ import annotations import logging -import os import threading import time from collections import Counter import yaml +from uipath.core.governance import GovernancePolicyProvider, PolicyContext from uipath.core.governance.config import is_governance_enabled -from uipath.runtime.governance.config import EnforcementMode, set_enforcement_mode +from uipath.runtime.governance.config import set_enforcement_mode from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml -from uipath.runtime.governance.native.backend_client import ( - ENV_ACCESS_TOKEN, - ENV_ORGANIZATION_ID, - ENV_TENANT_ID, - resolve_organization_id, - resolve_tenant_id, -) from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.native.policy_api_client import ( - POLICY_API_TIMEOUT_SECONDS, - fetch_policy_response, -) logger = logging.getLogger(__name__) -# Pack name aliases for backward compatibility -PACK_ALIASES: dict[str, str] = { - "owasp": "owasp_agentic", - "hipaa": "hipaa_runtime", - "soc2": "soc2_runtime", - "nist": "nist_ai_rmf_runtime", - "eu_ai": "eu_ai_act_runtime", - "iso": "iso42001_runtime", -} - - # Module-level cache _policy_index: PolicyIndex | None = None @@ -57,27 +37,63 @@ _prefetch_event: threading.Event | None = None _prefetch_lock = threading.Lock() -# Default wait when ``get_policy_index()`` blocks on an in-flight -# prefetch. Matched to the policy-API HTTP timeout so a stuck backend -# bounds the total time spent waiting at first hook fire to -# ~POLICY_API_TIMEOUT_SECONDS. If the wait expires we return an empty -# PolicyIndex — the agent runs without any policies rather than -# blocking further or retrying. -_PREFETCH_WAIT_SECONDS = POLICY_API_TIMEOUT_SECONDS +# Upper bound on how long ``get_policy_index()`` waits for an in-flight +# prefetch before falling back to an empty PolicyIndex. The provider +# owns its own transport timeouts; this is the runtime's ceiling on +# blocking the first hook fire. +_PROVIDER_WAIT_SECONDS = 10.0 + +# Registered :class:`GovernancePolicyProvider`. Set by +# :class:`GovernanceRuntime` at init. ``None`` means no provider is +# registered — :func:`load_policy_index` returns an empty PolicyIndex +# in that case. +_policy_provider: GovernancePolicyProvider | None = None + +# Whether the hosted agent is conversational. Travels in the +# :class:`PolicyContext` so the provider can select the matching policy +# view. A process-level holder (not a ContextVar) because the prefetch +# runs on a separate thread that wouldn't inherit one, and a +# coded-agent process hosts a single agent so the value is stable per +# process. ``None`` leaves the selector unset — the provider applies +# its default. +_agent_is_conversational: bool | None = None + + +def set_policy_provider(provider: GovernancePolicyProvider | None) -> None: + """Register the policy provider the loader will use to fetch policies. + + Called once by :class:`GovernanceRuntime` during init before + :func:`prefetch_policy_index`. ``None`` clears the registration — + used by tests and by callers that opt out of governance. + """ + global _policy_provider + _policy_provider = provider + + +def set_agent_conversational(value: bool | None) -> None: + """Record whether the hosted agent is conversational. + + Threaded into :class:`PolicyContext` on every provider call so the + provider can resolve the conversational-vs-autonomous policy view. + ``None`` clears the selector — the provider then applies its + default. + """ + global _agent_is_conversational + _agent_is_conversational = value def prefetch_policy_index() -> None: """Kick off a background load of the policy index. Non-blocking. Designed to be called as early as possible (at - ``GovernanceRuntime.__init__``) so the HTTP call to the governance - backend overlaps with the rest of agent setup. The result lands in - the same module cache that ``get_policy_index()`` reads from; - ``get_policy_index()`` waits on this prefetch when it's in flight. + ``GovernanceRuntime.__init__``) so the policy fetch overlaps with + the rest of agent setup. The result lands in the same module cache + that ``get_policy_index()`` reads from; ``get_policy_index()`` waits + on this prefetch when it's in flight. Idempotent: subsequent calls while the first is running are no-ops, and calls after completion are no-ops. Skipped entirely when the - governance feature flag is OFF so no network call is made. + governance feature flag is OFF so the provider is never invoked. """ global _prefetch_event @@ -116,11 +132,13 @@ def get_policy_index() -> PolicyIndex: Resolution order on first call: 1. If the governance feature flag is OFF, return an empty - PolicyIndex (cached). No network call. + PolicyIndex (cached). Provider is not invoked. 2. If a prefetch (see :func:`prefetch_policy_index`) is in flight, - wait for it to complete (bounded by ``_PREFETCH_WAIT_SECONDS``). - 3. Governance backend at ``api/v1/policy`` (one HTTP GET, cached). - 4. Empty PolicyIndex when the backend is unavailable or times out. + wait for it to complete (bounded by ``_PROVIDER_WAIT_SECONDS``). + 3. Synchronously call :func:`load_policy_index` (which invokes the + registered :class:`GovernancePolicyProvider`). + 4. Empty PolicyIndex when no provider is registered or the + provider fails / returns nothing. Result is cached for the process lifetime; per-hook evaluation never touches the network. Call :func:`clear_policy_cache` to force a @@ -141,7 +159,7 @@ def get_policy_index() -> PolicyIndex: event = _prefetch_event if event is not None: - completed = event.wait(timeout=_PREFETCH_WAIT_SECONDS) + completed = event.wait(timeout=_PROVIDER_WAIT_SECONDS) if completed and _policy_index is not None: return _policy_index if not completed: @@ -150,17 +168,17 @@ def get_policy_index() -> PolicyIndex: logger.warning( "Policy prefetch did not complete in %.1fs; " "agent will run without any policies", - _PREFETCH_WAIT_SECONDS, + _PROVIDER_WAIT_SECONDS, ) _policy_index = PolicyIndex() return _policy_index # Completed but produced no PolicyIndex — the worker hit an - # unexpected error (auth failure, server error, parse failure). - # Do NOT cache the empty result: caching would permanently - # disable governance for the process even though a later - # prefetch / clear_policy_cache could still recover. Return an - # empty index for this call only and leave the cache unset. + # unexpected error (provider failure, parse failure). Do NOT + # cache the empty result: caching would permanently disable + # governance for the process even though a later prefetch / + # clear_policy_cache could still recover. Return an empty index + # for this call only and leave the cache unset. logger.warning( "Policy prefetch completed but produced no PolicyIndex " "(see prior WARN for the root cause); agent will run " @@ -168,34 +186,31 @@ def get_policy_index() -> PolicyIndex: ) return PolicyIndex() - # No prefetch was started (direct callers / tests). Sync load — bounded - # by the HTTP timeout in the API client. + # No prefetch was started (direct callers / tests). Sync load. _policy_index = load_policy_index() return _policy_index -def load_policy_index(pack_name: str | None = None) -> PolicyIndex: - """Load the active PolicyIndex from the governance backend. - - Args: - pack_name: Ignored. Pack selection is controlled entirely by the - backend. +def load_policy_index() -> PolicyIndex: + """Load the active PolicyIndex via the registered policy provider. Returns: - PolicyIndex parsed from the backend response. Empty PolicyIndex - when the backend is unavailable, the token is unset, the YAML + PolicyIndex parsed from the provider response. Empty PolicyIndex + when no provider is registered, the provider raises, the YAML is malformed, or the response yields zero rules. """ start = time.perf_counter() - api_index = _load_from_api() - if api_index is not None: - _log_index_summary(api_index) + provider = _policy_provider + index = _load_from_provider(provider) if provider is not None else None + + if index is not None: + _log_index_summary(index) logger.info( - "Policy index ready: source=backend, total_ms=%.1f", + "Policy index ready: source=provider, total_ms=%.1f", (time.perf_counter() - start) * 1000, ) - return api_index + return index reason = _empty_index_reason() logger.info( @@ -207,85 +222,60 @@ def load_policy_index(pack_name: str | None = None) -> PolicyIndex: def _empty_index_reason() -> str: - """Diagnose why the policy fetch produced nothing.""" - if not resolve_organization_id(): - return ( - f"organization id unavailable — set {ENV_ORGANIZATION_ID}; " - "backend API not contacted" - ) - if not resolve_tenant_id(): - return ( - f"tenant id unavailable — set {ENV_TENANT_ID}; " - "backend API not contacted" - ) - if not os.environ.get(ENV_ACCESS_TOKEN): - return f"{ENV_ACCESS_TOKEN} unset — backend API not contacted" - return "backend returned no policies (timeout / error / empty body)" - - -def _apply_enforcement_mode(mode_str: str | None) -> None: - """Map a backend-supplied mode string onto :class:`EnforcementMode`. - - Unknown values log a warning and leave the existing mode untouched. - """ - if not mode_str: - return - try: - mode = EnforcementMode(mode_str.lower()) - except ValueError: - logger.warning( - "Backend returned unknown enforcement mode %r; keeping current mode", - mode_str, - ) - return - set_enforcement_mode(mode) - logger.info("Enforcement mode set from backend: %s", mode.value) + """Diagnose why policy loading produced nothing.""" + if _policy_provider is None: + return "no policy provider registered" + return "provider returned no policies (error / empty body / zero rules)" -def _load_from_api() -> PolicyIndex | None: - """Fetch and parse the policy index from the governance backend. +def _load_from_provider(provider: GovernancePolicyProvider) -> PolicyIndex | None: + """Fetch and parse the policy index via a :class:`GovernancePolicyProvider`. - Applies the backend-supplied enforcement mode as a side effect. - Returns ``None`` when the backend skips/errors, when the YAML is + Applies the provider-supplied enforcement mode as a side effect. + Returns ``None`` when the provider raises, when the YAML is malformed, or when the resulting index has no rules — caller returns an empty PolicyIndex in those cases. """ start = time.perf_counter() - response = fetch_policy_response() - if response is None: + + ctx = PolicyContext(is_conversational=_agent_is_conversational) + + try: + response = provider.get_policy(ctx) + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning("Policy provider get_policy failed: %s", exc) return None - # Apply the platform-controlled enforcement mode before building the - # index, so anything that reads ``get_enforcement_mode()`` during - # index compilation already sees the right value. - _apply_enforcement_mode(response.mode) + if response.mode is not None: + set_enforcement_mode(response.mode) + logger.info("Enforcement mode set from provider: %s", response.mode.value) - if not response.policy: + if not response.policies: logger.warning( - "Policy fetch returned empty policy field; " + "Policy provider returned empty policies field; " "agent will run without any policies" ) return None try: - index = build_policy_index_from_yaml(response.policy) + index = build_policy_index_from_yaml(response.policies) except yaml.YAMLError as exc: - logger.warning("Policy YAML from backend was malformed: %s", exc) + logger.warning("Policy YAML from provider was malformed: %s", exc) return None except Exception as exc: # noqa: BLE001 - never let load break agent startup - logger.warning("Failed to build PolicyIndex from backend YAML: %s", exc) + logger.warning("Failed to build PolicyIndex from provider YAML: %s", exc) return None if index.total_rules == 0: logger.warning( - "Policy YAML from backend yielded zero rules; " + "Policy YAML from provider yielded zero rules; " "agent will run without any policies" ) return None elapsed_ms = (time.perf_counter() - start) * 1000 logger.info( - "Loaded policy index from backend: packs=%s, rules=%d, elapsed_ms=%.1f", + "Loaded policy index from provider: packs=%s, rules=%d, elapsed_ms=%.1f", index.pack_names, index.total_rules, elapsed_ms, @@ -293,21 +283,8 @@ def _load_from_api() -> PolicyIndex | None: return index -def _backend_base_url() -> str: - """Return the backend base URL for logging; imported lazily to avoid cycles.""" - try: - from uipath.runtime.governance.native.backend_client import ( - get_backend_base_url, - ) - - return get_backend_base_url() - except Exception: # noqa: BLE001 - return "backend" - - def _log_index_summary(index: PolicyIndex) -> None: """Log summary of loaded policy index.""" - # Count rules by hook hook_counts: Counter[str] = Counter() for rule in index.all_rules: hook_counts[rule.hook.value] += 1 @@ -323,9 +300,8 @@ def _log_index_summary(index: PolicyIndex) -> None: def get_available_packs() -> list[str]: """Get list of pack names from the currently loaded policy index. - Returns whatever the backend supplied on the most recent load. - Empty list if no index has been loaded yet or the backend yielded - no packs. + Returns whatever the provider supplied on the most recent load. + Empty list if no index has been loaded yet. """ if _policy_index is None: return [] @@ -335,14 +311,11 @@ def get_available_packs() -> list[str]: def clear_policy_cache() -> None: """Clear the cached policy index and any in-flight prefetch state. - Next call to ``get_policy_index()`` will refetch from the backend. + Next call to ``get_policy_index()`` will reload from the registered + :class:`GovernancePolicyProvider`. """ global _policy_index, _prefetch_event with _prefetch_lock: _policy_index = None _prefetch_event = None logger.debug("Policy index cache cleared") - - -# Backward compatibility alias -reset_policy_index = clear_policy_cache diff --git a/src/uipath/runtime/governance/native/policy_api_client.py b/src/uipath/runtime/governance/native/policy_api_client.py deleted file mode 100644 index 0bc428a8..00000000 --- a/src/uipath/runtime/governance/native/policy_api_client.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Governance policy API client. - -Fetches the governance backend response so policies can be controlled -centrally without redeploying agents. Called once at process startup -from :mod:`uipath.runtime.governance.native.loader`; per-hook evaluation -stays in-process. - -Response shape (JSON):: - - { - "mode": "audit" | "enforce" | "disabled", - "policies": "" - } - -``mode`` is the platform-controlled enforcement mode for the tenant; -the loader applies it via -:func:`uipath.runtime.governance.config.set_enforcement_mode`. ``policies`` -is the YAML the evaluator compiles into a :class:`PolicyIndex`. - -Failure mode is fail-open: when the organization id is unknown, the -access token is missing, the backend errors, or the body can't be -parsed, the caller falls back to an empty PolicyIndex. The fetch is -single-shot (no retry by design — see :func:`_get_once`) so a slow -backend can't extend agent startup beyond -:data:`BACKEND_REQUEST_TIMEOUT_SECONDS`. Nothing in this module ever -raises to the caller. -""" - -from __future__ import annotations - -import json -import logging -import os -import urllib.error -import urllib.request -from dataclasses import dataclass -from urllib.parse import urlencode - -from uipath.runtime.governance.native.backend_client import ( - AGENT_TYPE_PARAM, - BACKEND_REQUEST_TIMEOUT_SECONDS, - ENV_ACCESS_TOKEN, - ENV_ORGANIZATION_ID, - ENV_TENANT_ID, - POLICY_API_PATH, - TENANT_HEADER, - agent_type_param, - build_governance_url, - governance_request_headers, - resolve_organization_id, - resolve_tenant_id, -) - -logger = logging.getLogger(__name__) - -# Re-exported alias kept for callers that imported the old name. -POLICY_API_TIMEOUT_SECONDS = BACKEND_REQUEST_TIMEOUT_SECONDS - - -@dataclass(frozen=True) -class PolicyResponse: - """Parsed governance backend response. - - Attributes: - mode: Enforcement mode string the backend returned - (``"audit"`` / ``"enforce"`` / ``"disabled"``), or ``None`` - when the backend omitted it. Loader applies this via - :func:`uipath.runtime.governance.config.set_enforcement_mode`. - policy: Policy pack YAML to compile into a ``PolicyIndex``. May - be an empty string if the backend returned no rules. - """ - - mode: str | None - policy: str - - -def build_policy_url(org_id: str) -> str: - """Build the policy endpoint URL for the given organization id. - - The tenant id is not part of the URL; it travels in the - ``x-uipath-internal-tenantid`` request header (see - :func:`fetch_policy_response`). - - When the hosted agent's type is known (see - :func:`uipath.runtime.governance.native.backend_client.set_agent_conversational`), - an ``agentType`` query param is appended so the server resolves the - conversational-vs-autonomous container key. Omitted when unknown — the - server then applies its default. - """ - url = build_governance_url(org_id, POLICY_API_PATH) - agent_type = agent_type_param() - if agent_type: - url = f"{url}?{urlencode({AGENT_TYPE_PARAM: agent_type})}" - return url - - -def fetch_policy_response() -> PolicyResponse | None: - """Fetch the governance backend's policy response. - - Single shot, no retry: a failed fetch (timeout / network error / - HTTP error / malformed body) returns ``None`` and the caller falls - back to an empty PolicyIndex. The agent must not spend time on a - second attempt — keeping governance off the critical path is more - important than maximising policy availability. - - Returns: - :class:`PolicyResponse` on success. ``None`` on any failure - path — caller falls back to an empty PolicyIndex. - - Never raises. - """ - try: - return _fetch_policy_response_inner() - except Exception as exc: # noqa: BLE001 - loader path must never raise - logger.warning("Policy fetch failed unexpectedly: %s", exc) - return None - - -def _fetch_policy_response_inner() -> PolicyResponse | None: - org_id = resolve_organization_id() - if not org_id: - logger.warning( - "Policy fetch skipped: organization id is not available " - "(set %s in the environment); governance will run with no " - "policies. The backend API was NOT contacted.", - ENV_ORGANIZATION_ID, - ) - return None - - tenant_id = resolve_tenant_id() - if not tenant_id: - logger.warning( - "Policy fetch skipped: tenant id is not available " - "(set %s in the environment); governance will run with no " - "policies. The backend API was NOT contacted.", - ENV_TENANT_ID, - ) - return None - - policy_url = build_policy_url(org_id) - - token = os.environ.get(ENV_ACCESS_TOKEN) - if not token: - logger.warning( - "Policy fetch skipped: %s is not set in the environment; " - "governance will run with no policies.", - ENV_ACCESS_TOKEN, - ) - return None - - # Policy fetch is a GET; ``json_body=False`` so ``Content-Type`` is - # omitted. Strict origin servers may 415 on unexpected Content-Type - # for GETs (see :func:`governance_request_headers` docstring). - headers = governance_request_headers(json_body=False) - headers[TENANT_HEADER] = tenant_id - logger.info("Policy fetch starting (org=%s, tenant=%s)", org_id, tenant_id) - - body = _get_once(policy_url, headers) - if body is None: - return None - return _parse_policy_body(body) - - -def _get_once(url: str, headers: dict[str, str]) -> bytes | None: - """GET ``url`` once. Returns body bytes, or ``None`` on any failure. - - No retry by design — see :func:`fetch_policy_response` for the - rationale. Every failure path logs a single WARNING and returns - ``None`` so the caller (the loader) falls back to an empty - PolicyIndex without delay. - """ - request = urllib.request.Request(url, headers=headers, method="GET") - try: - with urllib.request.urlopen( # noqa: S310 - URL is built from config - request, timeout=BACKEND_REQUEST_TIMEOUT_SECONDS - ) as response: - return response.read() - except urllib.error.HTTPError as exc: - logger.warning("Policy fetch returned HTTP %d: %s", exc.code, exc) - except (urllib.error.URLError, TimeoutError, OSError) as exc: - logger.warning("Policy fetch failed: %s", exc) - return None - - -def _parse_policy_body(body: bytes) -> PolicyResponse | None: - """Parse the JSON envelope into a :class:`PolicyResponse`.""" - if not body: - logger.warning("Policy fetch returned empty body") - return None - - try: - payload = json.loads(body.decode("utf-8")) - except UnicodeDecodeError as exc: - logger.warning("Policy fetch returned non-UTF8 body: %s", exc) - return None - except json.JSONDecodeError as exc: - logger.warning( - "Policy fetch returned malformed JSON " - "(server may have returned an HTML error page): %s", - exc, - ) - return None - - if not isinstance(payload, dict): - logger.warning( - "Policy fetch returned unexpected JSON shape (expected object, got %s)", - type(payload).__name__, - ) - return None - - raw_mode = payload.get("mode") - mode = raw_mode if isinstance(raw_mode, str) and raw_mode else None - - raw_policy = payload.get("policies", "") - if not isinstance(raw_policy, str): - logger.warning( - "Policy fetch returned non-string 'policies' field (got %s)", - type(raw_policy).__name__, - ) - return None - - logger.info( - "Policy fetch ok: mode=%s, policy_chars=%d", mode, len(raw_policy) - ) - return PolicyResponse(mode=mode, policy=raw_policy) diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py new file mode 100644 index 00000000..12001c29 --- /dev/null +++ b/src/uipath/runtime/governance/runtime.py @@ -0,0 +1,162 @@ +"""Governance runtime wrapper. + +Wraps a :class:`UiPathRuntimeProtocol` delegate so policy data is sourced +through a :class:`GovernancePolicyProvider`. The provider owns the wire +/ transport (auth, retries, telemetry); the runtime only consumes the +parsed :class:`PolicyResponse`. There is no direct backend fallback — +when ``policy_provider`` is ``None`` the agent runs without any +governance policies. + +**Staging caveat — policy loading only, no enforcement yet.** This +module is the policy-loading scaffold: ``__init__`` registers the +provider, extracts the conversational/autonomous selector, and kicks +off a background prefetch into the loader cache. ``execute`` / +``stream`` / ``get_schema`` / ``dispose`` are pure passthroughs — no +per-hook policy evaluation runs. The evaluator + adapter wiring that +consumes :func:`get_policy_index` lands in a follow-up slice. Customers +constructing :class:`GovernanceRuntime` today get policy loading without +policy enforcement; this is intentional and will change when the +evaluator slice merges. +""" + +from __future__ import annotations + +import logging +from typing import Any, AsyncGenerator + +from uipath.core.governance import GovernancePolicyProvider +from uipath.core.governance.config import is_governance_enabled + +from uipath.runtime.base import ( + UiPathExecuteOptions, + UiPathRuntimeProtocol, + UiPathStreamOptions, +) +from uipath.runtime.events import UiPathRuntimeEvent +from uipath.runtime.governance.native.loader import ( + prefetch_policy_index, + set_agent_conversational, + set_policy_provider, +) +from uipath.runtime.result import UiPathRuntimeResult +from uipath.runtime.schema import UiPathRuntimeSchema + +logger = logging.getLogger(__name__) + +# Bound on how deeply we walk ``_delegate`` / ``delegate`` chains when +# looking for an :class:`AgentDefinition`. Wrappers like +# :class:`UiPathExecutionRuntime` and :class:`UiPathResumableRuntime` +# add at most a handful of layers; 10 is well above any realistic +# stack and keeps a pathological self-referential wrapper from looping. +_MAX_DELEGATE_UNWRAP_DEPTH = 10 + + +def _extract_is_conversational(delegate: object) -> bool | None: + """Read ``is_conversational`` off the delegate's agent definition. + + Walks ``delegate._agent_definition.is_conversational`` (the + LicensedRuntime pattern published by the agents SDK), unwrapping + the ``_delegate`` / ``delegate`` chain up to + :data:`_MAX_DELEGATE_UNWRAP_DEPTH` so wrapper layers don't hide the + licensed runtime. + + Returns ``None`` when no agent definition is reachable — the + provider then applies its default rather than the runtime guessing + a value. + """ + node: object | None = delegate + for _ in range(_MAX_DELEGATE_UNWRAP_DEPTH): + if node is None: + break + agent_def = getattr(node, "_agent_definition", None) + if agent_def is not None: + value = getattr(agent_def, "is_conversational", None) + if value is not None: + return bool(value) + node = getattr(node, "_delegate", None) or getattr(node, "delegate", None) + return None + + +class GovernanceRuntime: + """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. + + Registers the supplied :class:`GovernancePolicyProvider` with the + policy loader and kicks off a non-blocking prefetch so the policy + pack overlaps with the rest of agent setup. When ``policy_provider`` + is ``None``, no provider is registered and the agent runs without + any governance policies (the loader yields an empty PolicyIndex). + + **Policy loading only — no enforcement yet.** ``execute`` / ``stream`` + / ``get_schema`` / ``dispose`` are passthroughs to the delegate; no + per-hook policy evaluation runs in this slice. The evaluator and + framework adapter wiring that consumes :func:`get_policy_index` is + staged separately. Constructing this wrapper today gives you the + policy load (provider invoked, index cached) but no actual + enforcement of the loaded rules. + """ + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + policy_provider: GovernancePolicyProvider | None, + ): + """Initialize the governance runtime. + + Args: + delegate: The wrapped runtime to forward execution to. + policy_provider: Source of the policy pack. ``None`` means + no policies will be loaded — the agent runs without + governance for the lifetime of this instance. + """ + self._delegate = delegate + self._policy_provider = policy_provider + + if is_governance_enabled(): + # Record agent-type before the prefetch fires so the + # provider's first ``get_policy`` call sees the right + # selector on its ``PolicyContext``. Wrapped in try/except + # so a misbehaving delegate getattr can't break runtime + # init — fail-open: on failure the selector keeps whatever + # value an integration may have set externally. + # + # Only write when extraction returned a concrete bool. An + # extraction miss (``None``) leaves the selector untouched + # so an externally-set value (e.g. an integration that + # pre-seeded the selector from a different signal) is not + # silently clobbered by our init. + try: + extracted = _extract_is_conversational(delegate) + except Exception as exc: # noqa: BLE001 - fail-open + logger.warning( + "Failed to extract is_conversational from delegate: %s", exc + ) + else: + if extracted is not None: + set_agent_conversational(extracted) + set_policy_provider(policy_provider) + prefetch_policy_index() + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the delegate. Policy evaluation hooks are wired separately.""" + return await self._delegate.execute(input, options=options) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events from the delegate. Hooks are wired separately.""" + async for event in self._delegate.stream(input, options=options): + yield event + + async def get_schema(self) -> UiPathRuntimeSchema: + """Passthrough schema for the delegate.""" + return await self._delegate.get_schema() + + async def dispose(self) -> None: + """Dispose the delegate.""" + await self._delegate.dispose() diff --git a/tests/_helpers.py b/tests/_helpers.py index 7d839ea5..c7dbbd84 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,12 +1,16 @@ """Shared test-only helpers. -Keeps test concerns out of the production governance package: the -enforcement-mode reset used for per-test isolation lives here rather than -in :mod:`uipath.runtime.governance.config`. +Keeps test concerns out of the production governance package: per-test +isolation utilities and shared stubs live here rather than inside the +production modules. """ from __future__ import annotations +import time + +from uipath.core.governance import PolicyContext, PolicyResponse + from uipath.runtime.governance import config @@ -14,6 +18,37 @@ def reset_enforcement_mode() -> None: """Clear the process-wide enforcement mode so the AUDIT default re-applies. Test isolation only — production code never resets the mode; the policy - loader sets it from the backend ``/runtime/policy`` response. + loader sets it from the provider-supplied :class:`PolicyResponse`. + """ + config._state.mode = None + + +class StubPolicyProvider: + """Minimal in-memory :class:`GovernancePolicyProvider` for tests. + + Records every :class:`PolicyContext` it receives so tests can assert + on the selector that travelled to the provider. Either returns a + pre-canned :class:`PolicyResponse` or raises a pre-canned exception; + the optional ``slow`` knob lets tests exercise the prefetch-wait + path. """ - config._state.mode = None \ No newline at end of file + + def __init__( + self, + response: PolicyResponse | None = None, + raises: Exception | None = None, + slow: float = 0.0, + ): + self.calls: list[PolicyContext] = [] + self._response = response + self._raises = raises + self._slow = slow + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + self.calls.append(context) + if self._slow: + time.sleep(self._slow) + if self._raises is not None: + raise self._raises + assert self._response is not None + return self._response diff --git a/tests/conftest.py b/tests/conftest.py index e337e968..78ea6fff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,27 +23,23 @@ def temp_dir() -> Generator[str, None, None]: def _reset_governance_process_state() -> Generator[None, None, None]: """Clear process-level governance state around every test. - The native governance layer keeps two pieces of state at module scope: - the conversational/autonomous selector consumed by the policy fetch, - and the memoized job-context. Both are stable per process in + The loader keeps the conversational selector and the registered + policy provider at module scope. Both are stable per process in production but leak across tests when not reset, masking ordering - bugs and producing flakes. - - ``backend_client`` is imported lazily and guarded: this shared - conftest ships alongside the foundation slice, where that module may - not exist yet, and the reset is simply a no-op until it does. + bugs and producing flakes. Import is guarded so this fixture is a + no-op when the governance package isn't built yet. """ try: - from uipath.runtime.governance.native.backend_client import ( - resolve_job_context, + from uipath.runtime.governance.native.loader import ( set_agent_conversational, + set_policy_provider, ) except ImportError: yield return set_agent_conversational(None) - resolve_job_context.cache_clear() + set_policy_provider(None) yield set_agent_conversational(None) - resolve_job_context.cache_clear() + set_policy_provider(None) diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py index 992641aa..5159c78f 100644 --- a/tests/test_enforcement_mode_default.py +++ b/tests/test_enforcement_mode_default.py @@ -1,13 +1,13 @@ """Tests for the default enforcement-mode resolution. The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at -runtime construction and the background policy fetch can run. If the -backend later returns ``disabled``, ``set_enforcement_mode`` flips the -mode and ``evaluate()`` short-circuits per-call. +runtime construction and the background policy load can run. If the +provider later returns ``disabled``, ``set_enforcement_mode`` flips +the mode and ``evaluate()`` short-circuits per-call. Resolution (per :func:`get_enforcement_mode`): -1. The backend-supplied value set via ``set_enforcement_mode`` (the - ``/runtime/policy`` response, applied by the policy loader). +1. The provider-supplied value applied via ``set_enforcement_mode`` by + the policy loader. 2. Default ``AUDIT``. """ diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py new file mode 100644 index 00000000..b2b126b1 --- /dev/null +++ b/tests/test_governance_runtime.py @@ -0,0 +1,460 @@ +"""Tests for the GovernanceRuntime wrapper and the provider loader path.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +from uipath.core.governance import ( + EnforcementMode, + PolicyResponse, +) + +from tests._helpers import StubPolicyProvider, reset_enforcement_mode +from uipath.runtime.governance.config import get_enforcement_mode +from uipath.runtime.governance.native import loader +from uipath.runtime.governance.native.loader import ( + _load_from_provider, + clear_policy_cache, + load_policy_index, + set_agent_conversational, + set_policy_provider, +) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.runtime import ( + GovernanceRuntime, + _extract_is_conversational, +) + +SIMPLE_POLICY_YAML = """ +standard: provider-pack +version: "1.0" +rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["leak"] +""" + + +@pytest.fixture(autouse=True) +def _enable_ff_and_reset(monkeypatch: pytest.MonkeyPatch): + """Reset module state and turn the governance FF on per test.""" + from uipath.core.feature_flags import FeatureFlags + + clear_policy_cache() + reset_enforcement_mode() + set_policy_provider(None) + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) + yield + clear_policy_cache() + reset_enforcement_mode() + set_policy_provider(None) + FeatureFlags.reset_flags() + + +# --------------------------------------------------------------------------- +# _load_from_provider — direct unit tests +# --------------------------------------------------------------------------- + + +def test_load_from_provider_builds_index_and_applies_mode() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) + ) + + index = _load_from_provider(provider) + + assert isinstance(index, PolicyIndex) + assert index.total_rules == 1 + assert "provider-pack" in index.pack_names + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +def test_load_from_provider_passes_is_conversational_in_context() -> None: + set_agent_conversational(True) + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + _load_from_provider(provider) + + assert len(provider.calls) == 1 + assert provider.calls[0].is_conversational is True + + +def test_load_from_provider_returns_none_when_provider_raises() -> None: + provider = StubPolicyProvider(raises=RuntimeError("boom")) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_empty_policies() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_zero_rules() -> None: + empty_pack_yaml = "standard: empty\nrules: []\n" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=empty_pack_yaml) + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_malformed_yaml() -> None: + provider = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.AUDIT, policies="key: : invalid: : yaml" + ) + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_does_not_change_mode_when_none() -> None: + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.ENFORCE) + provider = StubPolicyProvider( + response=PolicyResponse(mode=None, policies=SIMPLE_POLICY_YAML) + ) + + _load_from_provider(provider) + + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +# --------------------------------------------------------------------------- +# load_policy_index dispatch — registered provider vs empty fallback +# --------------------------------------------------------------------------- + + +def test_load_policy_index_uses_registered_provider() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + set_policy_provider(provider) + + index = load_policy_index() + + assert index.total_rules == 1 + assert provider.calls, "provider.get_policy was not called" + + +def test_load_policy_index_returns_empty_when_no_provider() -> None: + """No provider registered → empty PolicyIndex (no fallback path).""" + index = load_policy_index() + assert index.total_rules == 0 + + +def test_load_policy_index_empty_when_provider_yields_nothing() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") + ) + set_policy_provider(provider) + + index = load_policy_index() + + assert index.total_rules == 0 + + +# --------------------------------------------------------------------------- +# GovernanceRuntime +# --------------------------------------------------------------------------- + + +class _StubDelegate: + """Captures delegate calls so the passthroughs can be asserted.""" + + def __init__(self) -> None: + self.execute_calls: list[tuple[Any, Any]] = [] + self.stream_calls: list[tuple[Any, Any]] = [] + self.disposed = False + self.schema_called = False + + async def execute(self, input=None, options=None): + self.execute_calls.append((input, options)) + return "result" + + async def stream(self, input=None, options=None): + self.stream_calls.append((input, options)) + for event in ("a", "b"): + yield event + + async def get_schema(self): + self.schema_called = True + return "schema" + + async def dispose(self): + self.disposed = True + + +def test_governance_runtime_registers_provider_and_prefetches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Init wires provider into loader state and kicks off prefetch.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + # Spy on prefetch + set_policy_provider so we don't need a real + # background thread in the unit test. + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + delegate = _StubDelegate() + + GovernanceRuntime(delegate, policy_provider=provider) + + set_provider_spy.assert_called_once_with(provider) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_with_none_provider_still_prefetches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Passing ``None`` registers None → loader yields an empty PolicyIndex.""" + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + GovernanceRuntime(_StubDelegate(), policy_provider=None) + + set_provider_spy.assert_called_once_with(None) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_skips_prefetch_when_ff_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FF off → no provider registration, no prefetch.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + GovernanceRuntime(_StubDelegate(), policy_provider=StubPolicyProvider()) + + assert not set_provider_spy.called + assert not prefetch_spy.called + + +async def test_governance_runtime_execute_delegates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + result = await runtime.execute({"x": 1}) + + assert result == "result" + assert delegate.execute_calls == [({"x": 1}, None)] + + +async def test_governance_runtime_stream_delegates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + events = [e async for e in runtime.stream({"x": 1})] + + assert events == ["a", "b"] + assert delegate.stream_calls == [({"x": 1}, None)] + + +async def test_governance_runtime_schema_and_dispose_delegate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + assert await runtime.get_schema() == "schema" + await runtime.dispose() + assert delegate.schema_called + assert delegate.disposed + + +# --------------------------------------------------------------------------- +# _extract_is_conversational +# --------------------------------------------------------------------------- + + +def test_extract_is_conversational_true_from_agent_definition() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True) + ) + assert _extract_is_conversational(delegate) is True + + +def test_extract_is_conversational_false_from_agent_definition() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=False) + ) + assert _extract_is_conversational(delegate) is False + + +def test_extract_is_conversational_returns_none_when_unreachable() -> None: + """No ``_agent_definition`` anywhere on the chain → ``None`` (let the provider default).""" + assert _extract_is_conversational(SimpleNamespace()) is None + + +def test_extract_is_conversational_returns_none_when_field_is_none() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=None) + ) + assert _extract_is_conversational(delegate) is None + + +def test_extract_is_conversational_unwraps_via_underscore_delegate() -> None: + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + outer = SimpleNamespace(_delegate=inner) + assert _extract_is_conversational(outer) is True + + +def test_extract_is_conversational_unwraps_via_delegate_attr() -> None: + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) + outer = SimpleNamespace(delegate=inner) + assert _extract_is_conversational(outer) is False + + +def test_extract_is_conversational_depth_capped() -> None: + """A pathological self-referential wrapper can't loop forever.""" + self_ref = SimpleNamespace() + self_ref._delegate = self_ref # type: ignore[attr-defined] + assert _extract_is_conversational(self_ref) is None + + +# --------------------------------------------------------------------------- +# GovernanceRuntime wires the selector +# --------------------------------------------------------------------------- + + +def test_governance_runtime_sets_agent_type_from_delegate() -> None: + """Init reads ``delegate._agent_definition.is_conversational`` and writes the selector.""" + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True), + execute=_StubDelegate().execute, + stream=_StubDelegate().stream, + get_schema=_StubDelegate().get_schema, + dispose=_StubDelegate().dispose, + ) + + # Don't run the real prefetch thread — just confirm the selector + # ended up where the provider would read it. + GovernanceRuntime(delegate, policy_provider=None) + + assert loader._agent_is_conversational is True + + +def test_governance_runtime_sets_none_when_agent_definition_missing() -> None: + """No ``_agent_definition`` → selector stays unset (``None``).""" + GovernanceRuntime(_StubDelegate(), policy_provider=None) + assert loader._agent_is_conversational is None + + +def test_governance_runtime_preserves_externally_set_selector_on_extraction_miss() -> None: + """Externally-set selector survives a runtime init that finds no ``_agent_definition``. + + Regression: previously ``__init__`` unconditionally wrote whatever + ``_extract_is_conversational`` returned, so an extraction miss + (``None``) silently clobbered a value an integration had pre-seeded. + """ + set_agent_conversational(True) + GovernanceRuntime(_StubDelegate(), policy_provider=None) + assert loader._agent_is_conversational is True + + +def test_governance_runtime_fails_open_when_extraction_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A pathological delegate accessor raising mid-extraction can't break init.""" + monkeypatch.setattr( + "uipath.runtime.governance.runtime._extract_is_conversational", + MagicMock(side_effect=RuntimeError("boom")), + ) + set_provider_spy = MagicMock() + prefetch_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + + # No exception escapes; the rest of init still runs. + GovernanceRuntime(_StubDelegate(), policy_provider=None) + + set_provider_spy.assert_called_once_with(None) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_skips_extraction_when_ff_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FF off → no selector write, no provider registration, no prefetch.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + + extract_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime._extract_is_conversational", extract_spy + ) + + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True), + execute=_StubDelegate().execute, + stream=_StubDelegate().stream, + get_schema=_StubDelegate().get_schema, + dispose=_StubDelegate().dispose, + ) + GovernanceRuntime(delegate, policy_provider=None) + + assert not extract_spy.called + assert loader._agent_is_conversational is None diff --git a/tests/test_loader.py b/tests/test_loader.py index 202de394..23df2d6d 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,36 +1,39 @@ """Tests for the policy loader module. -Covers prefetch / get_policy_index / load_policy_index / _apply_enforcement_mode -plus the empty-index reason helper. +Provider-only world: the loader fetches policies exclusively through a +registered :class:`GovernancePolicyProvider`. Tests here cover the +caching, FF-gate, prefetch coordination, and fallback-to-empty behavior +that's independent of any specific provider. End-to-end provider +plumbing (mode application, YAML parsing, runtime wrapper integration) +lives in :mod:`tests.test_governance_runtime`. """ from __future__ import annotations import threading import time +from typing import Any from unittest.mock import patch import pytest -import yaml - -from tests._helpers import reset_enforcement_mode -from uipath.runtime.governance.config import ( +from uipath.core.governance import ( EnforcementMode, - get_enforcement_mode, + PolicyContext, + PolicyResponse, ) + +from tests._helpers import StubPolicyProvider, reset_enforcement_mode from uipath.runtime.governance.native import loader from uipath.runtime.governance.native.loader import ( - _apply_enforcement_mode, _empty_index_reason, - _load_from_api, clear_policy_cache, get_available_packs, get_policy_index, load_policy_index, prefetch_policy_index, + set_policy_provider, ) from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.native.policy_api_client import PolicyResponse SIMPLE_POLICY_YAML = """ standard: test-pack @@ -44,25 +47,25 @@ """ +def _ok_response() -> PolicyResponse: + return PolicyResponse( + mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML + ) + + @pytest.fixture(autouse=True) -def _clean_loader_state(monkeypatch: pytest.MonkeyPatch): - """Each test starts with a fresh loader cache and a known env. +def _clean_loader_state(): + """Each test starts with a fresh loader cache and FF on.""" + from uipath.core.feature_flags import FeatureFlags - Without this, tests leak the policy_index module global and - `_prefetch_event` into one another. - """ clear_policy_cache() reset_enforcement_mode() - # Enable the FF so the loader doesn't short-circuit immediately. - from uipath.core.feature_flags import FeatureFlags - + set_policy_provider(None) FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) - monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") - monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") - monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") yield clear_policy_cache() reset_enforcement_mode() + set_policy_provider(None) FeatureFlags.reset_flags() @@ -71,153 +74,43 @@ def _clean_loader_state(monkeypatch: pytest.MonkeyPatch): # --------------------------------------------------------------------------- -def test_empty_index_reason_missing_org_id(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) +def test_empty_index_reason_no_provider() -> None: msg = _empty_index_reason() - assert "UIPATH_ORGANIZATION_ID" in msg + assert "no policy provider" in msg -def test_empty_index_reason_missing_tenant_id(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) +def test_empty_index_reason_with_provider() -> None: + set_policy_provider(StubPolicyProvider(response=_ok_response())) msg = _empty_index_reason() - assert "UIPATH_TENANT_ID" in msg - - -def test_empty_index_reason_missing_token(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("UIPATH_ACCESS_TOKEN", raising=False) - msg = _empty_index_reason() - assert "UIPATH_ACCESS_TOKEN" in msg - - -def test_empty_index_reason_backend_returned_nothing() -> None: - """All env present → reason is 'backend returned no policies'.""" - msg = _empty_index_reason() - assert "backend returned no policies" in msg - - -# --------------------------------------------------------------------------- -# _apply_enforcement_mode -# --------------------------------------------------------------------------- - - -def test_apply_enforcement_mode_none_leaves_current() -> None: - """Calling with ``None`` is a no-op — the existing mode is preserved.""" - from uipath.runtime.governance.config import set_enforcement_mode - - set_enforcement_mode(EnforcementMode.ENFORCE) - _apply_enforcement_mode(None) - assert get_enforcement_mode() == EnforcementMode.ENFORCE - - -def test_apply_enforcement_mode_empty_string_leaves_current() -> None: - from uipath.runtime.governance.config import set_enforcement_mode - - set_enforcement_mode(EnforcementMode.AUDIT) - _apply_enforcement_mode("") - assert get_enforcement_mode() == EnforcementMode.AUDIT - - -@pytest.mark.parametrize( - "mode_str,expected", - [ - ("audit", EnforcementMode.AUDIT), - ("enforce", EnforcementMode.ENFORCE), - ("disabled", EnforcementMode.DISABLED), - ("AUDIT", EnforcementMode.AUDIT), # case-insensitive - ], -) -def test_apply_enforcement_mode_known_values( - mode_str: str, expected: EnforcementMode -) -> None: - _apply_enforcement_mode(mode_str) - assert get_enforcement_mode() == expected - - -def test_apply_enforcement_mode_unknown_value_keeps_current() -> None: - from uipath.runtime.governance.config import set_enforcement_mode - - set_enforcement_mode(EnforcementMode.AUDIT) - _apply_enforcement_mode("not-a-real-mode") - # Mode is unchanged after the warning. - assert get_enforcement_mode() == EnforcementMode.AUDIT + assert "provider returned no policies" in msg # --------------------------------------------------------------------------- -# _load_from_api +# load_policy_index — public entry # --------------------------------------------------------------------------- -def test_load_from_api_returns_none_when_fetch_returns_none() -> None: - with patch.object(loader, "fetch_policy_response", return_value=None): - assert _load_from_api() is None - - -def test_load_from_api_returns_none_when_policy_is_empty() -> None: - """A response with mode but empty policies field is treated as nothing.""" - response = PolicyResponse(mode="audit", policy="") - with patch.object(loader, "fetch_policy_response", return_value=response): - assert _load_from_api() is None - - -def test_load_from_api_applies_mode_then_parses() -> None: - """The mode is applied BEFORE the YAML is parsed, so downstream sees it.""" - response = PolicyResponse(mode="enforce", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - index = _load_from_api() +def test_load_policy_index_empty_when_no_provider() -> None: + """No provider registered → empty PolicyIndex.""" + index = load_policy_index() assert isinstance(index, PolicyIndex) - assert index.total_rules == 1 - assert get_enforcement_mode() == EnforcementMode.ENFORCE - - -def test_load_from_api_swallows_yaml_error() -> None: - """A malformed YAML body produces None, not an exception.""" - response = PolicyResponse(mode="audit", policy="key: : invalid: : yaml") - with patch.object(loader, "fetch_policy_response", return_value=response): - with patch.object( - loader, - "build_policy_index_from_yaml", - side_effect=yaml.YAMLError("bad yaml"), - ): - assert _load_from_api() is None - - -def test_load_from_api_swallows_unexpected_exception() -> None: - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - with patch.object( - loader, - "build_policy_index_from_yaml", - side_effect=RuntimeError("library bug"), - ): - assert _load_from_api() is None - - -def test_load_from_api_returns_none_when_zero_rules() -> None: - """YAML parses cleanly but yields no rules → treated as no-op.""" - empty_pack_yaml = "standard: empty\nrules: []\n" - response = PolicyResponse(mode="audit", policy=empty_pack_yaml) - with patch.object(loader, "fetch_policy_response", return_value=response): - assert _load_from_api() is None + assert index.total_rules == 0 -# --------------------------------------------------------------------------- -# load_policy_index — public entry -# --------------------------------------------------------------------------- +def test_load_policy_index_uses_registered_provider() -> None: + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + index = load_policy_index() -def test_load_policy_index_success_path() -> None: - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - index = load_policy_index() assert isinstance(index, PolicyIndex) assert "test-pack" in index.pack_names + assert len(provider.calls) == 1 -def test_load_policy_index_returns_empty_on_failure() -> None: - """When the API yields None, the loader returns an empty PolicyIndex.""" - with patch.object(loader, "fetch_policy_response", return_value=None): - index = load_policy_index() - assert isinstance(index, PolicyIndex) +def test_load_policy_index_returns_empty_when_provider_raises() -> None: + set_policy_provider(StubPolicyProvider(raises=RuntimeError("boom"))) + index = load_policy_index() assert index.total_rules == 0 @@ -227,33 +120,35 @@ def test_load_policy_index_returns_empty_on_failure() -> None: def test_get_policy_index_caches_after_first_call() -> None: - """A second call returns the cached index without re-fetching.""" - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object( - loader, "fetch_policy_response", return_value=response - ) as mock_fetch: - a = get_policy_index() - b = get_policy_index() + """A second call returns the cached index without re-invoking the provider.""" + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + a = get_policy_index() + b = get_policy_index() + assert a is b - assert mock_fetch.call_count == 1 + assert len(provider.calls) == 1 def test_get_policy_index_short_circuits_when_ff_off() -> None: - """FF off → return an empty index without contacting the backend.""" + """FF off → return an empty index without invoking the provider.""" from uipath.core.feature_flags import FeatureFlags FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - with patch.object(loader, "fetch_policy_response") as mock_fetch: - index = get_policy_index() + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + index = get_policy_index() + assert index.total_rules == 0 - assert not mock_fetch.called + assert provider.calls == [] def test_get_policy_index_sync_load_when_no_prefetch() -> None: """Without a prefetch in flight, get_policy_index synchronously loads.""" - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - index = get_policy_index() + set_policy_provider(StubPolicyProvider(response=_ok_response())) + index = get_policy_index() assert index.total_rules == 1 @@ -266,19 +161,20 @@ def test_prefetch_is_idempotent() -> None: """Second call while first is in flight is a no-op (no second thread).""" block = threading.Event() - def _slow_fetch(): + def _slow_get(context: PolicyContext) -> PolicyResponse: block.wait(timeout=2.0) - return None + return _ok_response() - with patch.object(loader, "fetch_policy_response", side_effect=_slow_fetch): - prefetch_policy_index() - first_event = loader._prefetch_event - prefetch_policy_index() - assert loader._prefetch_event is first_event - # Let the worker finish so the autouse fixture's clear runs cleanly. - block.set() - if first_event is not None: - first_event.wait(timeout=2.0) + provider: Any = type("P", (), {"get_policy": staticmethod(_slow_get)})() + set_policy_provider(provider) + + prefetch_policy_index() + first_event = loader._prefetch_event + prefetch_policy_index() + assert loader._prefetch_event is first_event + block.set() + if first_event is not None: + first_event.wait(timeout=2.0) def test_prefetch_skipped_when_ff_off() -> None: @@ -286,54 +182,52 @@ def test_prefetch_skipped_when_ff_off() -> None: from uipath.core.feature_flags import FeatureFlags FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - with patch.object(loader, "fetch_policy_response") as mock_fetch: - prefetch_policy_index() - assert not mock_fetch.called + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + prefetch_policy_index() + + assert provider.calls == [] assert loader._prefetch_event is None def test_prefetch_no_op_when_index_already_loaded() -> None: """If the index is already cached, prefetch is a no-op.""" - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - get_policy_index() # populate the cache - with patch.object(loader, "fetch_policy_response") as mock_fetch: - prefetch_policy_index() - assert not mock_fetch.called + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + get_policy_index() # populate the cache + + prefetch_policy_index() + + assert len(provider.calls) == 1 def test_get_policy_index_waits_for_prefetch_then_returns() -> None: """When a prefetch is in flight, get_policy_index waits for completion.""" - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) started = threading.Event() release = threading.Event() - def _fetch(): + def _fetch(context: PolicyContext) -> PolicyResponse: started.set() release.wait(timeout=2.0) - return response - - with patch.object(loader, "fetch_policy_response", side_effect=_fetch): - prefetch_policy_index() - assert started.wait(timeout=2.0) - # Release the worker in a side thread so get_policy_index's wait - # actually overlaps with the slow fetch. - threading.Thread( - target=lambda: (time.sleep(0.05), release.set()), daemon=True - ).start() - index = get_policy_index() + return _ok_response() + + provider: Any = type("P", (), {"get_policy": staticmethod(_fetch)})() + set_policy_provider(provider) + + prefetch_policy_index() + assert started.wait(timeout=2.0) + threading.Thread( + target=lambda: (time.sleep(0.05), release.set()), daemon=True + ).start() + index = get_policy_index() assert index.total_rules == 1 def test_get_policy_index_logs_when_prefetch_completes_with_empty_index( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The 'completed but produced no PolicyIndex' branch fires on auth/parse fail. - - Capturing via a logger mock instead of caplog because some - test-isolation paths (other tests installing log interceptors) - can prevent records from reaching caplog's root-attached handler. - """ + """The 'completed but produced no PolicyIndex' branch fires on provider failure.""" event = threading.Event() event.set() # prefetch already completed monkeypatch.setattr(loader, "_prefetch_event", event) @@ -357,23 +251,19 @@ def test_get_available_packs_before_load_returns_empty() -> None: def test_get_available_packs_after_load() -> None: - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object(loader, "fetch_policy_response", return_value=response): - get_policy_index() + set_policy_provider(StubPolicyProvider(response=_ok_response())) + get_policy_index() assert "test-pack" in get_available_packs() def test_clear_policy_cache_forces_refetch() -> None: - response = PolicyResponse(mode="audit", policy=SIMPLE_POLICY_YAML) - with patch.object( - loader, "fetch_policy_response", return_value=response - ) as mock_fetch: - get_policy_index() - clear_policy_cache() - get_policy_index() - assert mock_fetch.call_count == 2 - - -def test_reset_policy_index_alias_for_clear() -> None: - """``reset_policy_index`` is the legacy alias for ``clear_policy_cache``.""" - assert loader.reset_policy_index is loader.clear_policy_cache + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + get_policy_index() + clear_policy_cache() + get_policy_index() + + assert len(provider.calls) == 2 + + diff --git a/tests/test_policy_agent_type.py b/tests/test_policy_agent_type.py deleted file mode 100644 index f9b9fdb2..00000000 --- a/tests/test_policy_agent_type.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Tests for the conversational-vs-autonomous agent-type selector. - -The governance wrapper records whether the hosted agent is conversational; -the policy fetch then appends an ``agentType`` query param so the server's -clause-resolver reads the matching container key (``*-in-flight-agents`` vs -``*-in-flight-conversational-agents``). -""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -from uipath.runtime.governance.native import backend_client -from uipath.runtime.governance.native.backend_client import ( - agent_type_param, - set_agent_conversational, -) -from uipath.runtime.governance.native.policy_api_client import build_policy_url - -# The wrapper lands in a later slice of the governance stack; skip (don't -# error at collection) when it isn't present yet. -GovernanceRuntime = pytest.importorskip( - "uipath.runtime.governance.wrapper", - reason="GovernanceRuntime wrapper not yet present in this slice", -).GovernanceRuntime - - -def _extract(delegate, context=None) -> bool: - """Call _extract_is_conversational without running __init__.""" - runtime = object.__new__(GovernanceRuntime) - return runtime._extract_is_conversational(delegate, context) - - -@pytest.fixture(autouse=True) -def _reset_selector(): - """Clear the process-level selector around each test.""" - set_agent_conversational(None) - yield - set_agent_conversational(None) - - -def test_agent_type_param_unset_is_none(): - assert agent_type_param() is None - - -def test_agent_type_param_conversational(): - set_agent_conversational(True) - assert agent_type_param() == "conversational" - - -def test_agent_type_param_autonomous(): - set_agent_conversational(False) - assert agent_type_param() == "autonomous" - - -def test_build_policy_url_omits_param_when_unset(monkeypatch): - monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") - url = build_policy_url("my-org") - assert url == "https://alpha.uipath.com/my-org/agenticgovernance_/api/v1/runtime/policy" - assert "agentType" not in url - - -def test_build_policy_url_appends_conversational(monkeypatch): - monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") - set_agent_conversational(True) - assert build_policy_url("my-org").endswith( - "/my-org/agenticgovernance_/api/v1/runtime/policy?agentType=conversational" - ) - - -def test_build_policy_url_appends_autonomous(monkeypatch): - monkeypatch.setattr(backend_client, "get_backend_base_url", lambda: "https://alpha.uipath.com") - set_agent_conversational(False) - assert build_policy_url("my-org").endswith("?agentType=autonomous") - - -# ── _extract_is_conversational ────────────────────────────────────────────── - - -def test_extract_conversational_from_agent_definition(): - delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) - assert _extract(delegate) is True - - -def test_extract_autonomous_from_agent_definition(): - delegate = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) - assert _extract(delegate) is False - - -def test_extract_unwraps_delegate_chain(): - inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) - outer = SimpleNamespace(_delegate=inner) # no _agent_definition on the outer - assert _extract(outer) is True - - -def test_extract_falls_back_to_context_conversation_id(): - delegate = SimpleNamespace() # nothing reachable - context = SimpleNamespace(conversation_id="conv-1") - assert _extract(delegate, context) is True - - -def test_extract_defaults_to_autonomous_when_unknown(): - assert _extract(SimpleNamespace(), SimpleNamespace()) is False \ No newline at end of file diff --git a/tests/test_policy_api_client.py b/tests/test_policy_api_client.py deleted file mode 100644 index 9ebcdb5f..00000000 --- a/tests/test_policy_api_client.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Tests for ``fetch_policy_response`` and the body parser. - -Covers the skip paths (missing org / tenant / token), HTTP failures -(HTTPError, URLError, TimeoutError, OSError), and body parsing -(empty body, non-UTF8, malformed JSON, wrong top-level shape, bad -``policies`` type). -""" - -from __future__ import annotations - -import io -import json -import urllib.error -from unittest.mock import MagicMock, patch - -import pytest - -from uipath.runtime.governance.native import policy_api_client -from uipath.runtime.governance.native.policy_api_client import ( - PolicyResponse, - _parse_policy_body, - build_policy_url, - fetch_policy_response, -) - - -@pytest.fixture -def _fresh_env(monkeypatch: pytest.MonkeyPatch): - """Clear the env vars that the fetch path depends on.""" - for var in ( - "UIPATH_ORGANIZATION_ID", - "UIPATH_TENANT_ID", - "UIPATH_ACCESS_TOKEN", - "UIPATH_URL", - ): - monkeypatch.delenv(var, raising=False) - yield - - -@pytest.fixture -def _populated_env(monkeypatch: pytest.MonkeyPatch): - """All three vars present — the fetch path can reach urlopen.""" - monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") - monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") - monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok-abc") - monkeypatch.setenv("UIPATH_URL", "https://alpha.uipath.com") - yield - - -def _ok_response(body: bytes) -> MagicMock: - """urlopen()-compatible context manager that returns ``body``.""" - resp = MagicMock() - resp.read.return_value = body - resp.__enter__.return_value = resp - resp.__exit__.return_value = False - return resp - - -# --------------------------------------------------------------------------- -# Skip paths — fail-open without contacting the backend -# --------------------------------------------------------------------------- - - -def test_skip_when_org_id_missing(_fresh_env, monkeypatch) -> None: - monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") - monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") - with patch.object( - policy_api_client.urllib.request, "urlopen" - ) as mock_urlopen: - assert fetch_policy_response() is None - assert not mock_urlopen.called - - -def test_skip_when_tenant_id_missing(_fresh_env, monkeypatch) -> None: - monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") - monkeypatch.setenv("UIPATH_ACCESS_TOKEN", "tok") - with patch.object( - policy_api_client.urllib.request, "urlopen" - ) as mock_urlopen: - assert fetch_policy_response() is None - assert not mock_urlopen.called - - -def test_skip_when_token_missing(_fresh_env, monkeypatch) -> None: - monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") - monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") - with patch.object( - policy_api_client.urllib.request, "urlopen" - ) as mock_urlopen: - assert fetch_policy_response() is None - assert not mock_urlopen.called - - -# --------------------------------------------------------------------------- -# HTTP failure paths — fail-open with a warning -# --------------------------------------------------------------------------- - - -def test_returns_none_on_http_error(_populated_env) -> None: - err = urllib.error.HTTPError( - url="x", code=500, msg="Server Error", hdrs=None, fp=io.BytesIO(b"") - ) - with patch.object( - policy_api_client.urllib.request, "urlopen", side_effect=err - ): - assert fetch_policy_response() is None - - -def test_returns_none_on_url_error(_populated_env) -> None: - err = urllib.error.URLError("connection refused") - with patch.object( - policy_api_client.urllib.request, "urlopen", side_effect=err - ): - assert fetch_policy_response() is None - - -def test_returns_none_on_timeout(_populated_env) -> None: - with patch.object( - policy_api_client.urllib.request, "urlopen", side_effect=TimeoutError() - ): - assert fetch_policy_response() is None - - -def test_returns_none_on_os_error(_populated_env) -> None: - with patch.object( - policy_api_client.urllib.request, - "urlopen", - side_effect=OSError("disk full"), - ): - assert fetch_policy_response() is None - - -def test_outer_swallows_unexpected_exception(_populated_env) -> None: - """Even non-HTTP exceptions from urlopen don't escape the fetch helper.""" - with patch.object( - policy_api_client.urllib.request, - "urlopen", - side_effect=RuntimeError("library bug"), - ): - assert fetch_policy_response() is None - - -# --------------------------------------------------------------------------- -# Headers / URL composition -# --------------------------------------------------------------------------- - - -def test_sends_no_content_type_on_get(_populated_env) -> None: - """The GET must NOT carry Content-Type — some servers 415 on it.""" - with patch.object( - policy_api_client.urllib.request, - "urlopen", - return_value=_ok_response(b'{"mode": "audit", "policies": ""}'), - ) as mock_urlopen: - fetch_policy_response() - request_arg = mock_urlopen.call_args.args[0] - assert request_arg.get_header("Content-type") is None - assert request_arg.get_header("Accept") == "application/json" - assert request_arg.get_header("Authorization") == "Bearer tok-abc" - assert request_arg.get_header("X-uipath-internal-tenantid") == "tenant-1" - assert request_arg.get_method() == "GET" - - -def test_url_includes_agent_type_when_set(_populated_env, monkeypatch) -> None: - """``build_policy_url`` appends ``?agentType=...`` from the selector.""" - from uipath.runtime.governance.native import backend_client - - monkeypatch.setattr(backend_client, "_agent_is_conversational", True) - url = build_policy_url("org-x") - assert "agentType=conversational" in url - - -def test_url_omits_agent_type_when_unset(_populated_env, monkeypatch) -> None: - from uipath.runtime.governance.native import backend_client - - monkeypatch.setattr(backend_client, "_agent_is_conversational", None) - url = build_policy_url("org-x") - assert "agentType=" not in url - - -# --------------------------------------------------------------------------- -# Body parser — _parse_policy_body -# --------------------------------------------------------------------------- - - -def test_parse_empty_body_returns_none() -> None: - assert _parse_policy_body(b"") is None - - -def test_parse_non_utf8_body_returns_none() -> None: - # 0xff isn't valid UTF-8. - assert _parse_policy_body(b"\xff\xfe") is None - - -def test_parse_malformed_json_returns_none() -> None: - # A common shape: server returns HTML when it should return JSON. - assert _parse_policy_body(b"oops") is None - - -def test_parse_non_object_top_level_returns_none() -> None: - """Server returning a bare JSON array is rejected — expected an object.""" - assert _parse_policy_body(b'["audit", "policies"]') is None - - -def test_parse_non_string_policies_field_returns_none() -> None: - """``policies`` must be a string YAML body, not a number / dict / list.""" - assert _parse_policy_body(b'{"mode": "audit", "policies": 42}') is None - - -def test_parse_ok_yields_policy_response() -> None: - resp = _parse_policy_body( - b'{"mode": "enforce", "policies": "standard: p\\nrules: []"}' - ) - assert resp is not None - assert resp.mode == "enforce" - assert "standard: p" in resp.policy - - -def test_parse_ok_with_missing_mode_yields_none_mode() -> None: - """A response without ``mode`` is still valid — server may not override.""" - resp = _parse_policy_body(b'{"policies": ""}') - assert resp is not None - assert resp.mode is None - assert resp.policy == "" - - -def test_parse_empty_string_mode_treated_as_unset() -> None: - """Empty-string ``mode`` is normalized to ``None`` (don't override default).""" - resp = _parse_policy_body(b'{"mode": "", "policies": ""}') - assert resp is not None - assert resp.mode is None - - -def test_parse_non_string_mode_treated_as_unset() -> None: - """If the server sends mode as a number / null, treat as unset.""" - resp = _parse_policy_body(b'{"mode": 5, "policies": ""}') - assert resp is not None - assert resp.mode is None - - -# --------------------------------------------------------------------------- -# Full happy-path round-trip -# --------------------------------------------------------------------------- - - -def test_full_fetch_round_trip(_populated_env) -> None: - body = json.dumps( - {"mode": "audit", "policies": "standard: p\nrules: []"} - ).encode("utf-8") - with patch.object( - policy_api_client.urllib.request, - "urlopen", - return_value=_ok_response(body), - ): - resp = fetch_policy_response() - assert isinstance(resp, PolicyResponse) - assert resp.mode == "audit" - assert "standard: p" in resp.policy diff --git a/uv.lock b/uv.lock index a6564c90..e3a9a786 100644 --- a/uv.lock +++ b/uv.lock @@ -1191,7 +1191,7 @@ dev = [ requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "uipath-core", specifier = ">=0.5.19,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.21,<0.6.0" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] From 62e86125fdb2573910bcd940dd87a87739d755e0 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Wed, 24 Jun 2026 14:25:26 +0530 Subject: [PATCH 10/18] refactor(governance): instance-scope PolicyLoader; explicit is_conversational MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses radu's review on PR #121 — collapses three architectural boundary concerns into the loader/runtime layers. 1. PolicyLoader is now instance-scoped, not module-globals. Each GovernanceRuntime constructs its own loader carrying its own provider, cache, prefetch state, and conversational selector. uipath eval can spin up multiple runtimes in parallel without them clobbering each other's policy state. 2. is_governance_enabled() reads removed from the runtime layer. The decision "should governance attach?" belongs to the wiring layer (uipath CLI) — it chooses whether to construct GovernanceRuntime at all. Inside the loader the contract is purely "provider present → load policies; provider missing → empty PolicyIndex". The feature flag itself stays in uipath-core. 3. _extract_is_conversational and its delegate-walking deleted. GovernanceRuntime now takes is_conversational explicitly as a keyword arg; the wiring layer (which knows the agent type) passes it in. Runtime no longer reaches into _delegate._agent_definition private attrs. Plus two correctness fixes called out in the readiness re-check: - clear_cache() vs in-flight prefetch worker race: worker now checks _prefetch_event is event before publishing self._policy_index so an orphaned worker can't clobber the just-cleared cache. - _load_from_provider takes the narrowed provider as a parameter instead of asserting self._provider is not None — the bandit B101 "assert stripped under -O" finding is now gone. Tests rewritten around PolicyLoader instances; cross-instance isolation pinned; orphan-worker race regression test added; conftest autouse reset fixture removed (no module state to clean). 187 pass, ruff/mypy/bandit clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runtime/governance/native/loader.py | 540 +++++++++--------- src/uipath/runtime/governance/runtime.py | 130 ++--- tests/conftest.py | 29 +- tests/test_governance_runtime.py | 381 +++--------- tests/test_loader.py | 258 +++++---- 5 files changed, 550 insertions(+), 788 deletions(-) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index 6603a505..a59d96f0 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -1,12 +1,17 @@ """Policy pack loader. -Resolves the active PolicyIndex at startup by calling a registered -:class:`GovernancePolicyProvider`. The runtime never contacts the -governance backend directly; the provider owns the wire / transport -(auth, retries, telemetry). When no provider is registered, or the -provider raises / returns an empty body / yields zero rules, the -loader returns an empty PolicyIndex and the agent runs without any -rules. +Per-runtime policy loading: a :class:`PolicyLoader` instance owns one +provider plus the cached PolicyIndex and prefetch state. The runtime +never contacts the governance backend directly; the provider owns the +wire / transport (auth, retries, telemetry). When no provider is +supplied, or the provider raises / returns an empty body / yields zero +rules, the loader returns an empty PolicyIndex and the agent runs +without any rules. + +The loader holds **no module-level state**. ``uipath eval`` can spin up +multiple ``GovernanceRuntime`` instances in the same process and each +gets its own loader with its own provider, cache, and selector — no +cross-instance interference. """ from __future__ import annotations @@ -18,7 +23,6 @@ import yaml from uipath.core.governance import GovernancePolicyProvider, PolicyContext -from uipath.core.governance.config import is_governance_enabled from uipath.runtime.governance.config import set_enforcement_mode from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml @@ -26,296 +30,286 @@ logger = logging.getLogger(__name__) -# Module-level cache -_policy_index: PolicyIndex | None = None - -# Background-prefetch coordination. ``_prefetch_event`` is set once the -# background load_policy_index() call finishes (success OR failure); -# callers of ``get_policy_index()`` wait on it. ``_prefetch_lock`` -# protects the start-once semantics so concurrent ``prefetch`` calls -# don't kick off duplicate threads. -_prefetch_event: threading.Event | None = None -_prefetch_lock = threading.Lock() - -# Upper bound on how long ``get_policy_index()`` waits for an in-flight -# prefetch before falling back to an empty PolicyIndex. The provider -# owns its own transport timeouts; this is the runtime's ceiling on -# blocking the first hook fire. -_PROVIDER_WAIT_SECONDS = 10.0 - -# Registered :class:`GovernancePolicyProvider`. Set by -# :class:`GovernanceRuntime` at init. ``None`` means no provider is -# registered — :func:`load_policy_index` returns an empty PolicyIndex -# in that case. -_policy_provider: GovernancePolicyProvider | None = None - -# Whether the hosted agent is conversational. Travels in the -# :class:`PolicyContext` so the provider can select the matching policy -# view. A process-level holder (not a ContextVar) because the prefetch -# runs on a separate thread that wouldn't inherit one, and a -# coded-agent process hosts a single agent so the value is stable per -# process. ``None`` leaves the selector unset — the provider applies -# its default. -_agent_is_conversational: bool | None = None - - -def set_policy_provider(provider: GovernancePolicyProvider | None) -> None: - """Register the policy provider the loader will use to fetch policies. - - Called once by :class:`GovernanceRuntime` during init before - :func:`prefetch_policy_index`. ``None`` clears the registration — - used by tests and by callers that opt out of governance. - """ - global _policy_provider - _policy_provider = provider - - -def set_agent_conversational(value: bool | None) -> None: - """Record whether the hosted agent is conversational. - Threaded into :class:`PolicyContext` on every provider call so the - provider can resolve the conversational-vs-autonomous policy view. - ``None`` clears the selector — the provider then applies its - default. - """ - global _agent_is_conversational - _agent_is_conversational = value +class PolicyLoader: + """Instance-scoped policy loader bound to one provider. + Owns the policy-index cache, prefetch coordination, and the + conversational selector for a single :class:`GovernanceRuntime` + instance. Multiple loaders coexist in the same process without + clobbering each other. -def prefetch_policy_index() -> None: - """Kick off a background load of the policy index. + Typical lifecycle:: - Non-blocking. Designed to be called as early as possible (at - ``GovernanceRuntime.__init__``) so the policy fetch overlaps with - the rest of agent setup. The result lands in the same module cache - that ``get_policy_index()`` reads from; ``get_policy_index()`` waits - on this prefetch when it's in flight. + loader = PolicyLoader(provider, is_conversational=False) + loader.prefetch() # non-blocking, optional + index = loader.get_policy_index() # cached after first call - Idempotent: subsequent calls while the first is running are no-ops, - and calls after completion are no-ops. Skipped entirely when the - governance feature flag is OFF so the provider is never invoked. + When ``provider`` is ``None``, every load returns an empty + PolicyIndex without invoking anything. """ - global _prefetch_event - - if not is_governance_enabled(): - return - - with _prefetch_lock: - if _policy_index is not None: - return # already loaded - if _prefetch_event is not None: - return # already in flight - event = threading.Event() - _prefetch_event = event - def _worker() -> None: - global _policy_index - try: - loaded = load_policy_index() - except Exception as exc: # noqa: BLE001 - logged; first hook will retry sync - logger.warning("Policy prefetch failed: %s", exc) - else: - with _prefetch_lock: - _policy_index = loaded - finally: - event.set() - - threading.Thread( - target=_worker, - name="governance-policy-prefetch", - daemon=True, - ).start() - - -def get_policy_index() -> PolicyIndex: - """Get the cached policy index, loading if necessary. - - Resolution order on first call: - 1. If the governance feature flag is OFF, return an empty - PolicyIndex (cached). Provider is not invoked. - 2. If a prefetch (see :func:`prefetch_policy_index`) is in flight, - wait for it to complete (bounded by ``_PROVIDER_WAIT_SECONDS``). - 3. Synchronously call :func:`load_policy_index` (which invokes the - registered :class:`GovernancePolicyProvider`). - 4. Empty PolicyIndex when no provider is registered or the - provider fails / returns nothing. - - Result is cached for the process lifetime; per-hook evaluation never - touches the network. Call :func:`clear_policy_cache` to force a - refetch (mainly for tests). - """ - global _policy_index - - if _policy_index is not None: - return _policy_index - - if not is_governance_enabled(): - logger.info( - "Governance feature flag is OFF; returning empty PolicyIndex. " - "No rules will fire. Set EnablePythonGovernanceChecker=True to enable." - ) - _policy_index = PolicyIndex() - return _policy_index - - event = _prefetch_event - if event is not None: - completed = event.wait(timeout=_PROVIDER_WAIT_SECONDS) - if completed and _policy_index is not None: - return _policy_index - if not completed: - # Timeout: deliberately cache an empty index so we don't - # re-wait the full timeout on every subsequent hook. + # Upper bound on how long :meth:`get_policy_index` waits for an + # in-flight prefetch before falling back to an empty PolicyIndex. + # The provider owns its own transport timeouts; this is the runtime's + # ceiling on blocking the first hook fire. + _PROVIDER_WAIT_SECONDS = 10.0 + + def __init__( + self, + provider: GovernancePolicyProvider | None, + *, + is_conversational: bool | None = None, + ) -> None: + """Construct a per-runtime policy loader. + + Args: + provider: Policy source. ``None`` means no policies will be + loaded — the loader yields an empty PolicyIndex. + is_conversational: Whether the hosted agent is + conversational. Travels in the :class:`PolicyContext` + so the provider can select the matching policy view. + ``None`` leaves the selector unset — the provider + applies its default. + """ + self._provider = provider + self._is_conversational = is_conversational + self._policy_index: PolicyIndex | None = None + # ``_prefetch_event`` is set once the background load finishes + # (success OR failure); callers of ``get_policy_index`` wait on + # it. ``_prefetch_lock`` guards the start-once semantics so + # concurrent ``prefetch`` calls don't kick off duplicate threads. + self._prefetch_event: threading.Event | None = None + self._prefetch_lock = threading.Lock() + + def prefetch(self) -> None: + """Kick off a background load of the policy index. + + Non-blocking. Designed to be called as early as possible (at + :class:`GovernanceRuntime` init) so the policy fetch overlaps + with the rest of agent setup. The result lands in this loader's + cache; :meth:`get_policy_index` waits on the prefetch when it's + in flight. + + Idempotent: subsequent calls while the first is running are + no-ops, and calls after completion are no-ops. No-op when no + provider is supplied — there's nothing to fetch. + """ + if self._provider is None: + return + + with self._prefetch_lock: + if self._policy_index is not None: + return # already loaded + if self._prefetch_event is not None: + return # already in flight + event = threading.Event() + self._prefetch_event = event + + def _worker() -> None: + try: + loaded = self.load_policy_index() + except Exception as exc: # noqa: BLE001 - logged; first hook will retry sync + logger.warning("Policy prefetch failed: %s", exc) + else: + with self._prefetch_lock: + # Only publish if we're still the live prefetch. + # ``clear_cache`` nulls ``_prefetch_event`` to retire + # an in-flight worker; in that case the loaded value + # belongs to a stale generation and must be dropped + # rather than clobbering the just-cleared state. + if self._prefetch_event is event: + self._policy_index = loaded + finally: + event.set() + + threading.Thread( + target=_worker, + name="governance-policy-prefetch", + daemon=True, + ).start() + + def get_policy_index(self) -> PolicyIndex: + """Get the cached policy index, loading if necessary. + + Resolution order on first call: + 1. If a prefetch (see :meth:`prefetch`) is in flight, wait + for it to complete (bounded by ``_PROVIDER_WAIT_SECONDS``). + 2. Synchronously call :meth:`load_policy_index` (which invokes + the provider). + 3. Empty PolicyIndex when no provider is supplied or the + provider fails / returns nothing. + + Result is cached for the loader's lifetime; per-hook evaluation + never touches the network. Call :meth:`clear_cache` to force a + refetch (mainly for tests). + """ + if self._policy_index is not None: + return self._policy_index + + event = self._prefetch_event + if event is not None: + completed = event.wait(timeout=self._PROVIDER_WAIT_SECONDS) + if completed and self._policy_index is not None: + return self._policy_index + if not completed: + # Timeout: cache an empty index so we don't re-wait the + # full timeout on every subsequent hook. + logger.warning( + "Policy prefetch did not complete in %.1fs; " + "agent will run without any policies", + self._PROVIDER_WAIT_SECONDS, + ) + self._policy_index = PolicyIndex() + return self._policy_index + + # Completed but produced no PolicyIndex — the worker hit an + # unexpected error. Do NOT cache the empty result: caching + # would permanently disable governance for the loader's + # lifetime even though a later prefetch / clear_cache could + # still recover. Return an empty index for this call only. logger.warning( - "Policy prefetch did not complete in %.1fs; " - "agent will run without any policies", - _PROVIDER_WAIT_SECONDS, + "Policy prefetch completed but produced no PolicyIndex " + "(see prior WARN for the root cause); agent will run " + "without any policies for this call" ) - _policy_index = PolicyIndex() - return _policy_index - - # Completed but produced no PolicyIndex — the worker hit an - # unexpected error (provider failure, parse failure). Do NOT - # cache the empty result: caching would permanently disable - # governance for the process even though a later prefetch / - # clear_policy_cache could still recover. Return an empty index - # for this call only and leave the cache unset. - logger.warning( - "Policy prefetch completed but produced no PolicyIndex " - "(see prior WARN for the root cause); agent will run " - "without any policies for this call" + return PolicyIndex() + + # No prefetch was started (direct callers / tests). Sync load. + self._policy_index = self.load_policy_index() + return self._policy_index + + def load_policy_index(self) -> PolicyIndex: + """Synchronously load and parse the policy index. + + Returns: + PolicyIndex parsed from the provider response. Empty + PolicyIndex when no provider is supplied, the provider + raises, the YAML is malformed, or the response yields + zero rules. + """ + start = time.perf_counter() + + index = ( + self._load_from_provider(self._provider) + if self._provider is not None + else None ) - return PolicyIndex() - - # No prefetch was started (direct callers / tests). Sync load. - _policy_index = load_policy_index() - return _policy_index - - -def load_policy_index() -> PolicyIndex: - """Load the active PolicyIndex via the registered policy provider. - Returns: - PolicyIndex parsed from the provider response. Empty PolicyIndex - when no provider is registered, the provider raises, the YAML - is malformed, or the response yields zero rules. - """ - start = time.perf_counter() - - provider = _policy_provider - index = _load_from_provider(provider) if provider is not None else None + if index is not None: + self._log_index_summary(index) + logger.info( + "Policy index ready: source=provider, total_ms=%.1f", + (time.perf_counter() - start) * 1000, + ) + return index - if index is not None: - _log_index_summary(index) + reason = self._empty_index_reason() logger.info( - "Policy index ready: source=provider, total_ms=%.1f", + "Policy index ready: source=empty (%s), total_ms=%.1f", + reason, (time.perf_counter() - start) * 1000, ) - return index + return PolicyIndex() - reason = _empty_index_reason() - logger.info( - "Policy index ready: source=empty (%s), total_ms=%.1f", - reason, - (time.perf_counter() - start) * 1000, - ) - return PolicyIndex() + def _empty_index_reason(self) -> str: + """Diagnose why policy loading produced nothing.""" + if self._provider is None: + return "no policy provider supplied" + return "provider returned no policies (error / empty body / zero rules)" + def _load_from_provider( + self, provider: GovernancePolicyProvider + ) -> PolicyIndex | None: + """Fetch and parse the policy index via the supplied provider. -def _empty_index_reason() -> str: - """Diagnose why policy loading produced nothing.""" - if _policy_provider is None: - return "no policy provider registered" - return "provider returned no policies (error / empty body / zero rules)" + Applies the provider-supplied enforcement mode as a side effect. + Returns ``None`` when the provider raises, when the YAML is + malformed, or when the resulting index has no rules — caller + returns an empty PolicyIndex in those cases. + Takes ``provider`` as a parameter (rather than reading + ``self._provider``) so the type system can prove the call site + is non-None — :meth:`load_policy_index` guards on ``None`` and + passes the narrowed value through. + """ + start = time.perf_counter() -def _load_from_provider(provider: GovernancePolicyProvider) -> PolicyIndex | None: - """Fetch and parse the policy index via a :class:`GovernancePolicyProvider`. + ctx = PolicyContext(is_conversational=self._is_conversational) - Applies the provider-supplied enforcement mode as a side effect. - Returns ``None`` when the provider raises, when the YAML is - malformed, or when the resulting index has no rules — caller returns - an empty PolicyIndex in those cases. - """ - start = time.perf_counter() + try: + response = provider.get_policy(ctx) + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning("Policy provider get_policy failed: %s", exc) + return None - ctx = PolicyContext(is_conversational=_agent_is_conversational) + if response.mode is not None: + set_enforcement_mode(response.mode) + logger.info("Enforcement mode set from provider: %s", response.mode.value) - try: - response = provider.get_policy(ctx) - except Exception as exc: # noqa: BLE001 - fail-open by contract - logger.warning("Policy provider get_policy failed: %s", exc) - return None + if not response.policies: + logger.warning( + "Policy provider returned empty policies field; " + "agent will run without any policies" + ) + return None - if response.mode is not None: - set_enforcement_mode(response.mode) - logger.info("Enforcement mode set from provider: %s", response.mode.value) + try: + index = build_policy_index_from_yaml(response.policies) + except yaml.YAMLError as exc: + logger.warning("Policy YAML from provider was malformed: %s", exc) + return None + except Exception as exc: # noqa: BLE001 - never let load break agent startup + logger.warning("Failed to build PolicyIndex from provider YAML: %s", exc) + return None + + if index.total_rules == 0: + logger.warning( + "Policy YAML from provider yielded zero rules; " + "agent will run without any policies" + ) + return None - if not response.policies: - logger.warning( - "Policy provider returned empty policies field; " - "agent will run without any policies" - ) - return None - - try: - index = build_policy_index_from_yaml(response.policies) - except yaml.YAMLError as exc: - logger.warning("Policy YAML from provider was malformed: %s", exc) - return None - except Exception as exc: # noqa: BLE001 - never let load break agent startup - logger.warning("Failed to build PolicyIndex from provider YAML: %s", exc) - return None - - if index.total_rules == 0: - logger.warning( - "Policy YAML from provider yielded zero rules; " - "agent will run without any policies" + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info( + "Loaded policy index from provider: packs=%s, rules=%d, elapsed_ms=%.1f", + index.pack_names, + index.total_rules, + elapsed_ms, ) - return None - - elapsed_ms = (time.perf_counter() - start) * 1000 - logger.info( - "Loaded policy index from provider: packs=%s, rules=%d, elapsed_ms=%.1f", - index.pack_names, - index.total_rules, - elapsed_ms, - ) - return index - - -def _log_index_summary(index: PolicyIndex) -> None: - """Log summary of loaded policy index.""" - hook_counts: Counter[str] = Counter() - for rule in index.all_rules: - hook_counts[rule.hook.value] += 1 - - logger.debug( - "Policy packs: %s, total rules: %d, by hook: %s", - index.pack_names, - index.total_rules, - dict(hook_counts), - ) - - -def get_available_packs() -> list[str]: - """Get list of pack names from the currently loaded policy index. - - Returns whatever the provider supplied on the most recent load. - Empty list if no index has been loaded yet. - """ - if _policy_index is None: - return [] - return _policy_index.pack_names - + return index -def clear_policy_cache() -> None: - """Clear the cached policy index and any in-flight prefetch state. + def _log_index_summary(self, index: PolicyIndex) -> None: + """Log summary of loaded policy index.""" + hook_counts: Counter[str] = Counter() + for rule in index.all_rules: + hook_counts[rule.hook.value] += 1 + + logger.debug( + "Policy packs: %s, total rules: %d, by hook: %s", + index.pack_names, + index.total_rules, + dict(hook_counts), + ) - Next call to ``get_policy_index()`` will reload from the registered - :class:`GovernancePolicyProvider`. - """ - global _policy_index, _prefetch_event - with _prefetch_lock: - _policy_index = None - _prefetch_event = None - logger.debug("Policy index cache cleared") + @property + def available_packs(self) -> list[str]: + """Pack names from the currently loaded policy index. + + Returns whatever the provider supplied on the most recent load. + Empty list if no index has been loaded yet. + """ + if self._policy_index is None: + return [] + return self._policy_index.pack_names + + def clear_cache(self) -> None: + """Clear the cached policy index and any in-flight prefetch state. + + Next call to :meth:`get_policy_index` will reload from the + provider. + """ + with self._prefetch_lock: + self._policy_index = None + self._prefetch_event = None + logger.debug("Policy index cache cleared") diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py index 12001c29..c8f9dd94 100644 --- a/src/uipath/runtime/governance/runtime.py +++ b/src/uipath/runtime/governance/runtime.py @@ -7,16 +7,22 @@ when ``policy_provider`` is ``None`` the agent runs without any governance policies. +The wiring layer (uipath CLI) decides whether to construct +``GovernanceRuntime`` at all (feature flag, project config, etc.) and +passes ``is_conversational`` explicitly when it knows the agent type. +The runtime layer does not introspect the delegate's private attributes +to discover that. + **Staging caveat — policy loading only, no enforcement yet.** This -module is the policy-loading scaffold: ``__init__`` registers the -provider, extracts the conversational/autonomous selector, and kicks -off a background prefetch into the loader cache. ``execute`` / -``stream`` / ``get_schema`` / ``dispose`` are pure passthroughs — no -per-hook policy evaluation runs. The evaluator + adapter wiring that -consumes :func:`get_policy_index` lands in a follow-up slice. Customers -constructing :class:`GovernanceRuntime` today get policy loading without -policy enforcement; this is intentional and will change when the -evaluator slice merges. +module is the policy-loading scaffold: ``__init__`` constructs an +instance-scoped :class:`PolicyLoader` and kicks off a background +prefetch. ``execute`` / ``stream`` / ``get_schema`` / ``dispose`` are +pure passthroughs — no per-hook policy evaluation runs. The evaluator +and framework adapter wiring that consumes the loader's policy index +lands in a follow-up slice. Customers constructing +:class:`GovernanceRuntime` today get policy loading without policy +enforcement; this is intentional and will change when the evaluator +slice merges. """ from __future__ import annotations @@ -25,7 +31,6 @@ from typing import Any, AsyncGenerator from uipath.core.governance import GovernancePolicyProvider -from uipath.core.governance.config import is_governance_enabled from uipath.runtime.base import ( UiPathExecuteOptions, @@ -33,72 +38,36 @@ UiPathStreamOptions, ) from uipath.runtime.events import UiPathRuntimeEvent -from uipath.runtime.governance.native.loader import ( - prefetch_policy_index, - set_agent_conversational, - set_policy_provider, -) +from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.result import UiPathRuntimeResult from uipath.runtime.schema import UiPathRuntimeSchema logger = logging.getLogger(__name__) -# Bound on how deeply we walk ``_delegate`` / ``delegate`` chains when -# looking for an :class:`AgentDefinition`. Wrappers like -# :class:`UiPathExecutionRuntime` and :class:`UiPathResumableRuntime` -# add at most a handful of layers; 10 is well above any realistic -# stack and keeps a pathological self-referential wrapper from looping. -_MAX_DELEGATE_UNWRAP_DEPTH = 10 - - -def _extract_is_conversational(delegate: object) -> bool | None: - """Read ``is_conversational`` off the delegate's agent definition. - - Walks ``delegate._agent_definition.is_conversational`` (the - LicensedRuntime pattern published by the agents SDK), unwrapping - the ``_delegate`` / ``delegate`` chain up to - :data:`_MAX_DELEGATE_UNWRAP_DEPTH` so wrapper layers don't hide the - licensed runtime. - - Returns ``None`` when no agent definition is reachable — the - provider then applies its default rather than the runtime guessing - a value. - """ - node: object | None = delegate - for _ in range(_MAX_DELEGATE_UNWRAP_DEPTH): - if node is None: - break - agent_def = getattr(node, "_agent_definition", None) - if agent_def is not None: - value = getattr(agent_def, "is_conversational", None) - if value is not None: - return bool(value) - node = getattr(node, "_delegate", None) or getattr(node, "delegate", None) - return None - class GovernanceRuntime: """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. - Registers the supplied :class:`GovernancePolicyProvider` with the - policy loader and kicks off a non-blocking prefetch so the policy - pack overlaps with the rest of agent setup. When ``policy_provider`` - is ``None``, no provider is registered and the agent runs without - any governance policies (the loader yields an empty PolicyIndex). + Constructs an instance-scoped :class:`PolicyLoader` bound to the + supplied provider and kicks off a non-blocking prefetch so the + policy pack overlaps with the rest of agent setup. When + ``policy_provider`` is ``None``, the loader yields an empty + PolicyIndex and the agent runs without any governance policies for + the lifetime of this instance. **Policy loading only — no enforcement yet.** ``execute`` / ``stream`` / ``get_schema`` / ``dispose`` are passthroughs to the delegate; no per-hook policy evaluation runs in this slice. The evaluator and - framework adapter wiring that consumes :func:`get_policy_index` is - staged separately. Constructing this wrapper today gives you the - policy load (provider invoked, index cached) but no actual - enforcement of the loaded rules. + framework adapter wiring that consumes the loader's policy index is + staged separately. """ def __init__( self, delegate: UiPathRuntimeProtocol, policy_provider: GovernancePolicyProvider | None, + *, + is_conversational: bool | None = None, ): """Initialize the governance runtime. @@ -107,34 +76,29 @@ def __init__( policy_provider: Source of the policy pack. ``None`` means no policies will be loaded — the agent runs without governance for the lifetime of this instance. + is_conversational: Whether the hosted agent is + conversational. Forwarded into the provider's + :class:`PolicyContext` so it can pick the right policy + view (conversational vs autonomous). ``None`` (default) + leaves the selector unset — the provider applies its + default. The wiring layer (uipath CLI) is expected to + pass the concrete value when it knows the agent type. """ self._delegate = delegate - self._policy_provider = policy_provider - - if is_governance_enabled(): - # Record agent-type before the prefetch fires so the - # provider's first ``get_policy`` call sees the right - # selector on its ``PolicyContext``. Wrapped in try/except - # so a misbehaving delegate getattr can't break runtime - # init — fail-open: on failure the selector keeps whatever - # value an integration may have set externally. - # - # Only write when extraction returned a concrete bool. An - # extraction miss (``None``) leaves the selector untouched - # so an externally-set value (e.g. an integration that - # pre-seeded the selector from a different signal) is not - # silently clobbered by our init. - try: - extracted = _extract_is_conversational(delegate) - except Exception as exc: # noqa: BLE001 - fail-open - logger.warning( - "Failed to extract is_conversational from delegate: %s", exc - ) - else: - if extracted is not None: - set_agent_conversational(extracted) - set_policy_provider(policy_provider) - prefetch_policy_index() + self._loader = PolicyLoader( + policy_provider, + is_conversational=is_conversational, + ) + self._loader.prefetch() + + @property + def loader(self) -> PolicyLoader: + """The instance-scoped policy loader. + + Exposed so adapters / evaluators wired into this runtime can + call :meth:`PolicyLoader.get_policy_index` at hook time. + """ + return self._loader async def execute( self, diff --git a/tests/conftest.py b/tests/conftest.py index 78ea6fff..01e5dc95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,27 +19,8 @@ def temp_dir() -> Generator[str, None, None]: yield tmp_dir -@pytest.fixture(autouse=True) -def _reset_governance_process_state() -> Generator[None, None, None]: - """Clear process-level governance state around every test. - - The loader keeps the conversational selector and the registered - policy provider at module scope. Both are stable per process in - production but leak across tests when not reset, masking ordering - bugs and producing flakes. Import is guarded so this fixture is a - no-op when the governance package isn't built yet. - """ - try: - from uipath.runtime.governance.native.loader import ( - set_agent_conversational, - set_policy_provider, - ) - except ImportError: - yield - return - - set_agent_conversational(None) - set_policy_provider(None) - yield - set_agent_conversational(None) - set_policy_provider(None) +# The loader no longer keeps provider / selector at module scope — +# state is owned by each :class:`PolicyLoader` instance — so no +# autouse cross-test reset is needed. Tests that share enforcement +# mode call :func:`reset_enforcement_mode` from ``tests._helpers`` +# directly. diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py index b2b126b1..91fca095 100644 --- a/tests/test_governance_runtime.py +++ b/tests/test_governance_runtime.py @@ -1,10 +1,15 @@ -"""Tests for the GovernanceRuntime wrapper and the provider loader path.""" +"""Tests for the GovernanceRuntime wrapper and the provider loader path. + +The runtime no longer introspects the delegate's private attributes to +discover the conversational flag — the wiring layer passes it +explicitly. The runtime also no longer reads the governance feature +flag: the wiring layer decides whether to construct +:class:`GovernanceRuntime` at all. +""" from __future__ import annotations -from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock import pytest from uipath.core.governance import ( @@ -14,19 +19,9 @@ from tests._helpers import StubPolicyProvider, reset_enforcement_mode from uipath.runtime.governance.config import get_enforcement_mode -from uipath.runtime.governance.native import loader -from uipath.runtime.governance.native.loader import ( - _load_from_provider, - clear_policy_cache, - load_policy_index, - set_agent_conversational, - set_policy_provider, -) +from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.governance.native.models import PolicyIndex -from uipath.runtime.governance.runtime import ( - GovernanceRuntime, - _extract_is_conversational, -) +from uipath.runtime.governance.runtime import GovernanceRuntime SIMPLE_POLICY_YAML = """ standard: provider-pack @@ -41,32 +36,24 @@ @pytest.fixture(autouse=True) -def _enable_ff_and_reset(monkeypatch: pytest.MonkeyPatch): - """Reset module state and turn the governance FF on per test.""" - from uipath.core.feature_flags import FeatureFlags - - clear_policy_cache() +def _reset_mode() -> Any: + """Each test starts with a clean enforcement-mode slate.""" reset_enforcement_mode() - set_policy_provider(None) - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) yield - clear_policy_cache() reset_enforcement_mode() - set_policy_provider(None) - FeatureFlags.reset_flags() # --------------------------------------------------------------------------- -# _load_from_provider — direct unit tests +# PolicyLoader — provider plumbing (mode application, context, errors) # --------------------------------------------------------------------------- -def test_load_from_provider_builds_index_and_applies_mode() -> None: +def test_loader_builds_index_and_applies_mode() -> None: provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) ) - index = _load_from_provider(provider) + index = PolicyLoader(provider).load_policy_index() assert isinstance(index, PolicyIndex) assert index.total_rules == 1 @@ -74,52 +61,63 @@ def test_load_from_provider_builds_index_and_applies_mode() -> None: assert get_enforcement_mode() == EnforcementMode.ENFORCE -def test_load_from_provider_passes_is_conversational_in_context() -> None: - set_agent_conversational(True) +def test_loader_passes_is_conversational_in_context() -> None: provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) ) - _load_from_provider(provider) + PolicyLoader(provider, is_conversational=True).load_policy_index() assert len(provider.calls) == 1 assert provider.calls[0].is_conversational is True -def test_load_from_provider_returns_none_when_provider_raises() -> None: - provider = StubPolicyProvider(raises=RuntimeError("boom")) +def test_loader_omits_is_conversational_when_unset() -> None: + """``is_conversational=None`` (the default) leaves the selector unset.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + PolicyLoader(provider).load_policy_index() + + assert len(provider.calls) == 1 + assert provider.calls[0].is_conversational is None - assert _load_from_provider(provider) is None + +def test_loader_returns_empty_when_provider_raises() -> None: + provider = StubPolicyProvider(raises=RuntimeError("boom")) + index = PolicyLoader(provider).load_policy_index() + assert index.total_rules == 0 -def test_load_from_provider_returns_none_on_empty_policies() -> None: +def test_loader_returns_empty_on_empty_policies() -> None: provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") ) - - assert _load_from_provider(provider) is None + index = PolicyLoader(provider).load_policy_index() + assert index.total_rules == 0 -def test_load_from_provider_returns_none_on_zero_rules() -> None: +def test_loader_returns_empty_on_zero_rules() -> None: empty_pack_yaml = "standard: empty\nrules: []\n" provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=empty_pack_yaml) ) - - assert _load_from_provider(provider) is None + index = PolicyLoader(provider).load_policy_index() + assert index.total_rules == 0 -def test_load_from_provider_returns_none_on_malformed_yaml() -> None: +def test_loader_returns_empty_on_malformed_yaml() -> None: provider = StubPolicyProvider( response=PolicyResponse( mode=EnforcementMode.AUDIT, policies="key: : invalid: : yaml" ) ) - - assert _load_from_provider(provider) is None + index = PolicyLoader(provider).load_policy_index() + assert index.total_rules == 0 -def test_load_from_provider_does_not_change_mode_when_none() -> None: +def test_loader_does_not_change_mode_when_response_mode_is_none() -> None: from uipath.runtime.governance.config import set_enforcement_mode set_enforcement_mode(EnforcementMode.ENFORCE) @@ -127,47 +125,13 @@ def test_load_from_provider_does_not_change_mode_when_none() -> None: response=PolicyResponse(mode=None, policies=SIMPLE_POLICY_YAML) ) - _load_from_provider(provider) + PolicyLoader(provider).load_policy_index() assert get_enforcement_mode() == EnforcementMode.ENFORCE # --------------------------------------------------------------------------- -# load_policy_index dispatch — registered provider vs empty fallback -# --------------------------------------------------------------------------- - - -def test_load_policy_index_uses_registered_provider() -> None: - provider = StubPolicyProvider( - response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) - ) - set_policy_provider(provider) - - index = load_policy_index() - - assert index.total_rules == 1 - assert provider.calls, "provider.get_policy was not called" - - -def test_load_policy_index_returns_empty_when_no_provider() -> None: - """No provider registered → empty PolicyIndex (no fallback path).""" - index = load_policy_index() - assert index.total_rules == 0 - - -def test_load_policy_index_empty_when_provider_yields_nothing() -> None: - provider = StubPolicyProvider( - response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") - ) - set_policy_provider(provider) - - index = load_policy_index() - - assert index.total_rules == 0 - - -# --------------------------------------------------------------------------- -# GovernanceRuntime +# GovernanceRuntime — passthroughs + loader wiring # --------------------------------------------------------------------------- @@ -180,101 +144,72 @@ def __init__(self) -> None: self.disposed = False self.schema_called = False - async def execute(self, input=None, options=None): + async def execute(self, input: Any = None, options: Any = None) -> Any: self.execute_calls.append((input, options)) return "result" - async def stream(self, input=None, options=None): + async def stream(self, input: Any = None, options: Any = None) -> Any: self.stream_calls.append((input, options)) for event in ("a", "b"): yield event - async def get_schema(self): + async def get_schema(self) -> Any: self.schema_called = True return "schema" - async def dispose(self): + async def dispose(self) -> None: self.disposed = True -def test_governance_runtime_registers_provider_and_prefetches( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Init wires provider into loader state and kicks off prefetch.""" +def test_governance_runtime_exposes_loader_bound_to_provider() -> None: + """The wrapper builds an instance-scoped PolicyLoader carrying the provider.""" provider = StubPolicyProvider( response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) ) - # Spy on prefetch + set_policy_provider so we don't need a real - # background thread in the unit test. - prefetch_spy = MagicMock() - set_provider_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy - ) - - delegate = _StubDelegate() + runtime = GovernanceRuntime(_StubDelegate(), policy_provider=provider) - GovernanceRuntime(delegate, policy_provider=provider) + assert isinstance(runtime.loader, PolicyLoader) + assert runtime.loader._provider is provider - set_provider_spy.assert_called_once_with(provider) - prefetch_spy.assert_called_once_with() - -def test_governance_runtime_with_none_provider_still_prefetches( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Passing ``None`` registers None → loader yields an empty PolicyIndex.""" - prefetch_spy = MagicMock() - set_provider_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy +def test_governance_runtime_forwards_is_conversational_to_loader() -> None: + """The constructor's explicit ``is_conversational`` reaches PolicyContext.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + + runtime = GovernanceRuntime( + _StubDelegate(), policy_provider=provider, is_conversational=True ) + # Force the prefetch to land — load synchronously so we can read calls[0]. + runtime.loader.get_policy_index() - GovernanceRuntime(_StubDelegate(), policy_provider=None) + assert provider.calls, "provider.get_policy was never invoked" + assert provider.calls[0].is_conversational is True - set_provider_spy.assert_called_once_with(None) - prefetch_spy.assert_called_once_with() +def test_governance_runtime_loader_default_selector_is_none() -> None: + """Omitting ``is_conversational`` leaves the selector unset on PolicyContext.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) -def test_governance_runtime_skips_prefetch_when_ff_off( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """FF off → no provider registration, no prefetch.""" - from uipath.core.feature_flags import FeatureFlags + runtime = GovernanceRuntime(_StubDelegate(), policy_provider=provider) + runtime.loader.get_policy_index() - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + assert provider.calls[0].is_conversational is None - prefetch_spy = MagicMock() - set_provider_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy - ) - GovernanceRuntime(_StubDelegate(), policy_provider=StubPolicyProvider()) +def test_governance_runtime_with_none_provider_yields_empty_index() -> None: + """No provider → loader yields an empty PolicyIndex, no provider invocation.""" + runtime = GovernanceRuntime(_StubDelegate(), policy_provider=None) - assert not set_provider_spy.called - assert not prefetch_spy.called + index = runtime.loader.get_policy_index() + assert index.total_rules == 0 -async def test_governance_runtime_execute_delegates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() - ) +async def test_governance_runtime_execute_delegates() -> None: delegate = _StubDelegate() runtime = GovernanceRuntime(delegate, policy_provider=None) @@ -284,15 +219,7 @@ async def test_governance_runtime_execute_delegates( assert delegate.execute_calls == [({"x": 1}, None)] -async def test_governance_runtime_stream_delegates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() - ) +async def test_governance_runtime_stream_delegates() -> None: delegate = _StubDelegate() runtime = GovernanceRuntime(delegate, policy_provider=None) @@ -302,15 +229,7 @@ async def test_governance_runtime_stream_delegates( assert delegate.stream_calls == [({"x": 1}, None)] -async def test_governance_runtime_schema_and_dispose_delegate( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() - ) +async def test_governance_runtime_schema_and_dispose_delegate() -> None: delegate = _StubDelegate() runtime = GovernanceRuntime(delegate, policy_provider=None) @@ -318,143 +237,3 @@ async def test_governance_runtime_schema_and_dispose_delegate( await runtime.dispose() assert delegate.schema_called assert delegate.disposed - - -# --------------------------------------------------------------------------- -# _extract_is_conversational -# --------------------------------------------------------------------------- - - -def test_extract_is_conversational_true_from_agent_definition() -> None: - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=True) - ) - assert _extract_is_conversational(delegate) is True - - -def test_extract_is_conversational_false_from_agent_definition() -> None: - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=False) - ) - assert _extract_is_conversational(delegate) is False - - -def test_extract_is_conversational_returns_none_when_unreachable() -> None: - """No ``_agent_definition`` anywhere on the chain → ``None`` (let the provider default).""" - assert _extract_is_conversational(SimpleNamespace()) is None - - -def test_extract_is_conversational_returns_none_when_field_is_none() -> None: - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=None) - ) - assert _extract_is_conversational(delegate) is None - - -def test_extract_is_conversational_unwraps_via_underscore_delegate() -> None: - inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) - outer = SimpleNamespace(_delegate=inner) - assert _extract_is_conversational(outer) is True - - -def test_extract_is_conversational_unwraps_via_delegate_attr() -> None: - inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) - outer = SimpleNamespace(delegate=inner) - assert _extract_is_conversational(outer) is False - - -def test_extract_is_conversational_depth_capped() -> None: - """A pathological self-referential wrapper can't loop forever.""" - self_ref = SimpleNamespace() - self_ref._delegate = self_ref # type: ignore[attr-defined] - assert _extract_is_conversational(self_ref) is None - - -# --------------------------------------------------------------------------- -# GovernanceRuntime wires the selector -# --------------------------------------------------------------------------- - - -def test_governance_runtime_sets_agent_type_from_delegate() -> None: - """Init reads ``delegate._agent_definition.is_conversational`` and writes the selector.""" - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=True), - execute=_StubDelegate().execute, - stream=_StubDelegate().stream, - get_schema=_StubDelegate().get_schema, - dispose=_StubDelegate().dispose, - ) - - # Don't run the real prefetch thread — just confirm the selector - # ended up where the provider would read it. - GovernanceRuntime(delegate, policy_provider=None) - - assert loader._agent_is_conversational is True - - -def test_governance_runtime_sets_none_when_agent_definition_missing() -> None: - """No ``_agent_definition`` → selector stays unset (``None``).""" - GovernanceRuntime(_StubDelegate(), policy_provider=None) - assert loader._agent_is_conversational is None - - -def test_governance_runtime_preserves_externally_set_selector_on_extraction_miss() -> None: - """Externally-set selector survives a runtime init that finds no ``_agent_definition``. - - Regression: previously ``__init__`` unconditionally wrote whatever - ``_extract_is_conversational`` returned, so an extraction miss - (``None``) silently clobbered a value an integration had pre-seeded. - """ - set_agent_conversational(True) - GovernanceRuntime(_StubDelegate(), policy_provider=None) - assert loader._agent_is_conversational is True - - -def test_governance_runtime_fails_open_when_extraction_raises( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A pathological delegate accessor raising mid-extraction can't break init.""" - monkeypatch.setattr( - "uipath.runtime.governance.runtime._extract_is_conversational", - MagicMock(side_effect=RuntimeError("boom")), - ) - set_provider_spy = MagicMock() - prefetch_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy - ) - monkeypatch.setattr( - "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy - ) - - # No exception escapes; the rest of init still runs. - GovernanceRuntime(_StubDelegate(), policy_provider=None) - - set_provider_spy.assert_called_once_with(None) - prefetch_spy.assert_called_once_with() - - -def test_governance_runtime_skips_extraction_when_ff_off( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """FF off → no selector write, no provider registration, no prefetch.""" - from uipath.core.feature_flags import FeatureFlags - - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - - extract_spy = MagicMock() - monkeypatch.setattr( - "uipath.runtime.governance.runtime._extract_is_conversational", extract_spy - ) - - delegate = SimpleNamespace( - _agent_definition=SimpleNamespace(is_conversational=True), - execute=_StubDelegate().execute, - stream=_StubDelegate().stream, - get_schema=_StubDelegate().get_schema, - dispose=_StubDelegate().dispose, - ) - GovernanceRuntime(delegate, policy_provider=None) - - assert not extract_spy.called - assert loader._agent_is_conversational is None diff --git a/tests/test_loader.py b/tests/test_loader.py index 23df2d6d..4823014b 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,11 +1,15 @@ -"""Tests for the policy loader module. - -Provider-only world: the loader fetches policies exclusively through a -registered :class:`GovernancePolicyProvider`. Tests here cover the -caching, FF-gate, prefetch coordination, and fallback-to-empty behavior -that's independent of any specific provider. End-to-end provider -plumbing (mode application, YAML parsing, runtime wrapper integration) -lives in :mod:`tests.test_governance_runtime`. +"""Tests for the policy loader. + +Provider-only world: each :class:`PolicyLoader` is instance-scoped and +bound to one :class:`GovernancePolicyProvider`. Tests here cover the +caching, prefetch coordination, and fallback-to-empty behavior +independent of any specific provider. End-to-end provider plumbing +(mode application, YAML parsing, runtime wrapper integration) lives in +:mod:`tests.test_governance_runtime`. + +The loader no longer reads the governance feature flag — deciding +whether governance attaches at all is the wiring layer's concern, not +the loader's. """ from __future__ import annotations @@ -23,16 +27,8 @@ ) from tests._helpers import StubPolicyProvider, reset_enforcement_mode -from uipath.runtime.governance.native import loader -from uipath.runtime.governance.native.loader import ( - _empty_index_reason, - clear_policy_cache, - get_available_packs, - get_policy_index, - load_policy_index, - prefetch_policy_index, - set_policy_provider, -) +from uipath.runtime.governance.native import loader as loader_mod +from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.governance.native.models import PolicyIndex SIMPLE_POLICY_YAML = """ @@ -48,60 +44,48 @@ def _ok_response() -> PolicyResponse: - return PolicyResponse( - mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML - ) + return PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) @pytest.fixture(autouse=True) -def _clean_loader_state(): - """Each test starts with a fresh loader cache and FF on.""" - from uipath.core.feature_flags import FeatureFlags - - clear_policy_cache() +def _clean_enforcement_mode() -> Any: + """Each test starts from a clean enforcement-mode slate.""" reset_enforcement_mode() - set_policy_provider(None) - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) yield - clear_policy_cache() reset_enforcement_mode() - set_policy_provider(None) - FeatureFlags.reset_flags() # --------------------------------------------------------------------------- -# _empty_index_reason +# _empty_index_reason — diagnostic string for the "no policies" log # --------------------------------------------------------------------------- def test_empty_index_reason_no_provider() -> None: - msg = _empty_index_reason() + msg = PolicyLoader(None)._empty_index_reason() assert "no policy provider" in msg def test_empty_index_reason_with_provider() -> None: - set_policy_provider(StubPolicyProvider(response=_ok_response())) - msg = _empty_index_reason() + msg = PolicyLoader(StubPolicyProvider(response=_ok_response()))._empty_index_reason() assert "provider returned no policies" in msg # --------------------------------------------------------------------------- -# load_policy_index — public entry +# load_policy_index — synchronous entry point # --------------------------------------------------------------------------- def test_load_policy_index_empty_when_no_provider() -> None: - """No provider registered → empty PolicyIndex.""" - index = load_policy_index() + """No provider supplied → empty PolicyIndex.""" + index = PolicyLoader(None).load_policy_index() assert isinstance(index, PolicyIndex) assert index.total_rules == 0 -def test_load_policy_index_uses_registered_provider() -> None: +def test_load_policy_index_uses_provider() -> None: provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) - index = load_policy_index() + index = PolicyLoader(provider).load_policy_index() assert isinstance(index, PolicyIndex) assert "test-pack" in index.pack_names @@ -109,54 +93,56 @@ def test_load_policy_index_uses_registered_provider() -> None: def test_load_policy_index_returns_empty_when_provider_raises() -> None: - set_policy_provider(StubPolicyProvider(raises=RuntimeError("boom"))) - index = load_policy_index() + provider = StubPolicyProvider(raises=RuntimeError("boom")) + index = PolicyLoader(provider).load_policy_index() assert index.total_rules == 0 # --------------------------------------------------------------------------- -# get_policy_index — caching + FF gate +# get_policy_index — caching # --------------------------------------------------------------------------- def test_get_policy_index_caches_after_first_call() -> None: """A second call returns the cached index without re-invoking the provider.""" provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) + loader = PolicyLoader(provider) - a = get_policy_index() - b = get_policy_index() + a = loader.get_policy_index() + b = loader.get_policy_index() assert a is b assert len(provider.calls) == 1 -def test_get_policy_index_short_circuits_when_ff_off() -> None: - """FF off → return an empty index without invoking the provider.""" - from uipath.core.feature_flags import FeatureFlags - - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) - - index = get_policy_index() - - assert index.total_rules == 0 - assert provider.calls == [] - - def test_get_policy_index_sync_load_when_no_prefetch() -> None: """Without a prefetch in flight, get_policy_index synchronously loads.""" - set_policy_provider(StubPolicyProvider(response=_ok_response())) - index = get_policy_index() + loader = PolicyLoader(StubPolicyProvider(response=_ok_response())) + index = loader.get_policy_index() assert index.total_rules == 1 +def test_get_policy_index_empty_with_no_provider() -> None: + """No provider supplied → cached empty index, provider never invoked.""" + loader = PolicyLoader(None) + a = loader.get_policy_index() + b = loader.get_policy_index() + assert a is b + assert a.total_rules == 0 + + # --------------------------------------------------------------------------- # Prefetch — idempotency + completion + timeout # --------------------------------------------------------------------------- +def test_prefetch_no_op_when_provider_is_none() -> None: + """No provider → prefetch is a no-op (no thread, no event).""" + loader = PolicyLoader(None) + loader.prefetch() + assert loader._prefetch_event is None + + def test_prefetch_is_idempotent() -> None: """Second call while first is in flight is a no-op (no second thread).""" block = threading.Event() @@ -166,38 +152,24 @@ def _slow_get(context: PolicyContext) -> PolicyResponse: return _ok_response() provider: Any = type("P", (), {"get_policy": staticmethod(_slow_get)})() - set_policy_provider(provider) + loader = PolicyLoader(provider) - prefetch_policy_index() + loader.prefetch() first_event = loader._prefetch_event - prefetch_policy_index() + loader.prefetch() assert loader._prefetch_event is first_event block.set() if first_event is not None: first_event.wait(timeout=2.0) -def test_prefetch_skipped_when_ff_off() -> None: - """FF off → no prefetch thread started.""" - from uipath.core.feature_flags import FeatureFlags - - FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) - provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) - - prefetch_policy_index() - - assert provider.calls == [] - assert loader._prefetch_event is None - - def test_prefetch_no_op_when_index_already_loaded() -> None: """If the index is already cached, prefetch is a no-op.""" provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) - get_policy_index() # populate the cache + loader = PolicyLoader(provider) + loader.get_policy_index() # populate the cache - prefetch_policy_index() + loader.prefetch() assert len(provider.calls) == 1 @@ -213,27 +185,32 @@ def _fetch(context: PolicyContext) -> PolicyResponse: return _ok_response() provider: Any = type("P", (), {"get_policy": staticmethod(_fetch)})() - set_policy_provider(provider) + loader = PolicyLoader(provider) - prefetch_policy_index() + loader.prefetch() assert started.wait(timeout=2.0) threading.Thread( target=lambda: (time.sleep(0.05), release.set()), daemon=True ).start() - index = get_policy_index() + index = loader.get_policy_index() assert index.total_rules == 1 -def test_get_policy_index_logs_when_prefetch_completes_with_empty_index( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The 'completed but produced no PolicyIndex' branch fires on provider failure.""" +def test_get_policy_index_logs_when_prefetch_completes_with_empty_index() -> None: + """The 'completed but produced no PolicyIndex' branch fires on provider failure. + + Manually wire a completed event without populating ``_policy_index`` — + simulates a prefetch worker that hit an unexpected error after the + event was claimed but before the index was set. + """ + loader = PolicyLoader(StubPolicyProvider(response=_ok_response())) event = threading.Event() - event.set() # prefetch already completed - monkeypatch.setattr(loader, "_prefetch_event", event) - # _policy_index stays None — simulating "prefetch completed but produced nothing" - with patch.object(loader.logger, "warning") as mock_warning: - index = get_policy_index() + event.set() + loader._prefetch_event = event + + with patch.object(loader_mod.logger, "warning") as mock_warning: + index = loader.get_policy_index() + assert index.total_rules == 0 assert any( "completed but produced no PolicyIndex" in str(call.args[0]) @@ -242,28 +219,95 @@ def test_get_policy_index_logs_when_prefetch_completes_with_empty_index( # --------------------------------------------------------------------------- -# get_available_packs / clear_policy_cache +# available_packs / clear_cache # --------------------------------------------------------------------------- -def test_get_available_packs_before_load_returns_empty() -> None: - assert get_available_packs() == [] +def test_available_packs_before_load_returns_empty() -> None: + assert PolicyLoader(None).available_packs == [] -def test_get_available_packs_after_load() -> None: - set_policy_provider(StubPolicyProvider(response=_ok_response())) - get_policy_index() - assert "test-pack" in get_available_packs() +def test_available_packs_after_load() -> None: + loader = PolicyLoader(StubPolicyProvider(response=_ok_response())) + loader.get_policy_index() + assert "test-pack" in loader.available_packs -def test_clear_policy_cache_forces_refetch() -> None: +def test_clear_cache_forces_refetch() -> None: provider = StubPolicyProvider(response=_ok_response()) - set_policy_provider(provider) + loader = PolicyLoader(provider) - get_policy_index() - clear_policy_cache() - get_policy_index() + loader.get_policy_index() + loader.clear_cache() + loader.get_policy_index() assert len(provider.calls) == 2 +def test_clear_cache_drops_in_flight_worker_result() -> None: + """A worker spawned before ``clear_cache`` must not clobber state after it. + + The race: ``prefetch()`` starts a worker, ``clear_cache()`` retires + the prefetch event, then the worker finishes and (incorrectly, + before the fix) writes its loaded index back over the cleared + cache. With the fix the worker checks ``_prefetch_event is event`` + before publishing and discards its result when orphaned. + """ + block = threading.Event() + + def _slow_get(context: PolicyContext) -> PolicyResponse: + block.wait(timeout=2.0) + return _ok_response() + + provider: Any = type("P", (), {"get_policy": staticmethod(_slow_get)})() + loader = PolicyLoader(provider) + + loader.prefetch() + captured_event = loader._prefetch_event + assert captured_event is not None # prefetch actually started + + # Retire the in-flight worker. + loader.clear_cache() + assert loader._policy_index is None + assert loader._prefetch_event is None + + # Release the worker; let it finish and try to publish. + block.set() + assert captured_event.wait(timeout=2.0) + + # The orphan worker's result must NOT land in the cache. + assert loader._policy_index is None + + +# --------------------------------------------------------------------------- +# Cross-instance isolation — the whole point of instance-scoped state +# --------------------------------------------------------------------------- + + +def test_two_loaders_do_not_share_cache() -> None: + """Concurrent loaders maintain independent caches. + + ``uipath eval`` runs multiple runtimes in parallel; each gets its + own loader and must not leak its cached PolicyIndex into the next. + """ + p1 = StubPolicyProvider(response=_ok_response()) + p2 = StubPolicyProvider(response=_ok_response()) + l1 = PolicyLoader(p1) + l2 = PolicyLoader(p2) + + l1.get_policy_index() + l2.get_policy_index() + + assert len(p1.calls) == 1 + assert len(p2.calls) == 1 + + +def test_two_loaders_carry_independent_conversational_selectors() -> None: + """Each loader threads its own selector into PolicyContext.""" + p1 = StubPolicyProvider(response=_ok_response()) + p2 = StubPolicyProvider(response=_ok_response()) + PolicyLoader(p1, is_conversational=True).load_policy_index() + PolicyLoader(p2, is_conversational=False).load_policy_index() + + assert p1.calls[0].is_conversational is True + assert p2.calls[0].is_conversational is False From 13cd36602a0870046337dfb8cdd12323d4afba79 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Wed, 24 Jun 2026 14:58:02 +0530 Subject: [PATCH 11/18] refactor(governance): instance-scope enforcement mode on PolicyLoader Addresses radu's follow-up on PR #121 (discussion r3465934815): the enforcement mode was still process-level scoped via config._state, defeating the point of the loader instance-scoping when uipath eval runs parallel runtimes with mixed-mode policies. - PolicyLoader now owns _enforcement_mode and exposes it via the enforcement_mode property (defaults to AUDIT when no provider response has supplied a mode) - _load_from_provider writes the instance field instead of calling the global set_enforcement_mode - config.py deleted entirely: _state / _EnforcementModeState / get_enforcement_mode / set_enforcement_mode are gone. No production consumers outside the loader; canonical EnforcementMode lives in uipath.core.governance Tests: - _helpers.reset_enforcement_mode dropped (no global to reset) - test_enforcement_mode_default rewritten around PolicyLoader.enforcement_mode; new test_two_loaders_carry_independent_enforcement_modes pins the cross-instance isolation invariant - test_governance_runtime / test_loader drop the reset fixture and the get/set imports; mode-persistence test exercises two consecutive loads on a single loader 188 passed, ruff/mypy/bandit clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uipath/runtime/governance/config.py | 60 -------- .../runtime/governance/native/loader.py | 33 ++++- tests/_helpers.py | 20 +-- tests/conftest.py | 9 +- tests/test_enforcement_mode_default.py | 140 ++++++++++++------ tests/test_governance_runtime.py | 36 ++--- tests/test_loader.py | 10 +- 7 files changed, 158 insertions(+), 150 deletions(-) delete mode 100644 src/uipath/runtime/governance/config.py diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py deleted file mode 100644 index d766dfdb..00000000 --- a/src/uipath/runtime/governance/config.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Runtime-level governance enforcement-mode state. - -The feature-flag gate (``is_governance_enabled``) lives in -:mod:`uipath.core.governance.config` because it is process-level and -must be resolvable by callers that do not depend on -``uipath-runtime``. The enforcement mode is *per-policy* — -provider-supplied on each policy load — and therefore lives here in -the runtime package alongside the policy loader that applies it via -:func:`set_enforcement_mode`. -""" - -from __future__ import annotations - -# ``EnforcementMode`` is the shared governance value type; it's defined in -# uipath.core.governance (a lower abstraction level) and re-exported here so -# runtime callers keep a single import site. The per-process mode *state* -# below is runtime-owned and applied by the policy loader. -from uipath.core.governance import EnforcementMode as EnforcementMode - - -class _EnforcementModeState: - """Holds the active enforcement mode. - - A single module-level instance backs the get/set/reset helpers, so the - mode is updated by mutating an attribute rather than rebinding a module - global. ``mode is None`` means "no provider has supplied a mode yet" — - until then (and if the provider omits a mode) governance defaults to - AUDIT. - """ - - def __init__(self) -> None: - self.mode: EnforcementMode | None = None - - -# The enforcement mode is supplied by the policy provider on each load; -# the loader applies it via :func:`set_enforcement_mode`. -_state = _EnforcementModeState() - - -def get_enforcement_mode() -> EnforcementMode: - """Return the current enforcement mode. - - The canonical source is whatever the policy provider supplied on - the most recent load, applied via :func:`set_enforcement_mode`. - Until that load lands (or if the provider returns no mode), the - default is :attr:`EnforcementMode.AUDIT` — evaluate and log without - blocking. Defaulting to AUDIT avoids the chicken-and-egg where a - DISABLED default would short-circuit evaluation before the - background policy load could ever opt the tenant in. - """ - return _state.mode if _state.mode is not None else EnforcementMode.AUDIT - - -def set_enforcement_mode(mode: EnforcementMode) -> None: - """Set the enforcement mode programmatically. - - The policy loader calls this with the provider-supplied mode on - each load so the evaluator picks up the platform-controlled value. - """ - _state.mode = mode diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py index a59d96f0..5b45d210 100644 --- a/src/uipath/runtime/governance/native/loader.py +++ b/src/uipath/runtime/governance/native/loader.py @@ -22,9 +22,12 @@ from collections import Counter import yaml -from uipath.core.governance import GovernancePolicyProvider, PolicyContext +from uipath.core.governance import ( + EnforcementMode, + GovernancePolicyProvider, + PolicyContext, +) -from uipath.runtime.governance.config import set_enforcement_mode from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml from uipath.runtime.governance.native.models import PolicyIndex @@ -75,6 +78,12 @@ def __init__( self._provider = provider self._is_conversational = is_conversational self._policy_index: PolicyIndex | None = None + # Enforcement mode supplied by the provider on the most recent + # load. ``None`` until the first load lands (or whenever the + # provider omits a mode); :attr:`enforcement_mode` returns + # ``AUDIT`` in that case. Instance-scoped so parallel runtimes + # (e.g. ``uipath eval``) don't clobber each other. + self._enforcement_mode: EnforcementMode | None = None # ``_prefetch_event`` is set once the background load finishes # (success OR failure); callers of ``get_policy_index`` wait on # it. ``_prefetch_lock`` guards the start-once semantics so @@ -244,7 +253,7 @@ def _load_from_provider( return None if response.mode is not None: - set_enforcement_mode(response.mode) + self._enforcement_mode = response.mode logger.info("Enforcement mode set from provider: %s", response.mode.value) if not response.policies: @@ -292,6 +301,24 @@ def _log_index_summary(self, index: PolicyIndex) -> None: dict(hook_counts), ) + @property + def enforcement_mode(self) -> EnforcementMode: + """Active enforcement mode for this loader. + + The canonical source is whatever the policy provider supplied on + the most recent load. Until that load lands (or if the provider + omits a mode), the default is :attr:`EnforcementMode.AUDIT` — + evaluate and log without blocking. Defaulting to AUDIT avoids + the chicken-and-egg where a DISABLED default would short-circuit + evaluation before the background load could ever opt the tenant + in. + """ + return ( + self._enforcement_mode + if self._enforcement_mode is not None + else EnforcementMode.AUDIT + ) + @property def available_packs(self) -> list[str]: """Pack names from the currently loaded policy index. diff --git a/tests/_helpers.py b/tests/_helpers.py index c7dbbd84..2d3d924c 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,8 +1,11 @@ """Shared test-only helpers. -Keeps test concerns out of the production governance package: per-test -isolation utilities and shared stubs live here rather than inside the -production modules. +Keeps test concerns out of the production governance package: shared +stubs live here rather than inside the production modules. + +The enforcement-mode reset helper is gone because the mode is now +instance-scoped on :class:`PolicyLoader` — tests that want a clean +slate just construct a fresh loader instead of touching a global. """ from __future__ import annotations @@ -11,17 +14,6 @@ from uipath.core.governance import PolicyContext, PolicyResponse -from uipath.runtime.governance import config - - -def reset_enforcement_mode() -> None: - """Clear the process-wide enforcement mode so the AUDIT default re-applies. - - Test isolation only — production code never resets the mode; the policy - loader sets it from the provider-supplied :class:`PolicyResponse`. - """ - config._state.mode = None - class StubPolicyProvider: """Minimal in-memory :class:`GovernancePolicyProvider` for tests. diff --git a/tests/conftest.py b/tests/conftest.py index 01e5dc95..ba76eca6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,8 +19,7 @@ def temp_dir() -> Generator[str, None, None]: yield tmp_dir -# The loader no longer keeps provider / selector at module scope — -# state is owned by each :class:`PolicyLoader` instance — so no -# autouse cross-test reset is needed. Tests that share enforcement -# mode call :func:`reset_enforcement_mode` from ``tests._helpers`` -# directly. +# Governance state — provider, conversational selector, policy cache, +# enforcement mode — is owned by each :class:`PolicyLoader` instance, +# so no autouse cross-test reset is needed. Tests that want a clean +# slate just construct a fresh loader. diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py index 5159c78f..78230fd9 100644 --- a/tests/test_enforcement_mode_default.py +++ b/tests/test_enforcement_mode_default.py @@ -1,60 +1,114 @@ -"""Tests for the default enforcement-mode resolution. +"""Tests for the default enforcement-mode resolution on :class:`PolicyLoader`. The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at runtime construction and the background policy load can run. If the -provider later returns ``disabled``, ``set_enforcement_mode`` flips -the mode and ``evaluate()`` short-circuits per-call. +provider later returns ``disabled``, the loader records it and +:attr:`enforcement_mode` flips. -Resolution (per :func:`get_enforcement_mode`): -1. The provider-supplied value applied via ``set_enforcement_mode`` by - the policy loader. -2. Default ``AUDIT``. +Resolution (per :attr:`PolicyLoader.enforcement_mode`): +1. The provider-supplied value on the most recent load. +2. Default :attr:`EnforcementMode.AUDIT`. """ from __future__ import annotations -import pytest +from uipath.core.governance import EnforcementMode, PolicyResponse -from tests._helpers import reset_enforcement_mode -from uipath.runtime.governance.config import ( - EnforcementMode, - get_enforcement_mode, - set_enforcement_mode, -) - - -@pytest.fixture(autouse=True) -def _isolate_mode(): - """Each test starts from a clean module-state slate.""" - reset_enforcement_mode() - yield - reset_enforcement_mode() +from tests._helpers import StubPolicyProvider +from uipath.runtime.governance.native.loader import PolicyLoader def test_default_mode_is_audit() -> None: - """No backend-supplied mode → AUDIT. + """No provider-supplied mode yet → AUDIT. AUDIT is the default so the wrapper attaches and the background policy fetch can run. The backend can flip the mode to DISABLED on fetch when the tenant has no policies. """ - assert get_enforcement_mode() is EnforcementMode.AUDIT - - -def test_backend_disabled_wins_over_default() -> None: - """The backend mode (via ``set_enforcement_mode``) overrides the default.""" - set_enforcement_mode(EnforcementMode.DISABLED) - assert get_enforcement_mode() is EnforcementMode.DISABLED - - -def test_backend_enforce_wins_over_default() -> None: - set_enforcement_mode(EnforcementMode.ENFORCE) - assert get_enforcement_mode() is EnforcementMode.ENFORCE - - -def test_reset_returns_to_default() -> None: - """``reset_enforcement_mode`` clears the mode so the default re-applies.""" - set_enforcement_mode(EnforcementMode.ENFORCE) - assert get_enforcement_mode() is EnforcementMode.ENFORCE - reset_enforcement_mode() - assert get_enforcement_mode() is EnforcementMode.AUDIT \ No newline at end of file + loader = PolicyLoader(None) + assert loader.enforcement_mode is EnforcementMode.AUDIT + + +def test_provider_disabled_wins_over_default() -> None: + """A provider supplying DISABLED overrides the AUDIT default.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.DISABLED, policies="") + ) + loader = PolicyLoader(provider) + loader.load_policy_index() + assert loader.enforcement_mode is EnforcementMode.DISABLED + + +def test_provider_enforce_wins_over_default() -> None: + """A provider supplying ENFORCE flips the loader to enforce.""" + provider = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.ENFORCE, + policies="standard: p\nrules: [{id: r1, hook: before_model, " + "checks: [{type: regex, patterns: ['x']}]}]\n", + ) + ) + loader = PolicyLoader(provider) + loader.load_policy_index() + assert loader.enforcement_mode is EnforcementMode.ENFORCE + + +def test_loader_with_none_mode_response_keeps_previous_value() -> None: + """Provider returning ``mode=None`` doesn't clobber a previously-set mode. + + The wire response model treats ``None`` as "no opinion" — the loader + must not overwrite a real value with it. Otherwise a transient + provider response could silently demote a tenant's enforcement + posture. + """ + p1 = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.ENFORCE, + policies="standard: p\nrules: [{id: r1, hook: before_model, " + "checks: [{type: regex, patterns: ['x']}]}]\n", + ) + ) + loader = PolicyLoader(p1) + loader.load_policy_index() + assert loader.enforcement_mode is EnforcementMode.ENFORCE + + # A second provider response that omits mode should not flip back to AUDIT. + loader._provider = StubPolicyProvider( + response=PolicyResponse( + mode=None, + policies="standard: p\nrules: [{id: r1, hook: before_model, " + "checks: [{type: regex, patterns: ['x']}]}]\n", + ) + ) + loader.clear_cache() + loader.load_policy_index() + assert loader.enforcement_mode is EnforcementMode.ENFORCE + + +def test_two_loaders_carry_independent_enforcement_modes() -> None: + """The whole point of the refactor: parallel loaders don't share mode. + + Previously :func:`set_enforcement_mode` wrote a module global, so an + ENFORCE-mode loader and a DISABLED-mode loader running concurrently + in the same process clobbered each other (last writer wins). + Instance-scoped mode means each loader's mode is read-isolated. + """ + p_enforce = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.ENFORCE, + policies="standard: e\nrules: [{id: r1, hook: before_model, " + "checks: [{type: regex, patterns: ['x']}]}]\n", + ) + ) + p_disabled = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.DISABLED, policies="") + ) + + enforce_loader = PolicyLoader(p_enforce) + disabled_loader = PolicyLoader(p_disabled) + + enforce_loader.load_policy_index() + disabled_loader.load_policy_index() + + assert enforce_loader.enforcement_mode is EnforcementMode.ENFORCE + assert disabled_loader.enforcement_mode is EnforcementMode.DISABLED diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py index 91fca095..810a8819 100644 --- a/tests/test_governance_runtime.py +++ b/tests/test_governance_runtime.py @@ -11,14 +11,12 @@ from typing import Any -import pytest from uipath.core.governance import ( EnforcementMode, PolicyResponse, ) -from tests._helpers import StubPolicyProvider, reset_enforcement_mode -from uipath.runtime.governance.config import get_enforcement_mode +from tests._helpers import StubPolicyProvider from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.governance.native.models import PolicyIndex from uipath.runtime.governance.runtime import GovernanceRuntime @@ -35,12 +33,8 @@ """ -@pytest.fixture(autouse=True) -def _reset_mode() -> Any: - """Each test starts with a clean enforcement-mode slate.""" - reset_enforcement_mode() - yield - reset_enforcement_mode() +# Each test constructs a fresh ``PolicyLoader`` / ``GovernanceRuntime`` +# — no module-level state to reset. # --------------------------------------------------------------------------- @@ -53,12 +47,13 @@ def test_loader_builds_index_and_applies_mode() -> None: response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) ) - index = PolicyLoader(provider).load_policy_index() + loader = PolicyLoader(provider) + index = loader.load_policy_index() assert isinstance(index, PolicyIndex) assert index.total_rules == 1 assert "provider-pack" in index.pack_names - assert get_enforcement_mode() == EnforcementMode.ENFORCE + assert loader.enforcement_mode == EnforcementMode.ENFORCE def test_loader_passes_is_conversational_in_context() -> None: @@ -118,16 +113,23 @@ def test_loader_returns_empty_on_malformed_yaml() -> None: def test_loader_does_not_change_mode_when_response_mode_is_none() -> None: - from uipath.runtime.governance.config import set_enforcement_mode + """Provider returning ``mode=None`` doesn't clobber a previously-set mode.""" + p1 = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) + ) + loader = PolicyLoader(p1) + loader.load_policy_index() + assert loader.enforcement_mode == EnforcementMode.ENFORCE - set_enforcement_mode(EnforcementMode.ENFORCE) - provider = StubPolicyProvider( + # Next load via a different provider that returns mode=None must not + # demote the loader's mode back to AUDIT. + loader._provider = StubPolicyProvider( response=PolicyResponse(mode=None, policies=SIMPLE_POLICY_YAML) ) + loader.clear_cache() + loader.load_policy_index() - PolicyLoader(provider).load_policy_index() - - assert get_enforcement_mode() == EnforcementMode.ENFORCE + assert loader.enforcement_mode == EnforcementMode.ENFORCE # --------------------------------------------------------------------------- diff --git a/tests/test_loader.py b/tests/test_loader.py index 4823014b..87e453b2 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -19,14 +19,13 @@ from typing import Any from unittest.mock import patch -import pytest from uipath.core.governance import ( EnforcementMode, PolicyContext, PolicyResponse, ) -from tests._helpers import StubPolicyProvider, reset_enforcement_mode +from tests._helpers import StubPolicyProvider from uipath.runtime.governance.native import loader as loader_mod from uipath.runtime.governance.native.loader import PolicyLoader from uipath.runtime.governance.native.models import PolicyIndex @@ -47,12 +46,7 @@ def _ok_response() -> PolicyResponse: return PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) -@pytest.fixture(autouse=True) -def _clean_enforcement_mode() -> Any: - """Each test starts from a clean enforcement-mode slate.""" - reset_enforcement_mode() - yield - reset_enforcement_mode() +# Each test constructs a fresh ``PolicyLoader`` — no shared state to reset. # --------------------------------------------------------------------------- From 7e4ef8dc689463074494d6923f80e4d3f742ecfb Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Sat, 27 Jun 2026 15:56:41 +0530 Subject: [PATCH 12/18] =?UTF-8?q?feat(governance):=20audit=20pipeline=20?= =?UTF-8?q?=E2=80=94=20manager,=20console=20+=20traces=20sinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the audit pipeline that records governance evaluations: an AuditManager that fans out per-evaluation records to registered sinks (console + traces), with per-instance lifecycle (one ThreadPoolExecutor + atexit hook keyed via a WeakSet of live managers — same shape the later GuardrailCompensator slice reuses). Sinks ----- - Console sink for local development and CLI runs. - Traces sink that emits an OTel span per evaluation; severity is mapped from the matched rule's enforcement mode (audit / enforce / guardrail_fallback) so downstream traces UIs can filter by it. Companion changes pulled in via the main-merge on this branch ------------------------------------------------------------- This branch was kept in sync with main during review; the diff therefore includes the following work that originated in other PRs and will already be merged by the time this lands: - Workspace hydration primitives (hydration, hydrator, registry_store, workspace; from PR #131). - ``execution_source`` derived field on the runtime context (from PR #132). Co-Authored-By: Aditi Kumari Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runtime/governance/_audit/__init__.py | 12 + src/uipath/runtime/governance/_audit/base.py | 765 ++++++++++++++++++ .../runtime/governance/_audit/factory.py | 33 + .../runtime/governance/_audit/traces.py | 339 ++++++++ .../runtime/governance/native/models.py | 9 +- tests/test_audit_manager_lifecycle.py | 311 +++++++ tests/test_audit_register_sink.py | 108 +++ tests/test_traces_severity.py | 271 +++++++ uv.lock | 2 +- 9 files changed, 1847 insertions(+), 3 deletions(-) create mode 100644 src/uipath/runtime/governance/_audit/__init__.py create mode 100644 src/uipath/runtime/governance/_audit/base.py create mode 100644 src/uipath/runtime/governance/_audit/factory.py create mode 100644 src/uipath/runtime/governance/_audit/traces.py create mode 100644 tests/test_audit_manager_lifecycle.py create mode 100644 tests/test_audit_register_sink.py create mode 100644 tests/test_traces_severity.py diff --git a/src/uipath/runtime/governance/_audit/__init__.py b/src/uipath/runtime/governance/_audit/__init__.py new file mode 100644 index 00000000..b00769ce --- /dev/null +++ b/src/uipath/runtime/governance/_audit/__init__.py @@ -0,0 +1,12 @@ +"""Audit sink framework for governance events. + +Internal module. Provides a pluggable audit system that emits governance +events to one or more sinks. The only built-in sink is ``TracesAuditSink``, +which creates OpenTelemetry spans that uipath-core's exporter ships to the +Orchestrator Traces UI. This sink is always registered by every +:class:`AuditManager` and cannot be disabled by application code — it +carries the governance audit trail. + +Callers import from the submodules directly (``_audit.base``, ``_audit.traces``, +``_audit.factory``). This package exposes no aggregated symbols. +""" diff --git a/src/uipath/runtime/governance/_audit/base.py b/src/uipath/runtime/governance/_audit/base.py new file mode 100644 index 00000000..91bcaf58 --- /dev/null +++ b/src/uipath/runtime/governance/_audit/base.py @@ -0,0 +1,765 @@ +"""Base classes and models for the audit sink framework. + +This module provides the core abstractions for the governance audit system: +- AuditEvent: The data model for audit events +- EventType: Constants for common event types +- AuditSink: Abstract base class for sink implementations +- AuditManager: Central hub for routing events to sinks + +The AuditManager uses a background thread to process events asynchronously, +avoiding blocking the main agent execution path during audit trace HTTP calls. +""" + +from __future__ import annotations + +import atexit +import contextvars +import json +import logging +import os +import queue +import threading +import weakref +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + +from uipath.core.governance import EnforcementMode + +logger = logging.getLogger(__name__) + + +class _AuditManagerCleanupRegistry: + """Process-wide cleanup machinery for :class:`AuditManager` instances. + + A single ``atexit`` hook walks a ``WeakSet`` of live managers on + exit and flushes/closes each one. Two important properties: + + 1. **Bounded atexit registrations.** Per-instance ``atexit.register`` + grows the interpreter's atexit list without bound — N runtimes + → N hooks → N × shutdown-timeout total exit delay. One + process-level hook is constant work regardless of how many + managers were constructed. + + 2. **No strong reference to the manager.** ``WeakSet`` lets a + disposed manager get garbage-collected; if it's already gone by + exit time, we just skip it. Long-running ``uipath eval`` runs + that build many runtimes serially can therefore release each + one's memory as soon as nothing references it, instead of + pinning all of them until process exit. + + Encapsulated in a class (rather than three loose module-level + names + a ``global`` mutation) so the state is named, swappable in + tests, and the registration path doesn't reach across the module + scope to assign. + """ + + def __init__(self) -> None: + self.live_managers: weakref.WeakSet[AuditManager] = weakref.WeakSet() + self.atexit_registered = False + self.lock = threading.Lock() + + def register(self, manager: AuditManager) -> None: + """Add ``manager`` to the cleanup set + wire process atexit once. + + Double-checked under ``lock`` so two concurrent first-time + constructions don't both register the process atexit handler. + """ + self.live_managers.add(manager) + if self.atexit_registered: + return + with self.lock: + if not self.atexit_registered: + atexit.register(self.process_cleanup) + self.atexit_registered = True + + def process_cleanup(self) -> None: + """Process-exit handler: flush + close every live AuditManager. + + Iteration over a snapshot — the WeakSet may mutate during + cleanup (close() touches sinks_lock, GC may fire). Bounded by + each manager's own flush / close timeouts. + """ + for manager in list(self.live_managers): + try: + manager.flush(timeout=2.0) + manager.close() + except Exception as exc: # noqa: BLE001 - exit cleanup must not raise + logger.debug("Audit manager process cleanup error: %s", exc) + + +_cleanup_registry = _AuditManagerCleanupRegistry() + + +# ============================================================================= +# Audit Event Model +# ============================================================================= + + +@dataclass +class AuditEvent: + """Generic audit event that can be sent to any sink. + + Trace correlation is intentionally absent from this dataclass. + Sinks that need a trace id resolve one at their own boundary: + OTel-backed sinks let the SDK / exporter handle it (the audit + manager runs sink dispatch inside the caller's captured + contextvars snapshot, so the live OTel span is visible on the + worker), and HTTP sinks defer to their injected provider, which + resolves at HTTP-call time. + + Attributes: + event_type: Type of event (e.g., "rule_evaluation", "hook_summary") + timestamp: When the event occurred (auto-set if not provided) + agent_name: Name of the agent being governed + hook: Lifecycle hook where event occurred (optional) + data: Event-specific data dictionary + metadata: Additional metadata for filtering/routing + """ + + event_type: str + agent_name: str = "unknown" + hook: str = "" + data: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + result = asdict(self) + result["timestamp"] = self.timestamp.isoformat() + return result + + def to_json(self) -> str: + """Convert to JSON string.""" + return json.dumps(self.to_dict()) + + +class EventType: + """Constants for common event types.""" + + RULE_EVALUATION = "rule_evaluation" + HOOK_START = "hook_start" + HOOK_END = "hook_end" + SESSION_START = "session_start" + SESSION_END = "session_end" + POLICY_VIOLATION = "policy_violation" + POLICY_ALLOW = "policy_allow" + PACKS_LOADED = "packs_loaded" + + +# ============================================================================= +# Audit Sink Base Class +# ============================================================================= + + +class AuditSink(ABC): + """Abstract base class for audit output destinations. + + Subclass this to create custom audit sinks. Each sink receives + all audit events and decides how to handle them. + + Example: + class SlackAuditSink(AuditSink): + def __init__(self, webhook_url: str): + self.webhook_url = webhook_url + self._name = "slack" + + @property + def name(self) -> str: + return self._name + + def emit(self, event: AuditEvent) -> None: + if event.data.get("matched") and event.data.get("action") == "deny": + # Send to Slack on violations + requests.post(self.webhook_url, json=event.to_dict()) + + def flush(self) -> None: + pass + """ + + @property + @abstractmethod + def name(self) -> str: + """Unique name for this sink.""" + pass + + @abstractmethod + def emit(self, event: AuditEvent) -> None: + """Emit an audit event to this sink. + + Args: + event: The audit event to emit + + Note: + Implementations should handle errors gracefully and not + raise exceptions that would disrupt governance evaluation. + """ + pass + + def flush(self) -> None: + """Flush any buffered events. + + Override if sink buffers events before writing. + """ + return + + def close(self) -> None: + """Clean up resources. + + Override if sink holds resources that need cleanup. + """ + return + + def accepts(self, event: AuditEvent) -> bool: + """Check if this sink should receive the event. + + Override to filter events. Default accepts all events. + + Args: + event: The audit event to check + + Returns: + True if sink should receive event, False to skip + """ + return True + + +# ============================================================================= +# Audit Manager +# ============================================================================= + + +class AuditManager: + """Manages multiple audit sinks and routes events to them. + + Instance-scoped: each :class:`GovernanceRuntime` owns its own + manager. Parallel runtimes (``uipath eval``) don't share sinks, + workers, or per-sink failure state. + + Constructor automatically registers the always-on ``traces`` sink + (OpenTelemetry → Orchestrator audit UI). This sink writes the + governance audit trail and cannot be disabled by application code. + Additional sinks can be added via :meth:`register_sink`. + + Thread Safety: + Events are queued and processed by a background thread, making + :meth:`emit` non-blocking. This avoids blocking agent execution + during audit trace HTTP calls. + """ + + # Trip a sink after this many consecutive emit failures (circuit-breaker). + _SINK_FAILURE_THRESHOLD = 10 + # Bound the async queue so a stuck sink can't grow memory without limit. + # Matches the order of magnitude of a long-running agent's per-session + # audit volume; on overflow the oldest event is dropped to make room. + _DEFAULT_QUEUE_MAXSIZE = 10_000 + + def __init__( + self, + async_mode: bool = True, + queue_maxsize: int = _DEFAULT_QUEUE_MAXSIZE, + register_default_sinks: bool = True, + ) -> None: + """Initialize the audit manager. + + Args: + async_mode: If True (default), events are processed in a background + thread. If False, events are processed synchronously. + queue_maxsize: Max queued events in async mode. On overflow the + oldest queued event is dropped to make room. + register_default_sinks: If True (default), register the + always-on ``traces`` sink and an atexit cleanup + handler. Tests that want a bare manager can pass + ``False`` and register sinks explicitly. + """ + self._sinks: list[AuditSink] = [] + # Single lock guards _sinks, _sink_failures, _tripped_sinks — every + # collection mutated by both the worker thread and the emit caller. + self._sinks_lock = threading.Lock() + # Per-sink consecutive-failure counter, keyed by sink name. + self._sink_failures: dict[str, int] = {} + self._tripped_sinks: set[str] = set() + self._async_mode = async_mode + self._pid = os.getpid() + + # Background processing. + # + # Queue items are ``(contextvars.Context, AuditEvent)`` tuples + # so the caller's contextvars context (which holds the live + # OTel span, request correlation ids, etc.) propagates across + # the worker-thread hop. Without this the worker would see an + # empty contextvars context — OTel-backed sinks would render + # governance spans as orphan roots instead of children of the + # agent's live span. ``None`` is the shutdown sentinel. + self._queue: queue.Queue[ + tuple[contextvars.Context, AuditEvent] | None + ] = queue.Queue(maxsize=queue_maxsize) + self._worker_thread: threading.Thread | None = None + self._shutdown = threading.Event() + + if self._async_mode: + self._start_worker() + + if register_default_sinks: + self._register_traces_sink() + # Process-level atexit (one shared handler, weakref-tracked + # set) instead of per-instance ``atexit.register(self.method)``: + # avoids unbounded atexit list growth and the strong reference + # that would otherwise pin a disposed manager until process + # exit. See :class:`_AuditManagerCleanupRegistry`. + _cleanup_registry.register(self) + + def _register_traces_sink(self) -> None: + """Register the always-on ``traces`` sink. + + The traces sink (OpenTelemetry spans to the Orchestrator audit + UI) is registered for every manager and cannot be disabled by + application code — it carries the governance audit trail. The + factory import is deferred to avoid a module-load cycle + (``factory`` imports back into this module). + """ + from .factory import create_sink + + sink = create_sink("traces") + if sink is not None: + self.register_sink(sink) + logger.info("Governance audit sink registered: traces") + + def _start_worker(self) -> None: + """Start the background worker thread.""" + if self._worker_thread is not None and self._worker_thread.is_alive(): + return + + self._shutdown.clear() + self._worker_thread = threading.Thread( + target=self._worker_loop, + name="governance-audit-worker", + daemon=True, + ) + self._worker_thread.start() + logger.debug("Background audit worker started") + + def _worker_loop(self) -> None: + """Background worker loop that processes queued events.""" + while not self._shutdown.is_set(): + # Wait for an item with a timeout so we can re-check shutdown. + try: + item = self._queue.get(timeout=0.5) + except queue.Empty: + continue + # Every successful get() must be paired with exactly one + # task_done() — including the shutdown sentinel and the case + # where _emit_sync raises — otherwise unfinished_tasks never + # drains and flush()/join() hangs. + try: + if item is None: + # Shutdown signal + break + ctx, event = item + # Run sink dispatch inside the caller's captured + # contextvars context so OTel-backed sinks see the + # agent's live span via ``context.get_current()``. + ctx.run(self._emit_sync, event) + except Exception as e: + logger.warning("Audit worker error: %s", e) + finally: + self._queue.task_done() + + # Drain remaining events on shutdown + self._drain_queue() + + def _drain_queue(self) -> None: + """Process any remaining events in the queue.""" + while True: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + # As in _worker_loop: pair every get() with one task_done(), + # even when _emit_sync raises, so shutdown accounting is sound. + try: + if item is not None: + ctx, event = item + ctx.run(self._emit_sync, event) + except Exception as e: + logger.warning("Audit drain error: %s", e) + finally: + self._queue.task_done() + + def _emit_sync(self, event: AuditEvent) -> None: + """Emit event synchronously to all sinks (called from worker thread).""" + with self._sinks_lock: + sinks = list(self._sinks) + tripped = set(self._tripped_sinks) + for sink in sinks: + if sink.name in tripped: + continue + try: + if sink.accepts(event): + sink.emit(event) + # Success — reset failure counter for this sink. + with self._sinks_lock: + if self._sink_failures.get(sink.name): + self._sink_failures[sink.name] = 0 + except Exception as e: + with self._sinks_lock: + fails = self._sink_failures.get(sink.name, 0) + 1 + self._sink_failures[sink.name] = fails + tripped_now = fails >= self._SINK_FAILURE_THRESHOLD + if tripped_now: + self._tripped_sinks.add(sink.name) + if tripped_now: + logger.error( + "Audit sink '%s' tripped after %d consecutive failures; " + "will be skipped for the rest of this process. Last error: %s", + sink.name, + fails, + e, + ) + else: + logger.warning( + "Audit sink '%s' failed to emit event (%d/%d): %s", + sink.name, + fails, + self._SINK_FAILURE_THRESHOLD, + e, + ) + + def register_sink(self, sink: AuditSink) -> None: + """Register an audit sink. + + Args: + sink: The sink to register + + Note: + Duplicate sinks (same name) are ignored. + The circuit-breaker failure counter is cleared so a freshly + registered sink doesn't inherit a previous instance's tripped + state. ``unregister_sink`` already clears these, but the + defensive reset here guards against external manipulation + of the internal counters (tests, future callers). + """ + with self._sinks_lock: + if any(s.name == sink.name for s in self._sinks): + logger.debug("Sink '%s' already registered, skipping", sink.name) + return + self._sinks.append(sink) + self._sink_failures.pop(sink.name, None) + self._tripped_sinks.discard(sink.name) + logger.info("Registered audit sink: %s", sink.name) + + def unregister_sink(self, name: str) -> bool: + """Unregister an audit sink by name. + + Args: + name: Name of the sink to remove + + Returns: + True if sink was removed, False if not found + """ + sink_to_close: AuditSink | None = None + with self._sinks_lock: + for i, sink in enumerate(self._sinks): + if sink.name == name: + sink_to_close = sink + del self._sinks[i] + self._sink_failures.pop(name, None) + self._tripped_sinks.discard(name) + break + if sink_to_close is not None: + try: + sink_to_close.close() + except Exception as e: + logger.warning("Audit sink '%s' failed to close: %s", name, e) + logger.info("Unregistered audit sink: %s", name) + return True + return False + + def get_sink(self, name: str) -> AuditSink | None: + """Get a registered sink by name.""" + with self._sinks_lock: + for sink in self._sinks: + if sink.name == name: + return sink + return None + + def list_sinks(self) -> list[str]: + """Get names of all registered sinks.""" + with self._sinks_lock: + return [s.name for s in self._sinks] + + def emit(self, event: AuditEvent) -> None: + """Emit an audit event to all registered sinks. + + In async mode (default), this queues the event for background + processing and returns immediately. This avoids blocking the + main agent execution path during audit trace HTTP calls. + + On post-fork callers (worker process inheriting the parent's + manager), the queue is reinitialized and the worker thread + re-spawned before enqueue — otherwise events would silently + accumulate in a queue no one is draining. + + Args: + event: The audit event to emit + """ + self._ensure_alive_after_fork() + + if self._async_mode: + # Capture the caller's contextvars context now (while the + # OTel span and request correlation state are still live + # on this thread). The worker runs the sink dispatch + # inside this snapshot so cross-thread sinks see the same + # context the caller had. See queue type in __init__. + item = (contextvars.copy_context(), event) + # Non-blocking enqueue with drop-oldest backpressure: if the + # worker is wedged on a slow sink, this keeps memory bounded + # rather than growing without limit. + try: + self._queue.put_nowait(item) + except queue.Full: + try: + self._queue.get_nowait() + self._queue.task_done() + except queue.Empty: + pass + try: + self._queue.put_nowait(item) + except queue.Full: + # Worker is so far behind that the queue refilled + # between get_nowait and put_nowait — give up on + # this event rather than block. + pass + else: + # Synchronous processing — caller thread IS the worker, so + # the OTel context is already correct; no context snapshot + # or ctx.run() needed. + self._emit_sync(event) + + def _ensure_alive_after_fork(self) -> None: + """Reset queue and respawn worker if we're in a forked child. + + Double-checked under ``_sinks_lock``: a fresh-fork child where + multiple threads call :meth:`emit` concurrently could otherwise + each see the stale ``_pid`` and each rebuild ``_queue`` / + ``_shutdown`` / ``_worker_thread`` — one thread's writes would + clobber the other's, leaking the queue+worker pair. + """ + if os.getpid() == self._pid: + return # fast path: same process, no rebuild needed + with self._sinks_lock: + current_pid = os.getpid() + if current_pid == self._pid: + return # another thread won the rebuild race + # Child process inherited a dead worker_thread reference and + # a queue the parent owned. Rebuild both so child events drain. + self._pid = current_pid + self._queue = queue.Queue(maxsize=self._queue.maxsize) + self._shutdown = threading.Event() + self._worker_thread = None + if self._async_mode: + self._start_worker() + + def emit_rule_evaluation( + self, + policy_id: str, + rule_name: str, + pack_name: str, + hook: str, + matched: bool, + action: str, + enforcement_mode: EnforcementMode, + detail: str = "", + agent_name: str = "agent", + description: str = "", + ) -> None: + """Convenience method to emit a rule evaluation event. + + ``enforcement_mode`` travels on the event so sinks don't have to + read a process-global. Each emitter (instance-scoped) supplies + its own mode — parallel runtimes can run in different modes + simultaneously, and a process-global wouldn't be authoritative + for any of them. + """ + self.emit( + AuditEvent( + event_type=EventType.RULE_EVALUATION, + agent_name=agent_name, + hook=hook, + data={ + "policy_id": policy_id, + "rule_name": rule_name, + "pack_name": pack_name, + "matched": matched, + "action": action, + "enforcement_mode": enforcement_mode, + "detail": detail, + "description": description, + "status": "MATCHED" if matched else "PASS", + }, + ) + ) + + def emit_hook_summary( + self, + hook: str, + agent_name: str, + total_rules: int, + matched_rules: int, + final_action: str, + enforcement_mode: EnforcementMode, + ) -> None: + """Convenience method to emit a hook summary event.""" + self.emit( + AuditEvent( + event_type=EventType.HOOK_END, + agent_name=agent_name, + hook=hook, + data={ + "total_rules": total_rules, + "matched_rules": matched_rules, + "final_action": final_action, + "enforcement_mode": enforcement_mode, + }, + ) + ) + + def emit_session_start( + self, + session_id: str, + agent_name: str, + packs: list[str], + enforcement_mode: EnforcementMode, + ) -> None: + """Convenience method to emit a session start event. + + Same ``enforcement_mode: EnforcementMode`` contract as + :meth:`emit_rule_evaluation` and :meth:`emit_hook_summary` + — every governance event carries the emitter's per-instance + mode so sinks don't depend on a process-global. + """ + self.emit( + AuditEvent( + event_type=EventType.SESSION_START, + agent_name=agent_name, + data={ + "session_id": session_id, + "packs": packs, + "enforcement_mode": enforcement_mode, + }, + ) + ) + + def emit_session_end( + self, + session_id: str, + agent_name: str, + total_evaluations: int, + rules_matched: int, + rules_denied: int, + enforcement_mode: EnforcementMode, + ) -> None: + """Convenience method to emit a session end event.""" + self.emit( + AuditEvent( + event_type=EventType.SESSION_END, + agent_name=agent_name, + data={ + "session_id": session_id, + "total_evaluations": total_evaluations, + "rules_matched": rules_matched, + "rules_denied": rules_denied, + "enforcement_mode": enforcement_mode, + }, + ) + ) + + def flush(self, timeout: float = 5.0) -> None: + """Flush all pending events and sinks. + + In async mode, polls the queue until it drains or ``timeout`` + seconds elapse, whichever comes first. ``queue.Queue.join`` has + no timeout argument — using it would block indefinitely on a + wedged sink, which defeats the bounded-shutdown contract that + :class:`_AuditManagerCleanupRegistry` relies on at process exit. + + Args: + timeout: Maximum seconds to wait for queue to drain (default 5.0) + """ + if self._async_mode: + import time + + deadline = time.monotonic() + max(0.0, timeout) + poll_interval = min(0.05, timeout) if timeout > 0 else 0.0 + while time.monotonic() < deadline: + try: + if self._queue.unfinished_tasks == 0: + break + except Exception: # noqa: BLE001 - queue introspection is best-effort + break + time.sleep(poll_interval) + else: + # Loop didn't break — drain timed out. Log so a wedged + # sink is surfaced rather than swallowed. + try: + pending = self._queue.unfinished_tasks + except Exception: # noqa: BLE001 + pending = -1 + if pending: + logger.warning( + "Audit queue did not drain within %.2fs " + "(unfinished tasks=%s); sink may be wedged", + timeout, pending, + ) + + with self._sinks_lock: + sinks = list(self._sinks) + for sink in sinks: + try: + sink.flush() + except Exception as e: + logger.warning("Audit sink '%s' failed to flush: %s", sink.name, e) + + def close(self) -> None: + """Close all sinks and release resources. + + Stops the background worker thread and drains any remaining events. + Shutdown is bounded: ``_shutdown`` is the primary signal the + worker polls; the sentinel ``None`` enqueue is best-effort. If + the queue is full and the worker is wedged on a slow sink, + ``put_nowait`` fails fast rather than hanging process exit. + """ + if self._async_mode and self._worker_thread is not None: + # Signal shutdown first so the worker's next queue.get() loop + # iteration exits even if we can't enqueue the sentinel. + self._shutdown.set() + try: + self._queue.put_nowait(None) # Wake up worker + except queue.Full: + # Queue saturated by a stuck sink; the worker will see + # _shutdown on its next loop iteration once whatever it's + # blocked on completes (or the 2s join timeout fires). + logger.debug( + "Audit queue full at shutdown; relying on _shutdown signal" + ) + + # Wait for worker to finish (with timeout) + if self._worker_thread.is_alive(): + self._worker_thread.join(timeout=2.0) + + logger.debug("Background audit worker stopped") + + with self._sinks_lock: + sinks = list(self._sinks) + self._sinks.clear() + self._sink_failures.clear() + self._tripped_sinks.clear() + for sink in sinks: + try: + sink.close() + except Exception as e: + logger.warning("Audit sink '%s' failed to close: %s", sink.name, e) + + diff --git a/src/uipath/runtime/governance/_audit/factory.py b/src/uipath/runtime/governance/_audit/factory.py new file mode 100644 index 00000000..334f8678 --- /dev/null +++ b/src/uipath/runtime/governance/_audit/factory.py @@ -0,0 +1,33 @@ +"""Factory function for creating audit sinks by name. + +Used by :class:`AuditManager` to construct the always-on ``traces`` +sink at initialization. +""" + +from __future__ import annotations + +import logging + +from .base import AuditSink + +logger = logging.getLogger(__name__) + + +def create_sink(name: str) -> AuditSink | None: + """Create an audit sink by name. + + Args: + name: Name of the sink to create (currently only ``traces``). + + Returns: + The created sink, or ``None`` if the name is unknown. + """ + name = name.lower() + + if name == "traces": + from .traces import TracesAuditSink + + return TracesAuditSink() + + logger.warning("Unknown audit sink: %s", name) + return None diff --git a/src/uipath/runtime/governance/_audit/traces.py b/src/uipath/runtime/governance/_audit/traces.py new file mode 100644 index 00000000..abf1310f --- /dev/null +++ b/src/uipath/runtime/governance/_audit/traces.py @@ -0,0 +1,339 @@ +"""OpenTelemetry traces audit sink for Orchestrator integration. + +This sink creates OpenTelemetry spans for governance events. UiPath's +OTel exporter (``uipath.tracing._otel_exporters.LlmOpsHttpExporter`` via +``_SpanUtils.otel_span_to_uipath_span``) is what ships them to the +Orchestrator Traces UI and is also what reads ``UIPATH_TRACE_ID``, +``UIPATH_ORGANIZATION_ID``, ``UIPATH_TENANT_ID``, ``UIPATH_FOLDER_KEY`` +and ``UIPATH_JOB_KEY`` from the process environment and stamps them onto +the outgoing ``UiPathSpan``. We intentionally do **not** duplicate that +env-reading here — the exporter is the single source of truth for the +job-execution context. +""" + +from __future__ import annotations + +import importlib.metadata +import logging +from typing import Any + +from uipath.core.governance import EnforcementMode + +from .base import AuditEvent, AuditSink, EventType + +logger = logging.getLogger(__name__) + + +def _package_version() -> str: + """Return the installed ``uipath-runtime`` version (``unknown`` if absent).""" + try: + return importlib.metadata.version("uipath-runtime") + except importlib.metadata.PackageNotFoundError: + return "unknown" + + +# Stamped on every governance span as ``uipath_governance.version`` so +# consumers can correlate the trace payload shape with the runtime +# release that produced it. Resolved once at import time — the installed +# package version doesn't change for the life of the process. +SCHEMA_VERSION = _package_version() + +# Value for the ``type`` / ``span_type`` span attributes on every +# governance span. Matches ``SpanType.AGENT_RUN`` in uipath-agents-python +# — we use the string literal here (not a cross-package import) to keep +# uipath-runtime free of a uipath-agents dependency. If the agents-side +# registry adds new values, this constant is the single place to update. +SPAN_TYPE_AGENT_RUN = "agentRun" + +# Identifies this auditor on every governance span. Lets a downstream +# consumer distinguish traces emitted by the Python in-runtime governance +# checker from those produced by the governance-server (or any future +# language-specific governance SDK). Set as the ``source`` span +# attribute on every governance trace span. +GOVERNANCE_SOURCE = "governance-checker-python" + +# Shared attribute namespace for every key in the unified governance trace +# contract (§4 of the cross-product unification doc). Concatenated into +# each ``span.set_attribute`` call so the prefix appears in one place and +# a future rename (or alias) is a one-line change. +NS = "uipath_governance" + +# Unified-contract enum values (UPPER_SNAKE per §3 of the spec). +EVALUATOR_ALLOW = "ALLOW" +EVALUATOR_DENY = "DENY" +EVALUATOR_HITL = "HITL" + +ACTION_ALLOW = "ALLOW" +ACTION_DENY = "DENY" +ACTION_HITL = "HITL" +ACTION_AUDIT = "AUDIT" +ACTION_NONE = "NONE" + +def _resolve_mode(event: AuditEvent) -> EnforcementMode: + """Read the enforcement mode the evaluator stamped on the event. + + Mode travels with the event (set by the emitter when it calls + :meth:`AuditManager.emit_rule_evaluation` / + :meth:`emit_hook_summary` and passes its own per-instance 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 + the mode), but defaulting to the safe option avoids a sink crash. + """ + mode = event.data.get("enforcement_mode") + if isinstance(mode, EnforcementMode): + return mode + if isinstance(mode, str): + try: + return EnforcementMode(mode.lower()) + except ValueError: + pass + return EnforcementMode.AUDIT + + +def _derive_results( + matched: bool, configured_action: str, mode: EnforcementMode +) -> tuple[str, str]: + """Return ``(evaluator_result, action_applied)`` in spec vocabulary. + + ``evaluator_result`` is mode-independent — what the rule decided. The + rule's configured ``audit`` action collapses into a DENY decision + here; whether that DENY is actually applied is reflected in + ``action_applied``. + + ``action_applied`` is mode-driven. Currently only AUDIT mode is wired + in the runtime, so every non-allow result lands on ``AUDIT``; the + ENFORCE branch is kept so the contract is already correct when + ENFORCE arrives in a later phase. + + The configured ``audit`` rule-level action acts as a per-rule audit + override: even when global mode is ENFORCE, such a rule only ever + produces ``action_applied = AUDIT``. This preserves today's "audit + never blocks" behavior. + """ + action = configured_action.lower() + + if not matched or action == "allow": + return EVALUATOR_ALLOW, ACTION_NONE + + if action == "escalate": + evaluator = EVALUATOR_HITL + else: + evaluator = EVALUATOR_DENY + + # Per-rule audit override: emit AUDIT regardless of global mode. + if action == "audit": + return evaluator, ACTION_AUDIT + + if mode == EnforcementMode.ENFORCE: + return evaluator, ACTION_DENY if evaluator == EVALUATOR_DENY else ACTION_HITL + return evaluator, ACTION_AUDIT + +class TracesAuditSink(AuditSink): + """Audit sink that creates OpenTelemetry spans. + + Spans appear in UiPath Orchestrator Traces UI, providing structured + data for each governance evaluation. + """ + + def __init__(self) -> None: + """Initialize the sink with a deferred tracer and zero span count.""" + self._tracer: Any = None # Can be None, Tracer, or False + self._spans_created = 0 + + @property + def name(self) -> str: + """Constant sink identifier.""" + return "traces" + + def _get_tracer(self) -> Any: + """Get or create the OpenTelemetry tracer.""" + if self._tracer is None: + try: + from opentelemetry import trace + + self._tracer = trace.get_tracer("uipath.governance") + logger.info("OpenTelemetry tracer initialized for governance traces") + except ImportError: + # OpenTelemetry is supplied transitively by uipath-core; an + # ImportError here means the host install is broken or + # governance is running outside the UiPath SDK environment. + logger.warning( + "OpenTelemetry not available - governance traces disabled. " + "OTel is normally provided by uipath-core; reinstall the SDK." + ) + self._tracer = False + return self._tracer if self._tracer else None + + def emit(self, event: AuditEvent) -> None: + """Create a span for RULE_EVALUATION or HOOK_END events; drop others.""" + if event.event_type == EventType.RULE_EVALUATION: + self._emit_rule_span(event) + elif event.event_type == EventType.HOOK_END: + self._emit_hook_span(event) + + def _emit_hook_span(self, event: AuditEvent) -> None: + """Create a span for a hook summary (always emitted for each governance check).""" + tracer = self._get_tracer() + if tracer is None: + return + + try: + from opentelemetry import context + + data = event.data + hook = event.hook or "unknown" + span_name = f"governance.{hook.lower()}" + + # Use the current OTel context. The audit manager runs the + # sink inside the caller's captured ``contextvars`` context + # (see :meth:`AuditManager.emit`), so the agent's live span + # is still visible here even though we're on the audit + # worker thread — and the governance span attaches to it + # as a child instead of becoming an orphan root. + # + # We don't touch org/tenant/folder/job/trace ids here — the + # uipath OTel exporter resolves those at export time from the + # process env (see module docstring). + ctx = context.get_current() + + with tracer.start_as_current_span(span_name, context=ctx) as span: + # Required for Orchestrator Traces + span.set_attribute("type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("uipath.custom_instrumentation", True) + + # Identifies which agent emitted this audit trace. Lets + # downstream consumers (Orchestrator Traces UI, audit + # dashboards) filter governance spans by producer when + # multiple SDKs / governance backends co-exist. + span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) + # Hook summary attributes. Mode comes from the event — + # each emitter stamps its own per-instance mode, so the + # sink is correct for parallel runtimes running + # different modes. + mode = _resolve_mode(event) + final_action = data.get("final_action", "allow") + _, action_applied = _derive_results( + matched=final_action.lower() != "allow", + configured_action=final_action, + mode=mode, + ) + span.set_attribute(f"{NS}.hook", hook) + span.set_attribute(f"{NS}.action_applied", action_applied) + span.set_attribute(f"{NS}.mode", mode.value.upper()) + + # Hook spans are summary containers — they're left at + # Status.UNSET regardless of final_action. Severity is + # carried by the per-rule spans (see _emit_rule_span); + # marking the hook span as ERROR would falsely paint + # the entire lifecycle phase as failed when only a + # specific rule fired underneath. + + self._spans_created += 1 + + except Exception as e: + logger.warning("Failed to create governance hook span: %s", e) + + def _emit_rule_span(self, event: AuditEvent) -> None: + """Create a span for a rule evaluation.""" + tracer = self._get_tracer() + if tracer is None: + return + + try: + from opentelemetry import context + + data = event.data + policy_id = data.get("policy_id", "unknown") + span_name = f"{NS}.rule.{policy_id}" + + # See note in _emit_hook_span: the audit manager runs the + # sink inside the caller's captured contextvars context, + # so the current OTel context here is the agent's live + # span. The uipath OTel exporter populates the + # job-execution context at export time. + ctx = context.get_current() + + with tracer.start_as_current_span(span_name, context=ctx) as span: + # Required for Orchestrator Traces + span.set_attribute("type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("uipath.custom_instrumentation", True) + + # Identifies which agent emitted this audit trace. Lets + # downstream consumers (Orchestrator Traces UI, audit + # dashboards) filter governance spans by producer when + # multiple SDKs / governance backends co-exist. + span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) + + # Derive the spec-vocabulary verdict pair from the raw + # (matched, configured action, mode) tuple. Mode comes + # from the event (each emitter's per-instance value) 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. + mode = _resolve_mode(event) + configured_action = data.get("action", "allow") + matched = bool(data.get("matched", False)) + evaluator_result, action_applied = _derive_results( + matched=matched, + configured_action=configured_action, + mode=mode, + ) + + # Governance attributes + span.set_attribute(f"{NS}.policy_id", policy_id) + span.set_attribute(f"{NS}.rule_name", data.get("rule_name", "")) + span.set_attribute(f"{NS}.pack_name", data.get("pack_name", "")) + span.set_attribute(f"{NS}.hook", event.hook) + span.set_attribute(f"{NS}.evaluator_result", evaluator_result) + span.set_attribute(f"{NS}.action_applied", action_applied) + span.set_attribute(f"{NS}.mode", mode.value.upper()) + span.set_attribute(f"{NS}.version", SCHEMA_VERSION) + + detail = data.get("detail", "") + if detail: + span.set_attribute(f"{NS}.evidence", detail[:500]) + + # Severity is driven off the derived ``action_applied``: + # + # - ``DENY`` — runtime actually blocked the agent → + # verbosityLevel=4 (Error) + Status.ERROR. The agent + # span genuinely failed. + # - ``AUDIT`` / ``HITL`` — advisory only; runtime did NOT + # block → verbosityLevel=3 (Warning), Status stays + # UNSET. The agent's span shouldn't be marked failed + # just because an advisory rule fired. + # - ``ALLOW`` / ``NONE`` — no verbosityLevel attribute + # (Orchestrator default = 2, Information). + if action_applied == ACTION_DENY: + span.set_attribute("verbosityLevel", 4) + try: + from opentelemetry.trace import Status, StatusCode + + span.set_status( + Status( + StatusCode.ERROR, + f"Policy violation: " + f"{data.get('rule_name', policy_id)} " + f"(action={configured_action.lower()})", + ) + ) + except ImportError: + pass + elif action_applied in (ACTION_AUDIT, ACTION_HITL): + span.set_attribute("verbosityLevel", 3) + + self._spans_created += 1 + + except Exception as e: + logger.warning("Failed to create governance span: %s", e) + + @property + def spans_created(self) -> int: + """Number of spans created.""" + return self._spans_created diff --git a/src/uipath/runtime/governance/native/models.py b/src/uipath/runtime/governance/native/models.py index 125e75e0..4f2885c4 100644 --- a/src/uipath/runtime/governance/native/models.py +++ b/src/uipath/runtime/governance/native/models.py @@ -74,12 +74,17 @@ class Rule: @dataclass class CheckContext: - """Context passed to rule evaluation.""" + """Context passed to rule evaluation. + + Trace correlation is intentionally absent — the wire-side provider + resolves the canonical agent trace id at HTTP-call time, and + OTel-backed sinks propagate the live span via ``contextvars``. The + evaluation context doesn't carry one. + """ hook: LifecycleHook agent_name: str runtime_id: str - trace_id: str # Content fields (populated based on hook) agent_input: str = "" diff --git a/tests/test_audit_manager_lifecycle.py b/tests/test_audit_manager_lifecycle.py new file mode 100644 index 00000000..905891e4 --- /dev/null +++ b/tests/test_audit_manager_lifecycle.py @@ -0,0 +1,311 @@ +"""Lifecycle tests for :class:`AuditManager`. + +Pins the production-readiness invariants of the audit manager: + +- Process cleanup uses a single ``atexit`` handler that walks a + ``WeakSet`` — so creating many managers in one process doesn't + bloat the atexit list and doesn't pin managers in memory. +- The fork-rebuild path is lock-protected: two threads in a + freshly-forked child can't both rebuild the queue/worker + concurrently. +""" + +from __future__ import annotations + +import gc +import os +import threading +from typing import Any +from unittest.mock import patch + +import pytest + +from uipath.runtime.governance._audit import base as audit_base +from uipath.runtime.governance._audit.base import AuditManager + + +def _bare_manager() -> AuditManager: + """Build a manager with no default sinks (no traces sink, no atexit-set add).""" + return AuditManager(async_mode=False, register_default_sinks=False) + + +# --------------------------------------------------------------------------- +# atexit accounting: one process-level hook, no per-instance accumulation +# --------------------------------------------------------------------------- + + +def test_default_managers_register_once_in_process_atexit() -> None: + """Creating N managers must NOT add N entries to interpreter atexit. + + Regression: per-instance ``atexit.register(self._atexit_cleanup)`` + grew the atexit list linearly and held a strong ref to each manager. + The fix routes everyone through one process-level cleanup hook. + """ + with patch.object(audit_base.atexit, "register") as mock_register: + # Reset module state so the assertion is deterministic + # regardless of test-order side effects. + audit_base._cleanup_registry.atexit_registered = False + try: + AuditManager(async_mode=False) # first → registers + AuditManager(async_mode=False) # second → reuses + AuditManager(async_mode=False) # third → reuses + assert mock_register.call_count == 1, ( + "Each AuditManager must NOT register its own atexit handler" + ) + finally: + # Drop test managers from the cleanup set before leaving. + audit_base._cleanup_registry.live_managers.clear() + + +def test_register_default_sinks_false_skips_cleanup_set() -> None: + """Bare managers (tests) are not tracked for process cleanup.""" + m = _bare_manager() + assert m not in audit_base._cleanup_registry.live_managers + + +def test_disposed_manager_can_be_garbage_collected() -> None: + """The WeakSet must NOT keep a disposed manager alive. + + Regression: per-instance atexit held a strong ref → disposed + managers leaked until process exit. With ``WeakSet`` + a single + process hook, dropping the last reference lets the manager GC. + """ + import weakref + + manager = AuditManager(async_mode=False) + ref = weakref.ref(manager) + + # Sanity: it's tracked while alive. + assert manager in audit_base._cleanup_registry.live_managers + + # Drop the local strong ref + force collection. + del manager + gc.collect() + + # The WeakSet entry must be gone (or about to be). + assert ref() is None, ( + "AuditManager was kept alive — strong reference leak in cleanup machinery" + ) + + +def test_process_cleanup_handles_already_closed_manager() -> None: + """If a manager was explicitly closed, the process hook is a no-op for it. + + A manager that called close() during normal lifecycle should not + raise from the process-level cleanup — sink list is empty, worker + is already joined. + """ + m = AuditManager(async_mode=False) + m.close() + # Must not raise. + audit_base._cleanup_registry.process_cleanup() + + +# --------------------------------------------------------------------------- +# Fork-rebuild safety +# --------------------------------------------------------------------------- + + +def test_ensure_alive_after_fork_is_idempotent_under_concurrent_emit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two threads in a fresh-fork child must not both rebuild the queue. + + Without the lock, both threads observed the stale ``_pid``, both + constructed a new ``queue.Queue`` / ``threading.Event`` / + ``threading.Thread``, and the later writer leaked the earlier + one's queue+worker. With the lock the loser sees the updated + ``_pid`` after acquiring and returns. + """ + m = AuditManager(async_mode=True, register_default_sinks=False) + + # Capture the post-construction queue + worker so we can detect + # whether multiple rebuild winners occurred. + original_queue = m._queue + original_worker = m._worker_thread + + # Simulate a fork by mutating the recorded pid. We do NOT actually + # fork; we just put the manager into "I think I'm in a stale + # process" state. + m._pid = -1 + + barrier = threading.Barrier(8) + seen_queues: set[int] = set() + seen_workers: set[int] = set() + lock = threading.Lock() + + def worker() -> None: + barrier.wait() + m._ensure_alive_after_fork() + with lock: + seen_queues.add(id(m._queue)) + seen_workers.add(id(m._worker_thread)) + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=5.0) + + # Exactly one queue + worker survived the race. + assert len(seen_queues) == 1, ( + f"Multiple queues survived fork-rebuild race: {seen_queues}" + ) + assert len(seen_workers) == 1, ( + f"Multiple workers survived fork-rebuild race: {seen_workers}" + ) + # And the survivor is NOT the original (we did rebuild). + assert original_queue is not m._queue + assert original_worker is not m._worker_thread + assert m._pid == os.getpid() + + m.close() + + +def test_ensure_alive_after_fork_fast_path_when_pid_unchanged() -> None: + """Same-process call must NOT rebuild — sanity check on the fast path.""" + m = AuditManager(async_mode=True, register_default_sinks=False) + original_queue = m._queue + original_worker = m._worker_thread + + m._ensure_alive_after_fork() # same PID — no-op + + assert m._queue is original_queue + assert m._worker_thread is original_worker + m.close() + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# contextvars propagation — the worker sees the caller's OTel span +# --------------------------------------------------------------------------- + + +def test_async_emit_propagates_caller_contextvars_to_worker_thread() -> None: + """Async emit captures ``contextvars.copy_context()`` at enqueue. + + Worker threads do not inherit ``contextvars``, so without this + capture the audit worker would see an empty OTel context and + OTel-backed sinks would render governance spans as orphan roots. + The fix: queue items are ``(Context, AuditEvent)`` tuples; the + worker runs ``ctx.run(_emit_sync, event)`` so sink dispatch + happens inside the caller's snapshot. + + This test puts a real OTel span on the caller thread, fires the + event through ``async_mode=True``, and asserts the sink — running + on the audit worker — sees the same span via + ``trace.get_current_span()``. + """ + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + + from uipath.runtime.governance._audit.base import AuditEvent, AuditSink + + captured: dict[str, Any] = {} + done = threading.Event() + + class _Probe(AuditSink): + @property + def name(self) -> str: + return "probe" + + def emit(self, event: AuditEvent) -> None: + # Capture the OTel SpanContext the worker sees so the + # assertions below can compare it to the caller's live + # span. Naming the dict keys ``otel_*`` keeps them + # distinct from any application-level trace id — the + # ``AuditEvent`` itself carries none. + sc = trace.get_current_span().get_span_context() + captured["worker_thread"] = threading.current_thread().name + captured["otel_trace_id"] = sc.trace_id if sc.is_valid else None + captured["otel_span_id"] = sc.span_id if sc.is_valid else None + done.set() + + tracer = TracerProvider().get_tracer("test") + m = AuditManager(async_mode=True, register_default_sinks=False) + m.register_sink(_Probe()) + + try: + with tracer.start_as_current_span("agent-run") as span: + expected = span.get_span_context() + m.emit(AuditEvent(event_type="rule_evaluation")) + + assert done.wait(timeout=2.0), "audit worker never processed the event" + + # Worker really did run on the audit-manager thread. + assert captured["worker_thread"].startswith("governance-audit-worker") + # And the captured contextvars snapshot propagated the OTel span. + assert captured["otel_trace_id"] == expected.trace_id + assert captured["otel_span_id"] == expected.span_id + finally: + m.close() + + +def test_async_emit_drops_oldest_under_pressure_preserves_context() -> None: + """Even when the queue overflows and the oldest item is dropped, the + surviving item carries its own captured context. + + Regression guard: the drop-oldest branch re-puts the new tuple, + not a bare event. Forgetting to wrap there would silently send + items without context propagation. + """ + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + + from uipath.runtime.governance._audit.base import AuditEvent, AuditSink + + seen: list[int | None] = [] + done = threading.Event() + + class _Probe(AuditSink): + @property + def name(self) -> str: + return "probe" + + def emit(self, event: AuditEvent) -> None: + sc = trace.get_current_span().get_span_context() + seen.append(sc.trace_id if sc.is_valid else None) + if len(seen) >= 1: + done.set() + + tracer = TracerProvider().get_tracer("test") + # ``queue_maxsize=1`` forces the overflow path on the second put. + m = AuditManager( + async_mode=True, register_default_sinks=False, queue_maxsize=1 + ) + m.register_sink(_Probe()) + + try: + with tracer.start_as_current_span("first") as s1: + first_id = s1.get_span_context().trace_id + m.emit(AuditEvent(event_type="rule_evaluation")) + with tracer.start_as_current_span("second") as s2: + second_id = s2.get_span_context().trace_id + m.emit(AuditEvent(event_type="rule_evaluation")) + + assert done.wait(timeout=2.0) + # Whichever item won (the surviving one), it must carry its + # own captured context — not an empty one. + assert seen[0] in (first_id, second_id), ( + f"surviving item lost context propagation; got {seen[0]!r}" + ) + finally: + m.close() + + +@pytest.fixture(autouse=True) +def _clean_module_state() -> Any: + """Test isolation for the module-level cleanup machinery. + + Sweep the WeakSet between tests so leftovers from one test don't + show up in another. Don't reset ``_cleanup_registry.atexit_registered`` — once + Python's ``atexit`` accepts a handler, we shouldn't unregister it + just for tests, and the tests above that check registration count + do their own reset under a patched ``atexit.register``. + """ + yield + audit_base._cleanup_registry.live_managers.clear() diff --git a/tests/test_audit_register_sink.py b/tests/test_audit_register_sink.py new file mode 100644 index 00000000..19c99969 --- /dev/null +++ b/tests/test_audit_register_sink.py @@ -0,0 +1,108 @@ +"""Tests for ``AuditManager.register_sink`` failure-counter semantics. + +A re-registered same-name sink must NOT inherit the previous instance's +tripped circuit-breaker state. ``unregister_sink`` already clears these +counters, but ``register_sink`` also clears them on a successful add as +defense-in-depth (covers tests / external callers that touch the +internal counter dicts directly). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from uipath.runtime.governance._audit.base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, +) + + +class _NoopSink(AuditSink): + """Sink that records emit calls and never raises.""" + + def __init__(self, name: str = "test-sink") -> None: + self._name = name + self.events: list[AuditEvent] = [] + + @property + def name(self) -> str: + return self._name + + def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +def _event() -> AuditEvent: + return AuditEvent(event_type=EventType.RULE_EVALUATION, agent_name="a") + + +@pytest.fixture +def manager() -> Any: + """Build a fresh, sync-mode AuditManager with no default sinks. + + ``register_default_sinks=False`` keeps the traces sink (and the + per-instance atexit hook) out of the test, so assertions about + registered sinks see only what the test puts there. + """ + return AuditManager(async_mode=False, register_default_sinks=False) + + +def test_register_clears_stale_failure_counter(manager: AuditManager) -> None: + """A new sink with a name that previously tripped starts fresh.""" + # Simulate prior instance having tripped the circuit-breaker without + # going through unregister (e.g. test code or external code that + # mutated the counters directly). + manager._sink_failures["test-sink"] = manager._SINK_FAILURE_THRESHOLD + manager._tripped_sinks.add("test-sink") + + new_sink = _NoopSink(name="test-sink") + manager.register_sink(new_sink) + + # Counter and tripped-set must be cleared. + assert manager._sink_failures.get("test-sink", 0) == 0 + assert "test-sink" not in manager._tripped_sinks + + # And the new sink actually receives events (would be skipped if + # still considered tripped). + manager.emit(_event()) + assert len(new_sink.events) == 1 + + +def test_register_does_not_clear_for_duplicate(manager: AuditManager) -> None: + """Re-registering an already-present sink is a no-op (no counter reset).""" + sink = _NoopSink(name="test-sink") + manager.register_sink(sink) + + # Simulate the existing sink having accumulated some failures. + manager._sink_failures["test-sink"] = 3 + + # A second register call with the same name should NOT clear those + # failures — the duplicate-check fires before the reset. + duplicate = _NoopSink(name="test-sink") + manager.register_sink(duplicate) + + assert manager._sink_failures["test-sink"] == 3 + + +def test_unregister_then_register_starts_fresh(manager: AuditManager) -> None: + """The full lifecycle: register → trip → unregister → register again.""" + sink = _NoopSink(name="test-sink") + manager.register_sink(sink) + manager._sink_failures["test-sink"] = manager._SINK_FAILURE_THRESHOLD + manager._tripped_sinks.add("test-sink") + + manager.unregister_sink("test-sink") + # Unregister already clears. + assert "test-sink" not in manager._tripped_sinks + + new_sink = _NoopSink(name="test-sink") + manager.register_sink(new_sink) + assert manager._sink_failures.get("test-sink", 0) == 0 + assert "test-sink" not in manager._tripped_sinks + + manager.emit(_event()) + assert len(new_sink.events) == 1 diff --git a/tests/test_traces_severity.py b/tests/test_traces_severity.py new file mode 100644 index 00000000..30fd3565 --- /dev/null +++ b/tests/test_traces_severity.py @@ -0,0 +1,271 @@ +"""Tests for trace-span verbosity / status semantics. + +``TracesAuditSink`` emits an OpenTelemetry span for every governance +hook end and every rule evaluation. The contract follows §4 of the +cross-product unification doc — verdict is split into ``evaluator_result`` +(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 emitter from its +per-instance ``EnforcementMode``) 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 + the agent (ENFORCE mode + configured action ``deny``). +- ``verbosityLevel = 3`` (Warning) and ``Status.UNSET`` for advisory + outcomes (``action_applied`` in ``{AUDIT, HITL}``). HITL is its own + spec bucket — escalation pauses for human review, it doesn't fail + the run, so it stays Warning even in ENFORCE mode. +- Hook spans never set Status, regardless of mode or final_action. + They're summary containers; severity belongs on the per-rule span. +- ``ALLOW`` / ``NONE`` results leave verbosityLevel unset (Orchestrator + default = 2, Information) and never call set_status. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from uipath.core.governance import EnforcementMode + +from uipath.runtime.governance._audit.base import AuditEvent, EventType +from uipath.runtime.governance._audit.traces import TracesAuditSink + + +@pytest.fixture +def captured_span(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Wire ``TracesAuditSink`` to a mock tracer and return the span mock.""" + span = MagicMock(name="span") + tracer = MagicMock(name="tracer") + tracer.start_as_current_span.return_value.__enter__.return_value = span + tracer.start_as_current_span.return_value.__exit__.return_value = False + monkeypatch.setattr(TracesAuditSink, "_get_tracer", lambda self: tracer) + return span + + +def _hook_event(final_action: str, mode: EnforcementMode) -> AuditEvent: + return AuditEvent( + event_type=EventType.HOOK_END, + agent_name="agent", + hook="after_model", + data={ + "total_rules": 1, + "matched_rules": 1 if final_action != "allow" else 0, + "final_action": final_action, + "enforcement_mode": mode, + }, + ) + + +def _rule_event( + matched: bool, action: str, mode: EnforcementMode = EnforcementMode.AUDIT +) -> AuditEvent: + return AuditEvent( + event_type=EventType.RULE_EVALUATION, + agent_name="agent", + hook="after_model", + data={ + "policy_id": "A.10.4", + "rule_name": "commitment-language", + "pack_name": "iso42001", + "matched": matched, + "action": action, + "enforcement_mode": mode, + "status": "MATCHED" if matched else "PASS", + "detail": "Customer-binding commitment detected.", + }, + ) + + +def _span_attrs(span: MagicMock) -> dict[str, object]: + """Return a mapping of attribute name → value for set_attribute calls.""" + attrs: dict[str, object] = {} + for call in span.set_attribute.call_args_list: + key, value = call.args + attrs[key] = value + return attrs + + +# --------------------------------------------------------------------------- +# Hook span — never marked ERROR +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "final_action,mode", + [ + ("deny", EnforcementMode.ENFORCE), + ("deny", EnforcementMode.AUDIT), + ("audit", EnforcementMode.AUDIT), + ("escalate", EnforcementMode.AUDIT), + ("allow", EnforcementMode.AUDIT), + ], +) +def test_hook_span_never_sets_error( + captured_span: MagicMock, final_action: str, mode: EnforcementMode +) -> None: + """Hook spans are summary containers — they never carry an ERROR Status.""" + sink = TracesAuditSink() + sink.emit(_hook_event(final_action=final_action, mode=mode)) + assert not captured_span.set_status.called, ( + f"Hook span should never set_status; called with " + f"final_action={final_action!r}, mode={mode!r}" + ) + + +# --------------------------------------------------------------------------- +# Rule span — enforce-mode DENY is the only Status.ERROR case +# --------------------------------------------------------------------------- + + +def test_enforce_mode_deny_is_error(captured_span: MagicMock) -> None: + """Enforce mode + action=deny = real block → verbosityLevel=4 + Status.ERROR.""" + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert attrs.get("verbosityLevel") == 4 + assert attrs.get("uipath_governance.evaluator_result") == "DENY" + assert attrs.get("uipath_governance.action_applied") == "DENY" + assert attrs.get("uipath_governance.mode") == "ENFORCE" + + assert captured_span.set_status.called, ( + "Status.ERROR must fire for enforce-mode deny violation" + ) + (status_arg,) = captured_span.set_status.call_args.args + from opentelemetry.trace import Status, StatusCode + + assert isinstance(status_arg, Status) + assert status_arg.status_code is StatusCode.ERROR + assert "commitment-language" in status_arg.description + assert "deny" in status_arg.description + + +def test_enforce_mode_escalate_is_hitl_warning(captured_span: MagicMock) -> None: + """Enforce mode + action=escalate = HITL pause, not a block. + + HITL is its own spec bucket distinct from DENY — escalation pauses + for human review, the run isn't failed. So verbosityLevel stays at + Warning and Status is not marked ERROR. + """ + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="escalate", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert attrs.get("verbosityLevel") == 3 + assert attrs.get("uipath_governance.evaluator_result") == "HITL" + assert attrs.get("uipath_governance.action_applied") == "HITL" + assert attrs.get("uipath_governance.mode") == "ENFORCE" + assert not captured_span.set_status.called + + +# --------------------------------------------------------------------------- +# Rule span — advisory violations (audit mode, or audit-action rules) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "action,expected_evaluator", + [("deny", "DENY"), ("audit", "DENY"), ("escalate", "HITL")], +) +def test_audit_mode_violation_is_warning( + captured_span: MagicMock, action: str, expected_evaluator: str +) -> None: + """Audit mode never blocks → action_applied=AUDIT, verbosityLevel=3. + + Surfacing Status.ERROR for an audit-mode violation would falsely + mark the agent's run as failed when the runtime intentionally + let it through. evaluator_result still records the rule's actual + decision (DENY/HITL), independent of mode. + """ + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action=action, mode=EnforcementMode.AUDIT)) + + attrs = _span_attrs(captured_span) + assert attrs.get("verbosityLevel") == 3 + assert attrs.get("uipath_governance.evaluator_result") == expected_evaluator + assert attrs.get("uipath_governance.action_applied") == "AUDIT" + assert attrs.get("uipath_governance.mode") == "AUDIT" + + assert not captured_span.set_status.called, ( + f"Audit-mode {action} violation must NOT set Status.ERROR" + ) + + +def test_enforce_mode_audit_action_is_warning(captured_span: MagicMock) -> None: + """Enforce mode + action=audit is a per-rule audit override. + + The rule's configured ``audit`` action means "log this match but + don't block" even when the global mode is ENFORCE. evaluator_result + is DENY (the rule decided to deny), but action_applied is AUDIT + (the per-rule override kicks in), so verbosity stays Warning. + """ + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="audit", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert attrs.get("verbosityLevel") == 3 + assert attrs.get("uipath_governance.evaluator_result") == "DENY" + assert attrs.get("uipath_governance.action_applied") == "AUDIT" + assert attrs.get("uipath_governance.mode") == "ENFORCE" + assert not captured_span.set_status.called + + +# --------------------------------------------------------------------------- +# Rule span — no violation, no verbosityLevel attribute (Orchestrator default = 2) +# --------------------------------------------------------------------------- + + +def test_unmatched_rule_no_verbosity_no_error(captured_span: MagicMock) -> None: + """Unmatched evaluations → evaluator_result=ALLOW, action_applied=NONE, quiet.""" + sink = TracesAuditSink() + sink.emit(_rule_event(matched=False, action="deny", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert "verbosityLevel" not in attrs + assert attrs.get("uipath_governance.evaluator_result") == "ALLOW" + assert attrs.get("uipath_governance.action_applied") == "NONE" + assert not captured_span.set_status.called + + +def test_matched_allow_action_no_verbosity(captured_span: MagicMock) -> None: + """A rule whose action is 'allow' is an explicit non-violation.""" + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="allow", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert "verbosityLevel" not in attrs + assert attrs.get("uipath_governance.evaluator_result") == "ALLOW" + assert attrs.get("uipath_governance.action_applied") == "NONE" + assert not captured_span.set_status.called + + +# --------------------------------------------------------------------------- +# Cross-runtime isolation — the architectural motivation for the refactor +# --------------------------------------------------------------------------- + + +def test_two_events_carry_independent_modes(captured_span: MagicMock) -> None: + """Parallel runtimes (different modes) cannot cross-contaminate the sink. + + Mode travels on each event (set by the emitter from its own + per-instance ``EnforcementMode``), so two consecutive emits with + different modes each render their own correct + ``uipath_governance.mode`` value — no shared state in the sink + that one runtime could clobber for another. + """ + sink = TracesAuditSink() + + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.AUDIT)) + + # Collect every set_attribute call ordered by emit. + calls = [c.args for c in captured_span.set_attribute.call_args_list] + modes = [v for k, v in calls if k == "uipath_governance.mode"] + actions_applied = [v for k, v in calls if k == "uipath_governance.action_applied"] + assert modes == ["ENFORCE", "AUDIT"] + assert actions_applied == ["DENY", "AUDIT"] + + diff --git a/uv.lock b/uv.lock index e3a9a786..4f29f887 100644 --- a/uv.lock +++ b/uv.lock @@ -1191,7 +1191,7 @@ dev = [ requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "uipath-core", specifier = ">=0.5.21,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.22,<0.6.0" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] From c4e795893401d822a70fcc4ca94d6c807bd7ff72 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Mon, 29 Jun 2026 17:07:22 +0530 Subject: [PATCH 13/18] docs(governance): scope audit docstrings to runtime layer Address PR #122 review: docstrings and comments referenced external or higher-layer implementation details (uipath-core exporter, UIPATH_* env vars, UiPathSpan, uipath-agents-python SpanType, governance-server, Orchestrator UI, cross-product spec sections). Reworded to describe only what the runtime layer owns. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runtime/governance/_audit/__init__.py | 16 +-- src/uipath/runtime/governance/_audit/base.py | 13 +- .../runtime/governance/_audit/traces.py | 131 ++++++------------ .../runtime/governance/native/models.py | 7 +- 4 files changed, 60 insertions(+), 107 deletions(-) diff --git a/src/uipath/runtime/governance/_audit/__init__.py b/src/uipath/runtime/governance/_audit/__init__.py index b00769ce..ce109ef8 100644 --- a/src/uipath/runtime/governance/_audit/__init__.py +++ b/src/uipath/runtime/governance/_audit/__init__.py @@ -1,12 +1,12 @@ """Audit sink framework for governance events. -Internal module. Provides a pluggable audit system that emits governance -events to one or more sinks. The only built-in sink is ``TracesAuditSink``, -which creates OpenTelemetry spans that uipath-core's exporter ships to the -Orchestrator Traces UI. This sink is always registered by every -:class:`AuditManager` and cannot be disabled by application code — it -carries the governance audit trail. +Internal module. Provides a pluggable audit system that emits +governance events to one or more sinks. The built-in +:class:`TracesAuditSink` emits OpenTelemetry spans and is always +registered by every :class:`AuditManager` — it carries the governance +audit trail and cannot be disabled by application code. -Callers import from the submodules directly (``_audit.base``, ``_audit.traces``, -``_audit.factory``). This package exposes no aggregated symbols. +Callers import from the submodules directly (``_audit.base``, +``_audit.traces``, ``_audit.factory``). This package exposes no +aggregated symbols. """ diff --git a/src/uipath/runtime/governance/_audit/base.py b/src/uipath/runtime/governance/_audit/base.py index 91bcaf58..19fbc2ee 100644 --- a/src/uipath/runtime/governance/_audit/base.py +++ b/src/uipath/runtime/governance/_audit/base.py @@ -238,15 +238,15 @@ class AuditManager: manager. Parallel runtimes (``uipath eval``) don't share sinks, workers, or per-sink failure state. - Constructor automatically registers the always-on ``traces`` sink - (OpenTelemetry → Orchestrator audit UI). This sink writes the - governance audit trail and cannot be disabled by application code. - Additional sinks can be added via :meth:`register_sink`. + Constructor automatically registers the always-on ``traces`` sink, + which carries the governance audit trail and cannot be disabled by + application code. Additional sinks can be added via + :meth:`register_sink`. Thread Safety: Events are queued and processed by a background thread, making :meth:`emit` non-blocking. This avoids blocking agent execution - during audit trace HTTP calls. + while a sink is doing I/O. """ # Trip a sink after this many consecutive emit failures (circuit-breaker). @@ -314,8 +314,7 @@ def __init__( def _register_traces_sink(self) -> None: """Register the always-on ``traces`` sink. - The traces sink (OpenTelemetry spans to the Orchestrator audit - UI) is registered for every manager and cannot be disabled by + Registered for every manager and cannot be disabled by application code — it carries the governance audit trail. The factory import is deferred to avoid a module-load cycle (``factory`` imports back into this module). diff --git a/src/uipath/runtime/governance/_audit/traces.py b/src/uipath/runtime/governance/_audit/traces.py index abf1310f..735d297b 100644 --- a/src/uipath/runtime/governance/_audit/traces.py +++ b/src/uipath/runtime/governance/_audit/traces.py @@ -1,14 +1,11 @@ -"""OpenTelemetry traces audit sink for Orchestrator integration. - -This sink creates OpenTelemetry spans for governance events. UiPath's -OTel exporter (``uipath.tracing._otel_exporters.LlmOpsHttpExporter`` via -``_SpanUtils.otel_span_to_uipath_span``) is what ships them to the -Orchestrator Traces UI and is also what reads ``UIPATH_TRACE_ID``, -``UIPATH_ORGANIZATION_ID``, ``UIPATH_TENANT_ID``, ``UIPATH_FOLDER_KEY`` -and ``UIPATH_JOB_KEY`` from the process environment and stamps them onto -the outgoing ``UiPathSpan``. We intentionally do **not** duplicate that -env-reading here — the exporter is the single source of truth for the -job-execution context. +"""OpenTelemetry traces audit sink for governance events. + +Emits an OpenTelemetry span per rule evaluation and per hook summary. +This sink emits spans only — it does not resolve or stamp +job-execution metadata (organization, tenant, folder, job, trace id) +onto them. That resolution is owned by the platform-side OTel +exporter that ships spans downstream, so the runtime governance +contract stays scoped to span emission. """ from __future__ import annotations @@ -38,27 +35,23 @@ def _package_version() -> str: # package version doesn't change for the life of the process. SCHEMA_VERSION = _package_version() -# Value for the ``type`` / ``span_type`` span attributes on every -# governance span. Matches ``SpanType.AGENT_RUN`` in uipath-agents-python -# — we use the string literal here (not a cross-package import) to keep -# uipath-runtime free of a uipath-agents dependency. If the agents-side -# registry adds new values, this constant is the single place to update. +# Value of the ``type`` / ``span_type`` span attributes on every +# governance span. Local to the runtime trace contract — kept as a +# string literal (not a cross-package import) so the runtime stays +# self-contained. SPAN_TYPE_AGENT_RUN = "agentRun" -# Identifies this auditor on every governance span. Lets a downstream -# consumer distinguish traces emitted by the Python in-runtime governance -# checker from those produced by the governance-server (or any future -# language-specific governance SDK). Set as the ``source`` span -# attribute on every governance trace span. +# Set as the ``source`` attribute on every governance span. Lets +# consumers identify which producer emitted a given span when more +# than one governance producer feeds the same trace backend. GOVERNANCE_SOURCE = "governance-checker-python" -# Shared attribute namespace for every key in the unified governance trace -# contract (§4 of the cross-product unification doc). Concatenated into -# each ``span.set_attribute`` call so the prefix appears in one place and -# a future rename (or alias) is a one-line change. +# Shared attribute namespace for every governance span attribute. +# Concatenated into each ``span.set_attribute`` call so the prefix +# appears in one place and a future rename is a one-line change. NS = "uipath_governance" -# Unified-contract enum values (UPPER_SNAKE per §3 of the spec). +# Governance verdict / action vocabulary (UPPER_SNAKE). EVALUATOR_ALLOW = "ALLOW" EVALUATOR_DENY = "DENY" EVALUATOR_HITL = "HITL" @@ -132,11 +125,7 @@ def _derive_results( return evaluator, ACTION_AUDIT class TracesAuditSink(AuditSink): - """Audit sink that creates OpenTelemetry spans. - - Spans appear in UiPath Orchestrator Traces UI, providing structured - data for each governance evaluation. - """ + """Audit sink that emits an OpenTelemetry span per governance event.""" def __init__(self) -> None: """Initialize the sink with a deferred tracer and zero span count.""" @@ -157,12 +146,8 @@ def _get_tracer(self) -> Any: self._tracer = trace.get_tracer("uipath.governance") logger.info("OpenTelemetry tracer initialized for governance traces") except ImportError: - # OpenTelemetry is supplied transitively by uipath-core; an - # ImportError here means the host install is broken or - # governance is running outside the UiPath SDK environment. logger.warning( - "OpenTelemetry not available - governance traces disabled. " - "OTel is normally provided by uipath-core; reinstall the SDK." + "OpenTelemetry not available — governance traces disabled." ) self._tracer = False return self._tracer if self._tracer else None @@ -190,30 +175,18 @@ def _emit_hook_span(self, event: AuditEvent) -> None: # Use the current OTel context. The audit manager runs the # sink inside the caller's captured ``contextvars`` context # (see :meth:`AuditManager.emit`), so the agent's live span - # is still visible here even though we're on the audit - # worker thread — and the governance span attaches to it - # as a child instead of becoming an orphan root. - # - # We don't touch org/tenant/folder/job/trace ids here — the - # uipath OTel exporter resolves those at export time from the - # process env (see module docstring). + # is visible here even on the audit worker thread — the + # governance span attaches as a child instead of orphan root. ctx = context.get_current() with tracer.start_as_current_span(span_name, context=ctx) as span: - # Required for Orchestrator Traces span.set_attribute("type", SPAN_TYPE_AGENT_RUN) span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) span.set_attribute("uipath.custom_instrumentation", True) - - # Identifies which agent emitted this audit trace. Lets - # downstream consumers (Orchestrator Traces UI, audit - # dashboards) filter governance spans by producer when - # multiple SDKs / governance backends co-exist. span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) - # Hook summary attributes. Mode comes from the event — - # each emitter stamps its own per-instance mode, so the - # sink is correct for parallel runtimes running - # different modes. + + # Mode travels on the event so parallel runtimes running + # different per-instance modes don't cross-contaminate. mode = _resolve_mode(event) final_action = data.get("final_action", "allow") _, action_applied = _derive_results( @@ -225,12 +198,10 @@ def _emit_hook_span(self, event: AuditEvent) -> None: span.set_attribute(f"{NS}.action_applied", action_applied) span.set_attribute(f"{NS}.mode", mode.value.upper()) - # Hook spans are summary containers — they're left at - # Status.UNSET regardless of final_action. Severity is - # carried by the per-rule spans (see _emit_rule_span); - # marking the hook span as ERROR would falsely paint - # the entire lifecycle phase as failed when only a - # specific rule fired underneath. + # Hook spans are summary containers — severity lives on + # the per-rule spans. Marking the hook ERROR would paint + # the whole lifecycle phase as failed when only one rule + # fired beneath it. self._spans_created += 1 @@ -250,32 +221,21 @@ def _emit_rule_span(self, event: AuditEvent) -> None: policy_id = data.get("policy_id", "unknown") span_name = f"{NS}.rule.{policy_id}" - # See note in _emit_hook_span: the audit manager runs the - # sink inside the caller's captured contextvars context, - # so the current OTel context here is the agent's live - # span. The uipath OTel exporter populates the - # job-execution context at export time. + # See _emit_hook_span: the contextvars-captured caller + # context means the current OTel context is the agent's + # live span, so this rule span attaches as its child. ctx = context.get_current() with tracer.start_as_current_span(span_name, context=ctx) as span: - # Required for Orchestrator Traces span.set_attribute("type", SPAN_TYPE_AGENT_RUN) span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) span.set_attribute("uipath.custom_instrumentation", True) - - # Identifies which agent emitted this audit trace. Lets - # downstream consumers (Orchestrator Traces UI, audit - # dashboards) filter governance spans by producer when - # multiple SDKs / governance backends co-exist. span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) - # Derive the spec-vocabulary verdict pair from the raw - # (matched, configured action, mode) tuple. Mode comes - # from the event (each emitter's per-instance value) 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. + # Single source of truth for the emitted attributes + # below AND the verbosityLevel / Status decision further + # down. Mode comes from the event (per-instance) so + # parallel runtimes don't cross-contaminate. mode = _resolve_mode(event) configured_action = data.get("action", "allow") matched = bool(data.get("matched", False)) @@ -285,7 +245,6 @@ def _emit_rule_span(self, event: AuditEvent) -> None: mode=mode, ) - # Governance attributes span.set_attribute(f"{NS}.policy_id", policy_id) span.set_attribute(f"{NS}.rule_name", data.get("rule_name", "")) span.set_attribute(f"{NS}.pack_name", data.get("pack_name", "")) @@ -300,16 +259,12 @@ def _emit_rule_span(self, event: AuditEvent) -> None: span.set_attribute(f"{NS}.evidence", detail[:500]) # Severity is driven off the derived ``action_applied``: - # - # - ``DENY`` — runtime actually blocked the agent → - # verbosityLevel=4 (Error) + Status.ERROR. The agent - # span genuinely failed. - # - ``AUDIT`` / ``HITL`` — advisory only; runtime did NOT - # block → verbosityLevel=3 (Warning), Status stays - # UNSET. The agent's span shouldn't be marked failed - # just because an advisory rule fired. - # - ``ALLOW`` / ``NONE`` — no verbosityLevel attribute - # (Orchestrator default = 2, Information). + # - DENY — runtime blocked → verbosityLevel=4 + + # Status.ERROR (agent span genuinely failed). + # - AUDIT / HITL — advisory, runtime did not block → + # verbosityLevel=3, Status stays UNSET. Marking the + # agent span failed for an advisory rule would mislead. + # - ALLOW / NONE — no verbosityLevel attribute set. if action_applied == ACTION_DENY: span.set_attribute("verbosityLevel", 4) try: diff --git a/src/uipath/runtime/governance/native/models.py b/src/uipath/runtime/governance/native/models.py index 4f2885c4..eb874a76 100644 --- a/src/uipath/runtime/governance/native/models.py +++ b/src/uipath/runtime/governance/native/models.py @@ -76,10 +76,9 @@ class Rule: class CheckContext: """Context passed to rule evaluation. - Trace correlation is intentionally absent — the wire-side provider - resolves the canonical agent trace id at HTTP-call time, and - OTel-backed sinks propagate the live span via ``contextvars``. The - evaluation context doesn't carry one. + Scoped to evaluator input data only. Trace correlation is + intentionally not carried here — that concern is owned by the + provider / platform layer, not by the evaluator input model. """ hook: LifecycleHook From 85f0e93f3ec5f0eab5103d2f0c101bd34e6fbc23 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Mon, 29 Jun 2026 18:20:24 +0530 Subject: [PATCH 14/18] refactor(governance): drop async-mode dispatch from AuditManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the audit pipeline to synchronous sink dispatch on the caller's thread. Sinks that need to keep I/O off the hot path own their own batching — the OTel traces sink already rides on `opentelemetry-sdk`'s `BatchSpanProcessor`, which handles export off-thread. Removed from `AuditManager`: - `async_mode` / `queue_maxsize` constructor params - the background worker thread, queue, shutdown event - `_start_worker` / `_worker_loop` / `_drain_queue` / `_emit_sync` - per-instance pid tracking and the fork-rebuild path - `_AuditManagerCleanupRegistry` + the module-level singleton - the `contextvars.copy_context()` snapshot used to thread OTel context across the worker hop (no longer needed — caller's thread already carries the live span) - `flush(timeout=...)` queue-drain wait Result: `emit()` is a lock-protected snapshot + per-sink try/except with the existing 10-failure circuit breaker. `flush()` and `close()` are thin per-sink loops; `close()` is idempotent. `TracesAuditSink` drops the explicit `context=context.get_current()` arg to `tracer.start_as_current_span(...)` — sync dispatch means the current OTel context is already the agent's live span, so governance spans attach as children without cross-thread plumbing. `AuditSink` docstring example rewritten to demonstrate the recommended pattern (enqueue in `emit`, drain on a sink-owned daemon thread) instead of synchronous `requests.post`, which contradicted the new class-level guidance. Tests updated to match: dropped atexit / WeakSet-GC / fork-rebuild / contextvars-propagation cases; added side-effect-free-construction, default-sink registration, bare-construction, close-clears-state, and close-idempotence cases. Net diff: -606 / +249 LOC. No external consumers — grep across `uipath-runtime-python`, `uipath-python`, and `uipath-langchain-python` for `AuditManager(`, `async_mode`, `queue_maxsize`, `_cleanup_registry`, `.flush(timeout` returns zero hits outside the test files updated in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uipath/runtime/governance/_audit/base.py | 466 +++++------------- .../runtime/governance/_audit/traces.py | 25 +- tests/test_audit_manager_lifecycle.py | 354 +++++-------- tests/test_audit_register_sink.py | 10 +- 4 files changed, 249 insertions(+), 606 deletions(-) diff --git a/src/uipath/runtime/governance/_audit/base.py b/src/uipath/runtime/governance/_audit/base.py index 19fbc2ee..a8a47b29 100644 --- a/src/uipath/runtime/governance/_audit/base.py +++ b/src/uipath/runtime/governance/_audit/base.py @@ -6,20 +6,17 @@ - AuditSink: Abstract base class for sink implementations - AuditManager: Central hub for routing events to sinks -The AuditManager uses a background thread to process events asynchronously, -avoiding blocking the main agent execution path during audit trace HTTP calls. +Sink dispatch is synchronous on the caller's thread. Sinks that need +async export (HTTP, batched I/O) own that concern internally — the +OTel traces sink rides on opentelemetry-sdk's BatchSpanProcessor, +which handles export off the caller's thread. """ from __future__ import annotations -import atexit -import contextvars import json import logging -import os -import queue import threading -import weakref from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, field from datetime import datetime, timezone @@ -30,68 +27,6 @@ logger = logging.getLogger(__name__) -class _AuditManagerCleanupRegistry: - """Process-wide cleanup machinery for :class:`AuditManager` instances. - - A single ``atexit`` hook walks a ``WeakSet`` of live managers on - exit and flushes/closes each one. Two important properties: - - 1. **Bounded atexit registrations.** Per-instance ``atexit.register`` - grows the interpreter's atexit list without bound — N runtimes - → N hooks → N × shutdown-timeout total exit delay. One - process-level hook is constant work regardless of how many - managers were constructed. - - 2. **No strong reference to the manager.** ``WeakSet`` lets a - disposed manager get garbage-collected; if it's already gone by - exit time, we just skip it. Long-running ``uipath eval`` runs - that build many runtimes serially can therefore release each - one's memory as soon as nothing references it, instead of - pinning all of them until process exit. - - Encapsulated in a class (rather than three loose module-level - names + a ``global`` mutation) so the state is named, swappable in - tests, and the registration path doesn't reach across the module - scope to assign. - """ - - def __init__(self) -> None: - self.live_managers: weakref.WeakSet[AuditManager] = weakref.WeakSet() - self.atexit_registered = False - self.lock = threading.Lock() - - def register(self, manager: AuditManager) -> None: - """Add ``manager`` to the cleanup set + wire process atexit once. - - Double-checked under ``lock`` so two concurrent first-time - constructions don't both register the process atexit handler. - """ - self.live_managers.add(manager) - if self.atexit_registered: - return - with self.lock: - if not self.atexit_registered: - atexit.register(self.process_cleanup) - self.atexit_registered = True - - def process_cleanup(self) -> None: - """Process-exit handler: flush + close every live AuditManager. - - Iteration over a snapshot — the WeakSet may mutate during - cleanup (close() touches sinks_lock, GC may fire). Bounded by - each manager's own flush / close timeouts. - """ - for manager in list(self.live_managers): - try: - manager.flush(timeout=2.0) - manager.close() - except Exception as exc: # noqa: BLE001 - exit cleanup must not raise - logger.debug("Audit manager process cleanup error: %s", exc) - - -_cleanup_registry = _AuditManagerCleanupRegistry() - - # ============================================================================= # Audit Event Model # ============================================================================= @@ -103,11 +38,11 @@ class AuditEvent: Trace correlation is intentionally absent from this dataclass. Sinks that need a trace id resolve one at their own boundary: - OTel-backed sinks let the SDK / exporter handle it (the audit - manager runs sink dispatch inside the caller's captured - contextvars snapshot, so the live OTel span is visible on the - worker), and HTTP sinks defer to their injected provider, which - resolves at HTTP-call time. + OTel-backed sinks read the live span from the caller's + ``contextvars`` directly (sink dispatch runs synchronously on the + caller's thread, so ``trace.get_current_span()`` resolves to the + agent's live span), and HTTP sinks defer to their injected + provider, which resolves at HTTP-call time. Attributes: event_type: Type of event (e.g., "rule_evaluation", "hook_summary") @@ -160,23 +95,61 @@ class AuditSink(ABC): Subclass this to create custom audit sinks. Each sink receives all audit events and decides how to handle them. + Sinks that perform network I/O should batch internally — :meth:`emit` + runs on the caller's thread (typically an agent hook), so a slow + synchronous sink blocks the agent. The standard pattern is the one + opentelemetry-sdk uses for its trace exporter: enqueue in-process, + drain on a sink-owned background thread. + Example: + A Slack sink that posts on rule denials. ``emit`` enqueues onto + an in-process queue; a daemon thread the sink owns drains the + queue and runs the HTTP POST off the caller's thread. + class SlackAuditSink(AuditSink): def __init__(self, webhook_url: str): self.webhook_url = webhook_url self._name = "slack" + self._queue: queue.Queue[AuditEvent | None] = queue.Queue() + self._worker = threading.Thread( + target=self._drain, name="slack-audit", daemon=True + ) + self._worker.start() @property def name(self) -> str: return self._name + def accepts(self, event: AuditEvent) -> bool: + # Only ship denials — drops irrelevant events at the + # boundary instead of forwarding them to the queue. + return ( + event.data.get("matched") + and event.data.get("action") == "deny" + ) + def emit(self, event: AuditEvent) -> None: - if event.data.get("matched") and event.data.get("action") == "deny": - # Send to Slack on violations - requests.post(self.webhook_url, json=event.to_dict()) + # Non-blocking — runs on the caller's hook thread. + self._queue.put_nowait(event) + + def _drain(self) -> None: + while True: + event = self._queue.get() + if event is None: + return # close() sentinel + try: + requests.post(self.webhook_url, json=event.to_dict()) + except Exception: + pass # log/retry per sink's own policy + finally: + self._queue.task_done() def flush(self) -> None: - pass + self._queue.join() + + def close(self) -> None: + self._queue.put_nowait(None) + self._worker.join(timeout=2.0) """ @property @@ -235,8 +208,8 @@ class AuditManager: """Manages multiple audit sinks and routes events to them. Instance-scoped: each :class:`GovernanceRuntime` owns its own - manager. Parallel runtimes (``uipath eval``) don't share sinks, - workers, or per-sink failure state. + manager. Parallel runtimes (``uipath eval``) don't share sinks or + per-sink failure state. Constructor automatically registers the always-on ``traces`` sink, which carries the governance audit trail and cannot be disabled by @@ -244,72 +217,35 @@ class AuditManager: :meth:`register_sink`. Thread Safety: - Events are queued and processed by a background thread, making - :meth:`emit` non-blocking. This avoids blocking agent execution - while a sink is doing I/O. + :meth:`emit` dispatches synchronously on the caller's thread. + Sinks that need to avoid blocking the caller (HTTP exporters) + own their own batching — the OTel traces sink, for example, + rides on opentelemetry-sdk's BatchSpanProcessor. """ # Trip a sink after this many consecutive emit failures (circuit-breaker). _SINK_FAILURE_THRESHOLD = 10 - # Bound the async queue so a stuck sink can't grow memory without limit. - # Matches the order of magnitude of a long-running agent's per-session - # audit volume; on overflow the oldest event is dropped to make room. - _DEFAULT_QUEUE_MAXSIZE = 10_000 - def __init__( - self, - async_mode: bool = True, - queue_maxsize: int = _DEFAULT_QUEUE_MAXSIZE, - register_default_sinks: bool = True, - ) -> None: + def __init__(self, register_default_sinks: bool = True) -> None: """Initialize the audit manager. Args: - async_mode: If True (default), events are processed in a background - thread. If False, events are processed synchronously. - queue_maxsize: Max queued events in async mode. On overflow the - oldest queued event is dropped to make room. register_default_sinks: If True (default), register the - always-on ``traces`` sink and an atexit cleanup - handler. Tests that want a bare manager can pass - ``False`` and register sinks explicitly. + always-on ``traces`` sink. Tests that want a bare + manager can pass ``False`` and register sinks + explicitly. """ self._sinks: list[AuditSink] = [] - # Single lock guards _sinks, _sink_failures, _tripped_sinks — every - # collection mutated by both the worker thread and the emit caller. + # Guards _sinks, _sink_failures, _tripped_sinks — all read + + # mutated by emit() across threads when concurrent agent hooks + # share one manager. self._sinks_lock = threading.Lock() # Per-sink consecutive-failure counter, keyed by sink name. self._sink_failures: dict[str, int] = {} self._tripped_sinks: set[str] = set() - self._async_mode = async_mode - self._pid = os.getpid() - - # Background processing. - # - # Queue items are ``(contextvars.Context, AuditEvent)`` tuples - # so the caller's contextvars context (which holds the live - # OTel span, request correlation ids, etc.) propagates across - # the worker-thread hop. Without this the worker would see an - # empty contextvars context — OTel-backed sinks would render - # governance spans as orphan roots instead of children of the - # agent's live span. ``None`` is the shutdown sentinel. - self._queue: queue.Queue[ - tuple[contextvars.Context, AuditEvent] | None - ] = queue.Queue(maxsize=queue_maxsize) - self._worker_thread: threading.Thread | None = None - self._shutdown = threading.Event() - - if self._async_mode: - self._start_worker() if register_default_sinks: self._register_traces_sink() - # Process-level atexit (one shared handler, weakref-tracked - # set) instead of per-instance ``atexit.register(self.method)``: - # avoids unbounded atexit list growth and the strong reference - # that would otherwise pin a disposed manager until process - # exit. See :class:`_AuditManagerCleanupRegistry`. - _cleanup_registry.register(self) def _register_traces_sink(self) -> None: """Register the always-on ``traces`` sink. @@ -326,106 +262,6 @@ def _register_traces_sink(self) -> None: self.register_sink(sink) logger.info("Governance audit sink registered: traces") - def _start_worker(self) -> None: - """Start the background worker thread.""" - if self._worker_thread is not None and self._worker_thread.is_alive(): - return - - self._shutdown.clear() - self._worker_thread = threading.Thread( - target=self._worker_loop, - name="governance-audit-worker", - daemon=True, - ) - self._worker_thread.start() - logger.debug("Background audit worker started") - - def _worker_loop(self) -> None: - """Background worker loop that processes queued events.""" - while not self._shutdown.is_set(): - # Wait for an item with a timeout so we can re-check shutdown. - try: - item = self._queue.get(timeout=0.5) - except queue.Empty: - continue - # Every successful get() must be paired with exactly one - # task_done() — including the shutdown sentinel and the case - # where _emit_sync raises — otherwise unfinished_tasks never - # drains and flush()/join() hangs. - try: - if item is None: - # Shutdown signal - break - ctx, event = item - # Run sink dispatch inside the caller's captured - # contextvars context so OTel-backed sinks see the - # agent's live span via ``context.get_current()``. - ctx.run(self._emit_sync, event) - except Exception as e: - logger.warning("Audit worker error: %s", e) - finally: - self._queue.task_done() - - # Drain remaining events on shutdown - self._drain_queue() - - def _drain_queue(self) -> None: - """Process any remaining events in the queue.""" - while True: - try: - item = self._queue.get_nowait() - except queue.Empty: - break - # As in _worker_loop: pair every get() with one task_done(), - # even when _emit_sync raises, so shutdown accounting is sound. - try: - if item is not None: - ctx, event = item - ctx.run(self._emit_sync, event) - except Exception as e: - logger.warning("Audit drain error: %s", e) - finally: - self._queue.task_done() - - def _emit_sync(self, event: AuditEvent) -> None: - """Emit event synchronously to all sinks (called from worker thread).""" - with self._sinks_lock: - sinks = list(self._sinks) - tripped = set(self._tripped_sinks) - for sink in sinks: - if sink.name in tripped: - continue - try: - if sink.accepts(event): - sink.emit(event) - # Success — reset failure counter for this sink. - with self._sinks_lock: - if self._sink_failures.get(sink.name): - self._sink_failures[sink.name] = 0 - except Exception as e: - with self._sinks_lock: - fails = self._sink_failures.get(sink.name, 0) + 1 - self._sink_failures[sink.name] = fails - tripped_now = fails >= self._SINK_FAILURE_THRESHOLD - if tripped_now: - self._tripped_sinks.add(sink.name) - if tripped_now: - logger.error( - "Audit sink '%s' tripped after %d consecutive failures; " - "will be skipped for the rest of this process. Last error: %s", - sink.name, - fails, - e, - ) - else: - logger.warning( - "Audit sink '%s' failed to emit event (%d/%d): %s", - sink.name, - fails, - self._SINK_FAILURE_THRESHOLD, - e, - ) - def register_sink(self, sink: AuditSink) -> None: """Register an audit sink. @@ -490,76 +326,52 @@ def list_sinks(self) -> list[str]: return [s.name for s in self._sinks] def emit(self, event: AuditEvent) -> None: - """Emit an audit event to all registered sinks. - - In async mode (default), this queues the event for background - processing and returns immediately. This avoids blocking the - main agent execution path during audit trace HTTP calls. + """Dispatch ``event`` synchronously to every live sink. - On post-fork callers (worker process inheriting the parent's - manager), the queue is reinitialized and the worker thread - re-spawned before enqueue — otherwise events would silently - accumulate in a queue no one is draining. + Per-sink errors are caught and folded into the circuit breaker + — a sink that fails too many times in a row is skipped for the + rest of the manager's lifetime. The caller never sees a sink + exception. Args: event: The audit event to emit """ - self._ensure_alive_after_fork() - - if self._async_mode: - # Capture the caller's contextvars context now (while the - # OTel span and request correlation state are still live - # on this thread). The worker runs the sink dispatch - # inside this snapshot so cross-thread sinks see the same - # context the caller had. See queue type in __init__. - item = (contextvars.copy_context(), event) - # Non-blocking enqueue with drop-oldest backpressure: if the - # worker is wedged on a slow sink, this keeps memory bounded - # rather than growing without limit. - try: - self._queue.put_nowait(item) - except queue.Full: - try: - self._queue.get_nowait() - self._queue.task_done() - except queue.Empty: - pass - try: - self._queue.put_nowait(item) - except queue.Full: - # Worker is so far behind that the queue refilled - # between get_nowait and put_nowait — give up on - # this event rather than block. - pass - else: - # Synchronous processing — caller thread IS the worker, so - # the OTel context is already correct; no context snapshot - # or ctx.run() needed. - self._emit_sync(event) - - def _ensure_alive_after_fork(self) -> None: - """Reset queue and respawn worker if we're in a forked child. - - Double-checked under ``_sinks_lock``: a fresh-fork child where - multiple threads call :meth:`emit` concurrently could otherwise - each see the stale ``_pid`` and each rebuild ``_queue`` / - ``_shutdown`` / ``_worker_thread`` — one thread's writes would - clobber the other's, leaking the queue+worker pair. - """ - if os.getpid() == self._pid: - return # fast path: same process, no rebuild needed with self._sinks_lock: - current_pid = os.getpid() - if current_pid == self._pid: - return # another thread won the rebuild race - # Child process inherited a dead worker_thread reference and - # a queue the parent owned. Rebuild both so child events drain. - self._pid = current_pid - self._queue = queue.Queue(maxsize=self._queue.maxsize) - self._shutdown = threading.Event() - self._worker_thread = None - if self._async_mode: - self._start_worker() + sinks = list(self._sinks) + tripped = set(self._tripped_sinks) + for sink in sinks: + if sink.name in tripped: + continue + try: + if sink.accepts(event): + sink.emit(event) + # Success — reset failure counter for this sink. + with self._sinks_lock: + if self._sink_failures.get(sink.name): + self._sink_failures[sink.name] = 0 + except Exception as e: + with self._sinks_lock: + fails = self._sink_failures.get(sink.name, 0) + 1 + self._sink_failures[sink.name] = fails + tripped_now = fails >= self._SINK_FAILURE_THRESHOLD + if tripped_now: + self._tripped_sinks.add(sink.name) + if tripped_now: + logger.error( + "Audit sink '%s' tripped after %d consecutive failures; " + "will be skipped for the rest of this process. Last error: %s", + sink.name, + fails, + e, + ) + else: + logger.warning( + "Audit sink '%s' failed to emit event (%d/%d): %s", + sink.name, + fails, + self._SINK_FAILURE_THRESHOLD, + e, + ) def emit_rule_evaluation( self, @@ -675,44 +487,13 @@ def emit_session_end( ) ) - def flush(self, timeout: float = 5.0) -> None: - """Flush all pending events and sinks. - - In async mode, polls the queue until it drains or ``timeout`` - seconds elapse, whichever comes first. ``queue.Queue.join`` has - no timeout argument — using it would block indefinitely on a - wedged sink, which defeats the bounded-shutdown contract that - :class:`_AuditManagerCleanupRegistry` relies on at process exit. + def flush(self) -> None: + """Flush every registered sink. - Args: - timeout: Maximum seconds to wait for queue to drain (default 5.0) + Per-sink — a sink that maintains its own buffer (OTel batched + export, HTTP batcher, etc.) gets a chance to drain. The + manager itself holds no queue. """ - if self._async_mode: - import time - - deadline = time.monotonic() + max(0.0, timeout) - poll_interval = min(0.05, timeout) if timeout > 0 else 0.0 - while time.monotonic() < deadline: - try: - if self._queue.unfinished_tasks == 0: - break - except Exception: # noqa: BLE001 - queue introspection is best-effort - break - time.sleep(poll_interval) - else: - # Loop didn't break — drain timed out. Log so a wedged - # sink is surfaced rather than swallowed. - try: - pending = self._queue.unfinished_tasks - except Exception: # noqa: BLE001 - pending = -1 - if pending: - logger.warning( - "Audit queue did not drain within %.2fs " - "(unfinished tasks=%s); sink may be wedged", - timeout, pending, - ) - with self._sinks_lock: sinks = list(self._sinks) for sink in sinks: @@ -724,32 +505,9 @@ def flush(self, timeout: float = 5.0) -> None: def close(self) -> None: """Close all sinks and release resources. - Stops the background worker thread and drains any remaining events. - Shutdown is bounded: ``_shutdown`` is the primary signal the - worker polls; the sentinel ``None`` enqueue is best-effort. If - the queue is full and the worker is wedged on a slow sink, - ``put_nowait`` fails fast rather than hanging process exit. + Idempotent — a manager that has already been closed has an + empty sink list, so a repeat call is a no-op. """ - if self._async_mode and self._worker_thread is not None: - # Signal shutdown first so the worker's next queue.get() loop - # iteration exits even if we can't enqueue the sentinel. - self._shutdown.set() - try: - self._queue.put_nowait(None) # Wake up worker - except queue.Full: - # Queue saturated by a stuck sink; the worker will see - # _shutdown on its next loop iteration once whatever it's - # blocked on completes (or the 2s join timeout fires). - logger.debug( - "Audit queue full at shutdown; relying on _shutdown signal" - ) - - # Wait for worker to finish (with timeout) - if self._worker_thread.is_alive(): - self._worker_thread.join(timeout=2.0) - - logger.debug("Background audit worker stopped") - with self._sinks_lock: sinks = list(self._sinks) self._sinks.clear() @@ -760,5 +518,3 @@ def close(self) -> None: sink.close() except Exception as e: logger.warning("Audit sink '%s' failed to close: %s", sink.name, e) - - diff --git a/src/uipath/runtime/governance/_audit/traces.py b/src/uipath/runtime/governance/_audit/traces.py index 735d297b..e0092cf8 100644 --- a/src/uipath/runtime/governance/_audit/traces.py +++ b/src/uipath/runtime/governance/_audit/traces.py @@ -166,20 +166,15 @@ def _emit_hook_span(self, event: AuditEvent) -> None: return try: - from opentelemetry import context - data = event.data hook = event.hook or "unknown" span_name = f"governance.{hook.lower()}" - # Use the current OTel context. The audit manager runs the - # sink inside the caller's captured ``contextvars`` context - # (see :meth:`AuditManager.emit`), so the agent's live span - # is visible here even on the audit worker thread — the - # governance span attaches as a child instead of orphan root. - ctx = context.get_current() - - with tracer.start_as_current_span(span_name, context=ctx) as span: + # Sink dispatch runs on the caller's thread (see + # :meth:`AuditManager.emit`), so the current OTel context + # is the agent's live span — the governance span attaches + # as its child without any cross-thread plumbing. + with tracer.start_as_current_span(span_name) as span: span.set_attribute("type", SPAN_TYPE_AGENT_RUN) span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) span.set_attribute("uipath.custom_instrumentation", True) @@ -215,18 +210,14 @@ def _emit_rule_span(self, event: AuditEvent) -> None: return try: - from opentelemetry import context - data = event.data policy_id = data.get("policy_id", "unknown") span_name = f"{NS}.rule.{policy_id}" - # See _emit_hook_span: the contextvars-captured caller - # context means the current OTel context is the agent's + # See _emit_hook_span: sync dispatch on the caller's + # thread means the current OTel context is the agent's # live span, so this rule span attaches as its child. - ctx = context.get_current() - - with tracer.start_as_current_span(span_name, context=ctx) as span: + with tracer.start_as_current_span(span_name) as span: span.set_attribute("type", SPAN_TYPE_AGENT_RUN) span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) span.set_attribute("uipath.custom_instrumentation", True) diff --git a/tests/test_audit_manager_lifecycle.py b/tests/test_audit_manager_lifecycle.py index 905891e4..25094f9b 100644 --- a/tests/test_audit_manager_lifecycle.py +++ b/tests/test_audit_manager_lifecycle.py @@ -2,211 +2,158 @@ Pins the production-readiness invariants of the audit manager: -- Process cleanup uses a single ``atexit`` handler that walks a - ``WeakSet`` — so creating many managers in one process doesn't - bloat the atexit list and doesn't pin managers in memory. -- The fork-rebuild path is lock-protected: two threads in a - freshly-forked child can't both rebuild the queue/worker - concurrently. +- Construction is side-effect-free: no background thread, no atexit + registration, no global state mutation. +- :meth:`close` is idempotent. +- :meth:`emit` dispatches on the caller's thread, so an OTel-backed + sink sees the caller's live span without any cross-thread plumbing. """ from __future__ import annotations -import gc -import os import threading from typing import Any -from unittest.mock import patch - -import pytest - -from uipath.runtime.governance._audit import base as audit_base -from uipath.runtime.governance._audit.base import AuditManager - - -def _bare_manager() -> AuditManager: - """Build a manager with no default sinks (no traces sink, no atexit-set add).""" - return AuditManager(async_mode=False, register_default_sinks=False) +from uipath.runtime.governance._audit.base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, +) # --------------------------------------------------------------------------- -# atexit accounting: one process-level hook, no per-instance accumulation +# Construction is side-effect-free # --------------------------------------------------------------------------- -def test_default_managers_register_once_in_process_atexit() -> None: - """Creating N managers must NOT add N entries to interpreter atexit. +def test_construction_starts_no_background_thread() -> None: + """``AuditManager()`` must not spawn a worker thread. - Regression: per-instance ``atexit.register(self._atexit_cleanup)`` - grew the atexit list linearly and held a strong ref to each manager. - The fix routes everyone through one process-level cleanup hook. + Regression guard for the design pivot: the audit pipeline used to + construct a daemon worker thread eagerly. Construction now only + builds in-memory state; any async export lives inside the sink. """ - with patch.object(audit_base.atexit, "register") as mock_register: - # Reset module state so the assertion is deterministic - # regardless of test-order side effects. - audit_base._cleanup_registry.atexit_registered = False - try: - AuditManager(async_mode=False) # first → registers - AuditManager(async_mode=False) # second → reuses - AuditManager(async_mode=False) # third → reuses - assert mock_register.call_count == 1, ( - "Each AuditManager must NOT register its own atexit handler" - ) - finally: - # Drop test managers from the cleanup set before leaving. - audit_base._cleanup_registry.live_managers.clear() - - -def test_register_default_sinks_false_skips_cleanup_set() -> None: - """Bare managers (tests) are not tracked for process cleanup.""" - m = _bare_manager() - assert m not in audit_base._cleanup_registry.live_managers - - -def test_disposed_manager_can_be_garbage_collected() -> None: - """The WeakSet must NOT keep a disposed manager alive. - - Regression: per-instance atexit held a strong ref → disposed - managers leaked until process exit. With ``WeakSet`` + a single - process hook, dropping the last reference lets the manager GC. - """ - import weakref + before = {t.name for t in threading.enumerate()} + m = AuditManager(register_default_sinks=False) + after = {t.name for t in threading.enumerate()} + try: + assert after == before, ( + f"AuditManager() spawned a thread; new threads: {after - before}" + ) + finally: + m.close() - manager = AuditManager(async_mode=False) - ref = weakref.ref(manager) - # Sanity: it's tracked while alive. - assert manager in audit_base._cleanup_registry.live_managers +def test_default_sink_registered_on_construction() -> None: + """With defaults, the traces sink is auto-registered.""" + m = AuditManager() + try: + assert "traces" in m.list_sinks() + finally: + m.close() - # Drop the local strong ref + force collection. - del manager - gc.collect() - # The WeakSet entry must be gone (or about to be). - assert ref() is None, ( - "AuditManager was kept alive — strong reference leak in cleanup machinery" - ) +def test_bare_construction_skips_default_sink() -> None: + """``register_default_sinks=False`` produces an empty manager.""" + m = AuditManager(register_default_sinks=False) + try: + assert m.list_sinks() == [] + finally: + m.close() -def test_process_cleanup_handles_already_closed_manager() -> None: - """If a manager was explicitly closed, the process hook is a no-op for it. +# --------------------------------------------------------------------------- +# close() is idempotent and clears sinks +# --------------------------------------------------------------------------- - A manager that called close() during normal lifecycle should not - raise from the process-level cleanup — sink list is empty, worker - is already joined. - """ - m = AuditManager(async_mode=False) - m.close() - # Must not raise. - audit_base._cleanup_registry.process_cleanup() +def test_close_clears_sinks_and_failure_state() -> None: + """``close()`` empties sinks, failure counters, and tripped set.""" -# --------------------------------------------------------------------------- -# Fork-rebuild safety -# --------------------------------------------------------------------------- + class _Sink(AuditSink): + def __init__(self, name: str) -> None: + self._name = name + self.closed = False + @property + def name(self) -> str: + return self._name -def test_ensure_alive_after_fork_is_idempotent_under_concurrent_emit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Two threads in a fresh-fork child must not both rebuild the queue. + def emit(self, event: AuditEvent) -> None: + pass - Without the lock, both threads observed the stale ``_pid``, both - constructed a new ``queue.Queue`` / ``threading.Event`` / - ``threading.Thread``, and the later writer leaked the earlier - one's queue+worker. With the lock the loser sees the updated - ``_pid`` after acquiring and returns. - """ - m = AuditManager(async_mode=True, register_default_sinks=False) - - # Capture the post-construction queue + worker so we can detect - # whether multiple rebuild winners occurred. - original_queue = m._queue - original_worker = m._worker_thread - - # Simulate a fork by mutating the recorded pid. We do NOT actually - # fork; we just put the manager into "I think I'm in a stale - # process" state. - m._pid = -1 - - barrier = threading.Barrier(8) - seen_queues: set[int] = set() - seen_workers: set[int] = set() - lock = threading.Lock() - - def worker() -> None: - barrier.wait() - m._ensure_alive_after_fork() - with lock: - seen_queues.add(id(m._queue)) - seen_workers.add(id(m._worker_thread)) - - threads = [threading.Thread(target=worker) for _ in range(8)] - for t in threads: - t.start() - for t in threads: - t.join(timeout=5.0) - - # Exactly one queue + worker survived the race. - assert len(seen_queues) == 1, ( - f"Multiple queues survived fork-rebuild race: {seen_queues}" - ) - assert len(seen_workers) == 1, ( - f"Multiple workers survived fork-rebuild race: {seen_workers}" - ) - # And the survivor is NOT the original (we did rebuild). - assert original_queue is not m._queue - assert original_worker is not m._worker_thread - assert m._pid == os.getpid() + def close(self) -> None: + self.closed = True - m.close() + m = AuditManager(register_default_sinks=False) + s = _Sink("test") + m.register_sink(s) + m._sink_failures["test"] = 3 + m._tripped_sinks.add("test") + m.close() -def test_ensure_alive_after_fork_fast_path_when_pid_unchanged() -> None: - """Same-process call must NOT rebuild — sanity check on the fast path.""" - m = AuditManager(async_mode=True, register_default_sinks=False) - original_queue = m._queue - original_worker = m._worker_thread + assert m.list_sinks() == [] + assert m._sink_failures == {} + assert m._tripped_sinks == set() + assert s.closed - m._ensure_alive_after_fork() # same PID — no-op - assert m._queue is original_queue - assert m._worker_thread is original_worker +def test_close_is_idempotent() -> None: + """Calling ``close()`` twice must not raise.""" + m = AuditManager(register_default_sinks=False) m.close() + m.close() # must not raise # --------------------------------------------------------------------------- -# Shared helpers +# flush() delegates to every sink # --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# contextvars propagation — the worker sees the caller's OTel span -# --------------------------------------------------------------------------- +def test_flush_calls_flush_on_each_sink() -> None: + """The manager holds no buffer; ``flush()`` is a fan-out to sinks.""" + + class _Sink(AuditSink): + def __init__(self, name: str) -> None: + self._name = name + self.flush_count = 0 + + @property + def name(self) -> str: + return self._name + def emit(self, event: AuditEvent) -> None: + pass -def test_async_emit_propagates_caller_contextvars_to_worker_thread() -> None: - """Async emit captures ``contextvars.copy_context()`` at enqueue. + def flush(self) -> None: + self.flush_count += 1 - Worker threads do not inherit ``contextvars``, so without this - capture the audit worker would see an empty OTel context and - OTel-backed sinks would render governance spans as orphan roots. - The fix: queue items are ``(Context, AuditEvent)`` tuples; the - worker runs ``ctx.run(_emit_sync, event)`` so sink dispatch - happens inside the caller's snapshot. + m = AuditManager(register_default_sinks=False) + a, b = _Sink("a"), _Sink("b") + m.register_sink(a) + m.register_sink(b) + try: + m.flush() + assert a.flush_count == 1 + assert b.flush_count == 1 + finally: + m.close() - This test puts a real OTel span on the caller thread, fires the - event through ``async_mode=True``, and asserts the sink — running - on the audit worker — sees the same span via - ``trace.get_current_span()``. - """ - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - from uipath.runtime.governance._audit.base import AuditEvent, AuditSink +# --------------------------------------------------------------------------- +# emit() runs on the caller's thread — OTel context visible directly +# --------------------------------------------------------------------------- + +def test_emit_runs_on_caller_thread() -> None: + """``emit()`` invokes sinks synchronously on the calling thread. + + Asserts the design contract that lets OTel-backed sinks see the + agent's live span via ``trace.get_current_span()`` without any + cross-thread context propagation. + """ captured: dict[str, Any] = {} - done = threading.Event() class _Probe(AuditSink): @property @@ -214,52 +161,28 @@ def name(self) -> str: return "probe" def emit(self, event: AuditEvent) -> None: - # Capture the OTel SpanContext the worker sees so the - # assertions below can compare it to the caller's live - # span. Naming the dict keys ``otel_*`` keeps them - # distinct from any application-level trace id — the - # ``AuditEvent`` itself carries none. - sc = trace.get_current_span().get_span_context() - captured["worker_thread"] = threading.current_thread().name - captured["otel_trace_id"] = sc.trace_id if sc.is_valid else None - captured["otel_span_id"] = sc.span_id if sc.is_valid else None - done.set() + captured["thread"] = threading.current_thread() - tracer = TracerProvider().get_tracer("test") - m = AuditManager(async_mode=True, register_default_sinks=False) + m = AuditManager(register_default_sinks=False) m.register_sink(_Probe()) - try: - with tracer.start_as_current_span("agent-run") as span: - expected = span.get_span_context() - m.emit(AuditEvent(event_type="rule_evaluation")) - - assert done.wait(timeout=2.0), "audit worker never processed the event" - - # Worker really did run on the audit-manager thread. - assert captured["worker_thread"].startswith("governance-audit-worker") - # And the captured contextvars snapshot propagated the OTel span. - assert captured["otel_trace_id"] == expected.trace_id - assert captured["otel_span_id"] == expected.span_id + m.emit(AuditEvent(event_type=EventType.RULE_EVALUATION)) + assert captured["thread"] is threading.current_thread() finally: m.close() -def test_async_emit_drops_oldest_under_pressure_preserves_context() -> None: - """Even when the queue overflows and the oldest item is dropped, the - surviving item carries its own captured context. +def test_emit_propagates_otel_span_via_current_context() -> None: + """An OTel-backed sink sees the caller's live span directly. - Regression guard: the drop-oldest branch re-puts the new tuple, - not a bare event. Forgetting to wrap there would silently send - items without context propagation. + With sync dispatch there's no contextvars snapshot/restore — the + sink just calls ``trace.get_current_span()`` on the same thread the + caller is on, and that's the span the caller has active. """ from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider - from uipath.runtime.governance._audit.base import AuditEvent, AuditSink - - seen: list[int | None] = [] - done = threading.Event() + captured: dict[str, Any] = {} class _Probe(AuditSink): @property @@ -268,44 +191,17 @@ def name(self) -> str: def emit(self, event: AuditEvent) -> None: sc = trace.get_current_span().get_span_context() - seen.append(sc.trace_id if sc.is_valid else None) - if len(seen) >= 1: - done.set() + captured["trace_id"] = sc.trace_id if sc.is_valid else None + captured["span_id"] = sc.span_id if sc.is_valid else None tracer = TracerProvider().get_tracer("test") - # ``queue_maxsize=1`` forces the overflow path on the second put. - m = AuditManager( - async_mode=True, register_default_sinks=False, queue_maxsize=1 - ) + m = AuditManager(register_default_sinks=False) m.register_sink(_Probe()) - try: - with tracer.start_as_current_span("first") as s1: - first_id = s1.get_span_context().trace_id - m.emit(AuditEvent(event_type="rule_evaluation")) - with tracer.start_as_current_span("second") as s2: - second_id = s2.get_span_context().trace_id - m.emit(AuditEvent(event_type="rule_evaluation")) - - assert done.wait(timeout=2.0) - # Whichever item won (the surviving one), it must carry its - # own captured context — not an empty one. - assert seen[0] in (first_id, second_id), ( - f"surviving item lost context propagation; got {seen[0]!r}" - ) + with tracer.start_as_current_span("agent-run") as span: + expected = span.get_span_context() + m.emit(AuditEvent(event_type=EventType.RULE_EVALUATION)) + assert captured["trace_id"] == expected.trace_id + assert captured["span_id"] == expected.span_id finally: m.close() - - -@pytest.fixture(autouse=True) -def _clean_module_state() -> Any: - """Test isolation for the module-level cleanup machinery. - - Sweep the WeakSet between tests so leftovers from one test don't - show up in another. Don't reset ``_cleanup_registry.atexit_registered`` — once - Python's ``atexit`` accepts a handler, we shouldn't unregister it - just for tests, and the tests above that check registration count - do their own reset under a patched ``atexit.register``. - """ - yield - audit_base._cleanup_registry.live_managers.clear() diff --git a/tests/test_audit_register_sink.py b/tests/test_audit_register_sink.py index 19c99969..d0e3590b 100644 --- a/tests/test_audit_register_sink.py +++ b/tests/test_audit_register_sink.py @@ -42,13 +42,13 @@ def _event() -> AuditEvent: @pytest.fixture def manager() -> Any: - """Build a fresh, sync-mode AuditManager with no default sinks. + """Build a fresh AuditManager with no default sinks. - ``register_default_sinks=False`` keeps the traces sink (and the - per-instance atexit hook) out of the test, so assertions about - registered sinks see only what the test puts there. + ``register_default_sinks=False`` keeps the traces sink out of the + test, so assertions about registered sinks see only what the test + puts there. """ - return AuditManager(async_mode=False, register_default_sinks=False) + return AuditManager(register_default_sinks=False) def test_register_clears_stale_failure_counter(manager: AuditManager) -> None: From 3b351e0d1e2e848cdb4a70a71f0eb30948f31f4f Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Sun, 28 Jun 2026 08:05:18 +0530 Subject: [PATCH 15/18] feat(governance): in-runtime policy evaluator + guardrail compensation Rebase of the guardrail-compensation series onto feat/governance-audit's tip. Brings up the native governance layer in one squash: - In-runtime policy evaluator (native/evaluator.py): rule + check + condition matching with VADER sentiment / chardet / regex / entropy / incident / commitment operators. Honors per-check action overrides and cross-rule aggregation. Instance-scoped with explicit deps (AuditManager + GuardrailCompensator) injected by the host. - Native package exports (native/__init__.py): build_policy_index_from_yaml + GovernanceEvaluator + GuardrailCompensator + CheckContext + PolicyIndex. - GuardrailCompensator (native/guardrail_compensation.py): bounded ThreadPoolExecutor + BoundedSemaphore per runtime, contextvars propagation, weakref-tracked process-level atexit. Delegates HTTP / auth / URL / trace correlation to the injected GovernanceCompensationProvider. - Drop PolicyLoader: host fetches policy asynchronously via GovernancePolicyProvider and hands the resolved PolicyIndex to UiPathGovernedRuntime at construction. - Trace correlation: AuditEvent / AuditRecord no longer carry trace_id; OTel-backed sinks resolve from the live span via the AuditManager's captured contextvars snapshot. - testpypi dev pin (local dev only): uipath-core + uipath-platform pinned to the testpypi dev builds from PR UiPath/uipath-python#1761 (AdapterRegistry deletion + AuditRecord.trace_id field drop) via ``[tool.uv] override-dependencies`` + ``[tool.uv.sources]``. The wheel-baked ``[project.dependencies]`` constraint stays at the canonical ``uipath-core>=0.5.22,<0.6.0`` so consumer workspaces that don't configure testpypi (notably uipath-python's CI matrix) resolve cleanly against published versions. Tests: 346 passed + 1 skipped, ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 3 + .../runtime/governance/native/__init__.py | 45 + .../governance/native/_yaml_to_index.py | 11 +- .../runtime/governance/native/evaluator.py | 1102 +++++++++++++++++ .../native/guardrail_compensation.py | 311 +++++ .../runtime/governance/native/loader.py | 342 ----- src/uipath/runtime/governance/runtime.py | 224 ++-- tests/_helpers.py | 46 - tests/conftest.py | 8 +- tests/test_commitment_concern.py | 205 +++ tests/test_enforcement_mode_default.py | 114 -- tests/test_evaluator.py | 420 +++++++ tests/test_evaluator_operators.py | 672 ++++++++++ tests/test_governance_runtime.py | 193 +-- tests/test_guardrail_compensation.py | 503 ++++++++ tests/test_loader.py | 307 ----- tests/test_text_extraction.py | 307 +++++ uv.lock | 10 +- 18 files changed, 3792 insertions(+), 1031 deletions(-) create mode 100644 src/uipath/runtime/governance/native/__init__.py create mode 100644 src/uipath/runtime/governance/native/evaluator.py create mode 100644 src/uipath/runtime/governance/native/guardrail_compensation.py delete mode 100644 src/uipath/runtime/governance/native/loader.py delete mode 100644 tests/_helpers.py create mode 100644 tests/test_commitment_concern.py delete mode 100644 tests/test_enforcement_mode_default.py create mode 100644 tests/test_evaluator.py create mode 100644 tests/test_evaluator_operators.py create mode 100644 tests/test_guardrail_compensation.py delete mode 100644 tests/test_loader.py create mode 100644 tests/test_text_extraction.py diff --git a/pyproject.toml b/pyproject.toml index 48f7483c..8d8792f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,9 @@ exclude-newer = "2 days" [tool.uv.exclude-newer-package] uipath-core = false +[tool.uv.sources] +uipath-core = { index = "testpypi" } + [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" diff --git a/src/uipath/runtime/governance/native/__init__.py b/src/uipath/runtime/governance/native/__init__.py new file mode 100644 index 00000000..713a05df --- /dev/null +++ b/src/uipath/runtime/governance/native/__init__.py @@ -0,0 +1,45 @@ +"""Native UiPath governance policy evaluator. + +YAML-defined rules evaluated in-process at each agent lifecycle hook. +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. +- :func:`build_policy_index_from_yaml` – pure YAML → :class:`PolicyIndex` + compiler. +- The native policy model: :class:`Rule`, :class:`Check`, + :class:`Condition`, :class:`PolicyIndex`. + +Shared output types (``Action``, ``AuditRecord``, …) live in +:mod:`uipath.core.governance`. +""" + +from ._yaml_to_index import build_policy_index_from_yaml +from .evaluator import GovernanceEvaluator +from .models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, + Severity, +) + +__all__ = [ + "GovernanceEvaluator", + "build_policy_index_from_yaml", + # Native policy model + "Check", + "CheckContext", + "Condition", + "PolicyIndex", + "PolicyPack", + "Rule", + "Severity", +] diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index 3bf264c7..9abdec3a 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 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 new file mode 100644 index 00000000..f629902b --- /dev/null +++ b/src/uipath/runtime/governance/native/evaluator.py @@ -0,0 +1,1102 @@ +"""Governance rule evaluator. + +Instance-scoped — every :class:`GovernanceRuntime` constructs its own +evaluator with explicit dependencies (audit manager, compensator, +enforcement mode). The evaluator does not reach across the runtime +layer through process-globals; the wiring layer composes the runtime +graph and the evaluator consumes what it's given. +""" + +from __future__ import annotations + +import logging +import math +import re +from collections import Counter +from datetime import datetime, timezone +from functools import lru_cache +from typing import Any + +from uipath.core.governance import EnforcementMode +from uipath.core.governance.exceptions import GovernanceBlockException +from uipath.core.governance.models import ( + Action, + AuditRecord, + LifecycleHook, + RuleEvaluation, +) + +from uipath.runtime.governance._audit.base import AuditManager +from uipath.runtime.governance.native.guardrail_compensation import ( + GuardrailCompensator, + disabled_guardrails, +) +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + Rule, +) + +logger = logging.getLogger(__name__) + + +def _compensation_data_for_hook(context: CheckContext) -> dict[str, Any]: + """Build the ``data`` payload for the /runtime/govern compensating call. + + The server runs the guardrail check against the same content the + evaluator was looking at — so we forward whichever + :class:`CheckContext` field is populated for the active hook. Fields + not relevant to the hook are omitted to keep the payload tight. + """ + if context.hook in (LifecycleHook.BEFORE_AGENT,): + return {"content": context.agent_input} + if context.hook in (LifecycleHook.AFTER_AGENT,): + return {"content": context.agent_output} + if context.hook in (LifecycleHook.BEFORE_MODEL,): + payload: dict[str, Any] = {"content": context.model_input} + if context.messages: + payload["messages"] = context.messages + return payload + if context.hook in (LifecycleHook.AFTER_MODEL,): + return {"content": context.model_output} + if context.hook in (LifecycleHook.TOOL_CALL,): + return {"tool_name": context.tool_name, "tool_args": context.tool_args} + if context.hook in (LifecycleHook.AFTER_TOOL,): + return {"tool_name": context.tool_name, "tool_result": context.tool_result} + # Memory-write and unknown hooks: pass an empty content so the + # server still receives a structurally-valid payload. + return {"content": ""} + + +@lru_cache(maxsize=256) +def _compile_regex(pattern: str) -> re.Pattern[str] | None: + """Compile and cache a regex pattern. + + Args: + pattern: The regex pattern string + + Returns: + Compiled pattern or None if invalid + """ + try: + return re.compile(pattern) + except re.error as e: + logger.warning("Invalid regex pattern '%s': %s", pattern, e) + return None + + +# --- vaderSentiment: lazy-imported singleton --- +# Hard dependency, but lazy-loaded to keep import-time cost off the +# critical path. The except branch is defence against a corrupted +# install (file present in METADATA but module unimportable) — the +# operator no-ops rather than crashing the agent. +_VADER_UNINITIALIZED = object() +_vader_analyzer: Any = _VADER_UNINITIALIZED + + +def _get_vader_analyzer() -> Any: + """Return a cached SentimentIntensityAnalyzer, or None if unavailable.""" + global _vader_analyzer + if _vader_analyzer is _VADER_UNINITIALIZED: + try: + from vaderSentiment.vaderSentiment import ( # type: ignore[import-untyped] + SentimentIntensityAnalyzer, + ) + + _vader_analyzer = SentimentIntensityAnalyzer() + except ImportError: + logger.error( + "vaderSentiment failed to import despite being a hard dependency; " + "sentiment_concern checks will not fire. Reinstall uipath-core." + ) + _vader_analyzer = None + return _vader_analyzer + + +# --- chardet: lazy-imported module for encoding integrity (A.7.4) --- +# Hard dependency, lazy-loaded for symmetry with the other library +# wrappers. The except branch covers corrupted installs only. +_CHARDET_UNINITIALIZED = object() +_chardet_module: Any = _CHARDET_UNINITIALIZED + + +def _get_chardet() -> Any: + """Return the chardet module, or None if unavailable.""" + global _chardet_module + if _chardet_module is _CHARDET_UNINITIALIZED: + try: + import chardet + + _chardet_module = chardet + except ImportError: + logger.error( + "chardet failed to import despite being a hard dependency; " + "encoding_concern confidence check will not fire (stdlib " + "signals still apply). Reinstall uipath-core." + ) + _chardet_module = None + return _chardet_module + + +# --- Static patterns for encoding_concern (A.7.4) --- +# Latin-1-as-UTF-8 mojibake bigrams — the visible artefacts when +# UTF-8-encoded text is re-decoded as Latin-1 / Windows-1252. +_MOJIBAKE_BIGRAMS: tuple[str, ...] = ( + "é", + "è", + "â", + "à ", + "ù", + "î", + "ô", + "ç", # accented vowels + "Ä", + "Ö", + "Ü", + "ß", # German umlauts / eszett + "’", + "“", + "â€\x9d", + "–", + "—", + "•", # smart quotes / dashes + "£", + "°", + "§", + "¶", + "©", + "®", # NBSP-leading symbols + "ï¿", + "¿½", # mojibake'd U+FFFD (0xEF 0xBF 0xBD as Latin-1) + "ï»", + "»¿", # mojibake'd BOM (0xEF 0xBB 0xBF as Latin-1) +) + +# Literal hex escape sequences ("\x80" as 4 source chars) indicate raw +# bytes leaked through a string layer rather than being decoded. +_HEX_ESCAPE_PATTERN = re.compile(r"\\x[0-9a-fA-F]{2}") + + +# --- Static patterns for incident_concern (A.8.4) --- +# Stdlib-only categorical taxonomy. Mirrors sentry-sdk's incident shape +# (categorical types over stack/status), but for string payloads from +# model output / tool result rather than exception objects. +_INCIDENT_PATTERNS: dict[str, list[re.Pattern[str]]] = { + "safety_refusal": [ + re.compile( + r"(?i)\b(i\s+(?:cannot|can'?t|am\s+unable\s+to|won'?t\s+be\s+able\s+to)" + r"\s+(?:help|assist|provide|answer|do\s+that))\b" + ), + re.compile(r"(?i)\b(i'?m\s+sorry,?\s+but\s+i\s+(?:cannot|can'?t))\b"), + re.compile(r"(?i)\b(against\s+my\s+(?:guidelines|policies|programming))\b"), + ], + "tool_failure": [ + re.compile( + r"\b(5\d{2})\b\s*(?:internal\s+server\s+error|service\s+unavailable)" + ), + re.compile(r"(?i)\b(ERR_[A-Z_]+|connection\s+refused|ECONNREFUSED)\b"), + re.compile(r"(?i)\b(timed?\s*out|timeout)\b"), + ], + "auth_failure": [ + re.compile(r"\b(401|403)\b\s*(?:unauthori[sz]ed|forbidden)"), + re.compile( + r"(?i)\b(authentication\s+failed|invalid\s+(?:token|credentials))\b" + ), + ], + "quota_exceeded": [ + re.compile(r"\b(429)\b"), + re.compile( + r"(?i)\b(rate\s+limit\s+exceeded|quota\s+exceeded|too\s+many\s+requests)\b" + ), + ], + "hallucination": [ + re.compile(r"(?i)\b(i\s+(?:made\s+(?:that|this)\s+up|am\s+just\s+guessing))\b"), + re.compile(r"(?i)\b(i\s+don'?t\s+actually\s+know|i\s+fabricat(?:ed|ing))\b"), + ], +} + +# --- Static patterns for commitment_concern (A.10.4) --- +# Commitment-language signals. The verb pattern covers both first-person +# promise verbs ("we will refund") and formal-business commitment markers +# common in proposal / SOW outputs ("Cost: $X", "fixed scope", +# "Deliverables", "Timeline: N days", "I propose"). Verb, amount, and +# deadline signals combine via OR semantics — see +# :meth:`_check_commitment_concern`. +_COMMITMENT_VERB_PATTERN = re.compile( + r"(?i)(" + # First-person promise / liability verbs + r"\brefund\b|\breimburse\b|" + r"\bwarranty\b|\bwarrant(?:y|ed|ies)\b|\bguarante[ed]+\b|" + r"\bsla\b|" + r"\bwaive[d]?\b|" + r"\b(?:we|i)\s+(?:will|shall|promise|commit|guarantee)\b|" + r"\b(?:we|i|i'?ll)\s+(?:deliver|provide|complete|finish|" + r"handover|hand\s+over|ship)\b|" + # Proposal / SOW commitment markers + r"\bfixed\s+(?:price|cost|fee|scope|bid|rate)\b|" + r"\bcost\s*:\s*\$?\d|" + r"\bquote\s*:\s*\$?\d|" + r"\bdeliverables?\b|" + r"\btimeline\s*:\s*\d+\s*(?:second|minute|hour|day|week|month|year)s?\b|" + r"\bI\s+propose\b" + r")" +) +# Currency-anchored amount detection. Requires a currency marker adjacent +# to the number so URL fragments (e.g. ``/667851``) don't false-positive. +# Covers symbol-then-number ($780) and number-then-code (780 USD). +# +# Bare percentages (``75%``, ``99.9%``) are deliberately NOT matched +# here — they fire on benign status / progress text ("75% complete", +# "99.9% uptime") under OR semantics. Real percentage-bearing +# commitments ("we'll give you a 20% discount", "refund 100%") still +# fire via the verb pattern. +_COMMITMENT_AMOUNT_FALLBACK = re.compile( + r"(?:\$|€|£|¥|₹|USD|EUR|GBP|JPY|INR)\s*\d[\d,]*(?:\.\d+)?" + r"|\b\d[\d,]*(?:\.\d+)?\s*(?:USD|EUR|GBP|JPY|INR|" + r"dollars?|euros?|pounds?|yen|rupees?)\b" +) +_COMMITMENT_DEADLINE_PATTERN = re.compile( + r"(?i)\bwithin\s+\d+\s*(?:second|minute|hour|day|week|month|year)s?\b" + r"|\bby\s+(?:tomorrow|next\s+\w+|\d+/\d+(?:/\d+)?)\b" +) + + +class GovernanceEvaluator: + """Evaluates governance rules against check contexts. + + Supports two enforcement modes: + + - ``AUDIT``: log all violations but never block (DENY collapses to + AUDIT in the final action). + - ``ENFORCE``: actually block on DENY rules — raises + :class:`GovernanceBlockException` and the agent stops. + + All dependencies (mode, audit manager, compensator) are injected + via the constructor. The evaluator does not consult any + process-global state — parallel runtimes (``uipath eval``) get + their own evaluator with their own audit + compensation pipelines. + """ + + def __init__( + self, + policy_index: PolicyIndex, + *, + enforcement_mode: EnforcementMode = EnforcementMode.AUDIT, + audit_manager: AuditManager | None = None, + compensator: GuardrailCompensator | None = None, + ) -> None: + """Initialize with a compiled policy index and runtime-scoped deps. + + Args: + policy_index: The compiled :class:`PolicyIndex` to evaluate. + 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 ``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 + leave this out. + compensator: Per-runtime :class:`GuardrailCompensator` + used to dispatch ``/runtime/govern`` POSTs for + guardrail-fallback rules. When ``None`` such dispatch + is skipped — the evaluator still records the matched + rules in the :class:`AuditRecord`. + """ + self._policy_index = policy_index + self._enforcement_mode = enforcement_mode + self._audit_manager = audit_manager + self._compensator = compensator + + @property + def policy_index(self) -> PolicyIndex: + """Return the compiled policy index this evaluator runs against.""" + return self._policy_index + + @property + def mode(self) -> EnforcementMode: + """The enforcement mode this evaluator applies.""" + return self._enforcement_mode + + def is_audit_mode(self) -> bool: + """Check if running in audit-only mode.""" + return self._enforcement_mode == EnforcementMode.AUDIT + + def evaluate(self, context: CheckContext) -> AuditRecord: + """Evaluate rules registered for ``context.hook`` against the context. + + Only rules whose ``hook`` field matches the current lifecycle hook + are evaluated — a ``tool_call`` rule does not fire on + ``before_model``, and vice versa. This avoids running checks + against fields the context cannot provide and keeps the audit + stream scoped to the active phase. + + The final action depends on the enforcement mode: + - DISABLED mode: Short-circuit; no rules evaluated, no audit emitted. + - AUDIT mode: Even DENY rules result in AUDIT action (log only, don't block) + - ENFORCE mode: DENY rules result in DENY action AND a + :class:`GovernanceBlockException` is raised. + + Audit events (per-rule + hook summary) are emitted via the + :class:`AuditManager` injected at construction (skipped when + none was supplied). + + Args: + context: The check context with hook and content + + Returns: + AuditRecord with all evaluations and final action. + + Raises: + GovernanceBlockException: In ENFORCE mode when a DENY rule matches. + """ + mode = self._enforcement_mode + if mode == EnforcementMode.DISABLED: + return AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name=context.agent_name, + runtime_id=context.runtime_id, + hook=context.hook, + evaluations=[], + final_action=Action.ALLOW, + metadata={**context.metadata, "enforcement_mode": mode.value}, + ) + + rules = self._policy_index.get_rules_for_hook(context.hook) + + evaluations: list[RuleEvaluation] = [] + raw_action = Action.ALLOW # The action before mode adjustment + deny_would_fire = False # Track if DENY would have fired + + for rule in rules: + if not rule.enabled: + continue + + evaluation = self._evaluate_rule(rule, context) + evaluations.append(evaluation) + + if evaluation.matched: + # Take the most restrictive action. Use evaluation.action + # (which already folds in per-check overrides), not + # rule.action, so check-level overrides are honored here too. + eval_action = evaluation.action + if eval_action == Action.DENY: + raw_action = Action.DENY + deny_would_fire = True + elif eval_action == Action.ESCALATE and raw_action != Action.DENY: + raw_action = Action.ESCALATE + elif eval_action == Action.AUDIT and raw_action == Action.ALLOW: + raw_action = Action.AUDIT + + # Apply enforcement mode + final_action = self._apply_enforcement_mode(raw_action) + + # Build metadata with mode info + record_metadata = dict(context.metadata) + record_metadata["enforcement_mode"] = mode.value + if deny_would_fire and self.is_audit_mode(): + record_metadata["audit_mode_would_deny"] = True + + audit = AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name=context.agent_name, + runtime_id=context.runtime_id, + hook=context.hook, + evaluations=evaluations, + final_action=final_action, + metadata=record_metadata, + ) + + self._emit_audit(audit, mode) + + # For any guardrail mapped to UiPath but currently disabled, hand + # the disabled guardrails to the governance-server's + # /runtime/govern endpoint. The SERVER runs the guardrail check + # AND writes the trace (the payload carries traceId / src_timestamp + # / hook / agent so it can correlate) — the agent does NOT emit a + # trace itself, to avoid double-writing. Fire-and-forget on a + # daemon thread so a slow or unreachable endpoint never blocks + # the agent. + self._dispatch_compensation(audit, context) + + if final_action == Action.DENY: + raise GovernanceBlockException.from_audit_record(audit) + + return audit + + def _dispatch_compensation( + self, audit: AuditRecord, context: CheckContext + ) -> None: + """Schedule compensating governance for any matched fallback rules. + + Delegates to the injected :class:`GuardrailCompensator`. The + compensator owns concurrency, queue caps, exception isolation, + and graceful process-exit cancellation — this method just + builds the payload, logs the summary, and submits. + + No-op when no compensator was supplied at construction (e.g. + unit tests that don't care about the dispatch path). + """ + if self._compensator is None: + return + + try: + disabled = disabled_guardrails(audit, self._policy_index) + if not disabled: + return + + # Distinct validator names for the operator-facing log line. + validators = [rule.validator for rule in disabled] + + # Surface the disabled-guardrail fire-up: how many rules + # triggered the compensating call, and which validators + # they map to (e.g. pii_detection / prompt_injection / + # harmful_content). One line per dispatch so an operator + # can see the volume + breakdown at a glance. + logger.info( + "Compensating governance triggered: hook=%s, count=%d, validators=[%s]", + audit.hook.value, + len(disabled), + ", ".join(validators), + ) + + self._compensator.submit( + rules=disabled, + data=_compensation_data_for_hook(context), + hook=audit.hook.value, + src_timestamp=audit.timestamp.isoformat(), + agent_name=audit.agent_name, + runtime_id=audit.runtime_id, + ) + except Exception as exc: # noqa: BLE001 - fail-open + logger.warning( + "Failed to dispatch compensating governance call: %s", exc + ) + + def _emit_audit(self, audit: AuditRecord, mode: EnforcementMode) -> None: + """Emit per-rule and hook-summary events to the injected audit manager. + + No-op when no audit manager was supplied at construction. The + per-runtime :class:`AuditManager` handles sink-level circuit + breaking; emission errors stay there and never break evaluation. + """ + manager = self._audit_manager + if manager is None: + return + + hook_name = audit.hook.name + + # ``guardrail_fallback`` rules are server-traced: the agent POSTs + # to ``/runtime/govern`` (see :meth:`_dispatch_compensation`) and + # the governance-server emits the audit event with the actual + # validator verdict. Emitting a Python-side ``rule_evaluation`` + # event here would produce a duplicate trace carrying no + # verdict, so filter these rules out of every event the Python + # evaluator emits (per-rule AND the hook summary's counts). + emittable = [ + ev for ev in audit.evaluations + if not self._is_guardrail_fallback_rule(ev.rule_id) + ] + + for evaluation in emittable: + manager.emit_rule_evaluation( + policy_id=evaluation.rule_id, + rule_name=evaluation.rule_name, + pack_name=evaluation.pack_name, + hook=hook_name, + matched=evaluation.matched, + action=evaluation.action.value if evaluation.matched else "allow", + enforcement_mode=mode, + detail=evaluation.detail, + agent_name=audit.agent_name, + description=evaluation.description, + ) + + manager.emit_hook_summary( + hook=hook_name, + agent_name=audit.agent_name, + total_rules=len(emittable), + matched_rules=sum(1 for ev in emittable if ev.matched), + final_action=audit.final_action.value, + enforcement_mode=mode, + ) + + def _is_guardrail_fallback_rule(self, rule_id: str) -> bool: + """Return True if the rule is a UiPath-compensating fallback rule. + + Such rules carry a ``guardrail_fallback`` condition; their audit + trace is emitted by the governance-server in response to the + ``/runtime/govern`` POST, so the Python evaluator must not emit + a duplicate trace for them. + """ + rule = self._policy_index.get_rule(rule_id) + if rule is None: + return False + for check in rule.checks: + for cond in check.conditions: + if cond.operator == "guardrail_fallback": + return True + return False + + def _apply_enforcement_mode(self, raw_action: Action) -> Action: + """Apply enforcement mode to the raw action. + + In AUDIT mode: + - DENY becomes AUDIT (log but don't block) + - ESCALATE becomes AUDIT (log but don't escalate) + - AUDIT stays AUDIT + - ALLOW stays ALLOW + + In ENFORCE mode: + - All actions pass through unchanged + """ + if self._enforcement_mode == EnforcementMode.AUDIT: + if raw_action in (Action.DENY, Action.ESCALATE): + return Action.AUDIT + return raw_action + + def evaluate_before_agent( + self, + agent_input: str, + agent_name: str, + runtime_id: str, + model_name: str = "", + **kwargs: Any, + ) -> AuditRecord: + """Evaluate BEFORE_AGENT rules.""" + context = CheckContext( + hook=LifecycleHook.BEFORE_AGENT, + agent_name=agent_name, + runtime_id=runtime_id, + agent_input=agent_input, + model_name=model_name, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_agent( + self, + agent_output: str, + agent_name: str, + runtime_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_AGENT rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_AGENT, + agent_name=agent_name, + runtime_id=runtime_id, + agent_output=agent_output, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_before_model( + self, + model_input: str, + agent_name: str, + runtime_id: str, + messages: list[dict[str, Any]] | None = None, + model_name: str = "", + **kwargs: Any, + ) -> AuditRecord: + """Evaluate BEFORE_MODEL rules.""" + context = CheckContext( + hook=LifecycleHook.BEFORE_MODEL, + agent_name=agent_name, + runtime_id=runtime_id, + model_input=model_input, + model_name=model_name, + messages=messages or [], + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_model( + self, + model_output: str, + agent_name: str, + runtime_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_MODEL rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_MODEL, + agent_name=agent_name, + runtime_id=runtime_id, + model_output=model_output, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_tool_call( + self, + tool_name: str, + tool_args: dict[str, Any], + agent_name: str, + runtime_id: str, + session_state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate TOOL_CALL rules.""" + context = CheckContext( + hook=LifecycleHook.TOOL_CALL, + agent_name=agent_name, + runtime_id=runtime_id, + tool_name=tool_name, + tool_args=tool_args, + session_state=session_state or {}, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_tool( + self, + tool_name: str, + tool_result: str, + agent_name: str, + runtime_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_TOOL rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_TOOL, + agent_name=agent_name, + runtime_id=runtime_id, + tool_name=tool_name, + tool_result=tool_result, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def _evaluate_rule(self, rule: Rule, context: CheckContext) -> RuleEvaluation: + """Evaluate a single rule against the context.""" + if not rule.checks: + # No checks = always matches (for audit-only rules) + return RuleEvaluation( + rule_id=rule.rule_id, + rule_name=rule.name, + matched=True, + detail="Rule has no conditions (always matches)", + pack_name=rule.pack_name, + action=rule.action, + description=rule.description, + ) + + check_results: list[dict[str, Any]] = [] + any_check_matched = False + # Resolve the rule's action from the MATCHED checks so per-check + # `action` overrides take effect. ``Check.action`` defaults to the + # rule's action (see _yaml_to_index), so for rules without an + # override this equals ``rule.action`` exactly. Take the most + # restrictive matched action (DENY > ESCALATE > AUDIT > ALLOW), + # mirroring evaluate()'s cross-rule aggregation. + matched_action = Action.ALLOW + + for check in rule.checks: + matched, detail = self._evaluate_check(check, context) + check_results.append( + { + "matched": matched, + "detail": detail, + "action": check.action.value, + } + ) + if matched: + any_check_matched = True + if check.action == Action.DENY: + matched_action = Action.DENY + elif ( + check.action == Action.ESCALATE + and matched_action != Action.DENY + ): + matched_action = Action.ESCALATE + elif ( + check.action == Action.AUDIT + and matched_action == Action.ALLOW + ): + matched_action = Action.AUDIT + + # Surface the FIRST matched check's message; falls back to the + # first check's detail (empty string when none matched) for + # backward compatibility with rules that have a single check. + first_matched_detail = next( + (cr["detail"] for cr in check_results if cr["matched"]), + check_results[0]["detail"] if check_results else "", + ) + + return RuleEvaluation( + rule_id=rule.rule_id, + rule_name=rule.name, + matched=any_check_matched, + detail=first_matched_detail, + pack_name=rule.pack_name, + action=matched_action if any_check_matched else Action.ALLOW, + description=rule.description, + check_results=check_results, + ) + + def _evaluate_check(self, check: Check, context: CheckContext) -> tuple[bool, str]: + """Evaluate a single check against the context.""" + if not check.conditions: + return True, "No conditions (always matches)" + + results = [] + for condition in check.conditions: + matched = self._evaluate_condition(condition, context) + results.append(matched) + + if check.logic == "any": + final_match = any(results) + else: # "all" is default + final_match = all(results) + + detail = check.message if final_match else "" + return final_match, detail + + def _evaluate_condition(self, condition: Condition, context: CheckContext) -> bool: + """Evaluate a single condition against the context.""" + field_value = self._get_field_value(condition.field, context) + result = self._apply_operator(condition.operator, field_value, condition.value) + + if condition.negate: + result = not result + + return result + + def _get_field_value(self, field: str, context: CheckContext) -> Any: + """Get a field value from the context.""" + parts = field.split(".") + + # Start with context + value: Any = context + + for part in parts: + if hasattr(value, part): + value = getattr(value, part) + elif isinstance(value, dict) and part in value: + value = value[part] + else: + return None + + return value + + def _apply_operator( + self, operator: str, field_value: Any, check_value: Any + ) -> bool: + """Apply an operator to compare field value against check value.""" + # Handle existence checks before the None check + if operator == "exists": + return field_value is not None + if operator == "not_exists": + return field_value is None + + # guardrail_fallback fires only when the guardrail is mapped to + # UiPath but its policy is disabled. Config travels in + # ``check_value``; the rule's ``field`` is unused (so + # ``field_value`` is ``None`` here, which is expected — we must + # special-case this before the generic ``None`` short-circuit + # below). + if operator == "guardrail_fallback": + cfg = check_value if isinstance(check_value, dict) else {} + return bool(cfg.get("mapped_to_uipath", False)) and not bool( + cfg.get("policy_enabled", True) + ) + + if field_value is None: + return False + + # Numeric operators don't need stringification — short-circuit + # before `str(field_value)` (expensive for dict / large payloads). + if operator in ("gt", "gte", "lt", "lte"): + try: + lhs = float(field_value) + rhs = float(check_value) + except (ValueError, TypeError): + return False + if operator == "gt": + return lhs > rhs + if operator == "gte": + return lhs >= rhs + if operator == "lt": + return lhs < rhs + return lhs <= rhs + + field_str = str(field_value) + + match operator: + case "equals" | "eq": + return field_str == str(check_value) + + case "not_equals" | "ne": + return field_str != str(check_value) + + case "contains": + return str(check_value).lower() in field_str.lower() + + case "not_contains": + return str(check_value).lower() not in field_str.lower() + + case "regex" | "matches": + compiled = _compile_regex(str(check_value)) + if compiled is None: + return False + return bool(compiled.search(field_str)) + + case "in_list": + if isinstance(check_value, list): + return field_str in check_value + return False + + case "not_in_list": + if isinstance(check_value, list): + return field_str not in check_value + return True + + case "vader_concern": + # VADER compound score <= threshold. + # check_value: dict like {"threshold": -0.3} (default -0.3) + return self._check_vader_concern(field_str, check_value) + + case "encoding_concern": + # chardet-backed encoding integrity check (A.7.4). + # check_value: dict with optional `min_confidence` (default 0.5) + # and `max_replacement_ratio` (default 0.05). + return self._check_encoding_concern(field_str, check_value) + + case "entropy_concern": + # Shannon entropy outside expected range (A.7.4). + # check_value: dict with optional `min` (default 1.5) and + # `max` (default 7.5) bits/byte. Stdlib only. + return self._check_entropy_concern(field_str, check_value) + + case "incident_concern": + # Categorical incident detection (A.8.4). + # check_value: dict with optional `categories` list + # (subset of safety_refusal/tool_failure/auth_failure/ + # quota_exceeded/hallucination). Default: all categories. + return self._check_incident_concern(field_str, check_value) + + case "commitment_concern": + # Customer commitment language detection (A.10.4). + # check_value: dict with optional `require_amount` (default + # True) and `require_deadline` (default False). Fires when + # a commitment verb co-occurs with the configured signals. + return self._check_commitment_concern(field_str, check_value) + + case _: + logger.debug("Unknown operator: %s", operator) + return False + + @staticmethod + def _check_vader_concern(text: str, params: Any) -> bool: + """Return True if VADER compound score on `text` is <= threshold. + + Args: + text: Text to analyse. + params: Either a dict with `threshold` key, or a numeric threshold + directly. Default threshold is -0.3 (clearly-negative). + + Returns: + True iff vaderSentiment is available AND compound score <= threshold. + Returns False on empty input or if the library is not installed — + sentiment checks no-op rather than crash. + """ + if not text or not text.strip(): + return False + + analyzer = _get_vader_analyzer() + if analyzer is None: + return False + + if isinstance(params, dict): + threshold = float(params.get("threshold", -0.3)) + else: + try: + threshold = float(params) + except (TypeError, ValueError): + threshold = -0.3 + + try: + compound = float(analyzer.polarity_scores(text)["compound"]) + except Exception as exc: # pragma: no cover - defensive + logger.debug("VADER analysis failed: %s", exc) + return False + + return compound <= threshold + + @staticmethod + def _check_encoding_concern(text: str, params: Any) -> bool: + r"""Return True if `text` shows encoding integrity issues. + + Sums multiple deterministic corruption signals against text length: + - U+FFFD replacement characters (already-decoded lossy text) + - Literal ``�`` escape sequences carried through a JSON + / repr layer rather than being decoded + - Literal ``\xHH`` hex escapes (raw bytes leaked into a string) + - Latin-1-as-UTF-8 mojibake bigrams (e.g. ``é``, ``’``) + If the corruption ratio exceeds ``max_replacement_ratio`` the + check fires. chardet (when installed) is consulted as a + secondary low-confidence signal. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + min_confidence = float(params.get("min_confidence", 0.5)) + max_replacement_ratio = float(params.get("max_replacement_ratio", 0.05)) + min_corruption_events = int(params.get("min_corruption_events", 2)) + + length = max(len(text), 1) + + replacement_chars = text.count("�") + literal_ufffd_escapes = text.count("\\ufffd") + hex_escapes = len(_HEX_ESCAPE_PATTERN.findall(text)) + mojibake_bigrams = sum(text.count(bigram) for bigram in _MOJIBAKE_BIGRAMS) + + # Absolute count of distinct corruption *events* (one per + # U+FFFD, one per literal escape sequence, one per mojibake + # bigram). Even diluted by a lot of clean text, a few of these + # in production output is a strong signal. + corruption_events = ( + replacement_chars + literal_ufffd_escapes + hex_escapes + mojibake_bigrams + ) + if corruption_events >= min_corruption_events: + return True + + # Ratio-based fallback for cases below the absolute floor: still + # catches very short payloads where a single corruption char is + # disproportionate. + # Weight each event by its source-char span so denser corruption + # in shorter text trips the ratio sooner: + # U+FFFD = 1 char, "�" = 6 chars, "\xHH" = 4 chars, + # mojibake bigram = 2 chars. + corruption_chars = ( + replacement_chars + + 6 * literal_ufffd_escapes + + 4 * hex_escapes + + 2 * mojibake_bigrams + ) + if corruption_chars / length > max_replacement_ratio: + return True + + # Secondary: chardet on the encoded bytes. For pure str input + # this almost always reports high UTF-8/ASCII confidence (the + # branch is intentionally permissive), but it does catch bytes + # routed through `repr()` or `__str__` of a `bytes` object that + # chardet recognises as a non-UTF8 encoding with low confidence. + chardet = _get_chardet() + if chardet is None: + return False + try: + detection = chardet.detect(text.encode("utf-8", errors="replace")) + confidence = float(detection.get("confidence") or 0.0) + except Exception as exc: # pragma: no cover - defensive + logger.debug("chardet detection failed: %s", exc) + return False + + return confidence < min_confidence + + @staticmethod + def _check_entropy_concern(text: str, params: Any) -> bool: + """Return True if Shannon entropy of `text` is outside an expected range. + + Stdlib-only. Entropy is computed in bits per symbol over byte + frequencies. English prose typically lands ~3.5–4.5 bits/byte; + binary noise approaches 8 bits/byte; constant/repetitive text + approaches 0. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + lo = float(params.get("min", 1.5)) + hi = float(params.get("max", 7.5)) + + data = text.encode("utf-8", errors="replace") + total = len(data) + if total == 0: + return False + + counts = Counter(data) + entropy = 0.0 + for c in counts.values(): + p = c / total + entropy -= p * math.log2(p) + + return entropy < lo or entropy > hi + + @staticmethod + def _check_incident_concern(text: str, params: Any) -> bool: + """Return True if `text` matches any configured incident pattern (A.8.4). + + Categories: safety_refusal, tool_failure, auth_failure, + quota_exceeded, hallucination. Pass ``{"categories": [...]}`` to + restrict; default scans all categories. + """ + if not text or not text.strip(): + return False + + if isinstance(params, dict): + requested = params.get("categories") + else: + requested = None + + if not requested: + categories = list(_INCIDENT_PATTERNS.keys()) + else: + categories = [c for c in requested if c in _INCIDENT_PATTERNS] + + for category in categories: + for pattern in _INCIDENT_PATTERNS[category]: + if pattern.search(text): + return True + return False + + @staticmethod + def _check_commitment_concern(text: str, params: Any) -> bool: + """Return True if `text` carries customer-commitment language (A.10.4). + + OR semantics: a commitment-verb match always fires; when + ``require_amount`` is true, a currency-anchored amount alone also + fires; when ``require_deadline`` is true, a deadline phrase alone + also fires. With both flags false the rule matches on verb only + (verb-only mode). + + The verb pattern covers first-person promise verbs *and* proposal + / SOW commitment markers ("Cost: $X", "fixed scope", + "Deliverables", "Timeline: N days", "I propose"). The amount + pattern requires a currency marker adjacent to the number so URL + fragments don't false-positive. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + require_amount = bool(params.get("require_amount", True)) + require_deadline = bool(params.get("require_deadline", False)) + + verb_match = bool(_COMMITMENT_VERB_PATTERN.search(text)) + + # Verb-only mode: neither supporting signal is enabled. + if not require_amount and not require_deadline: + return verb_match + + amount_match = require_amount and bool( + _COMMITMENT_AMOUNT_FALLBACK.search(text) + ) + deadline_match = require_deadline and bool( + _COMMITMENT_DEADLINE_PATTERN.search(text) + ) + return verb_match or amount_match or deadline_match diff --git a/src/uipath/runtime/governance/native/guardrail_compensation.py b/src/uipath/runtime/governance/native/guardrail_compensation.py new file mode 100644 index 00000000..d3466115 --- /dev/null +++ b/src/uipath/runtime/governance/native/guardrail_compensation.py @@ -0,0 +1,311 @@ +"""Compensating governance for disabled centralized guardrails. + +When a ``guardrail_fallback`` rule fires (the guardrail is mapped to +UiPath but the centralized policy is disabled), the framework asks the +governance-server to run the real guardrail check via its +``/{org_id}/agenticgovernance_/api/v1/runtime/govern`` endpoint. + +This module owns only the **local concerns**: a bounded background +pool that schedules the call without blocking the agent hook, and a +trace-id capture that runs on the caller thread before the worker hop +(the worker has no OpenTelemetry context). + +The actual HTTP call — URL composition, auth, headers, JSON +serialisation, env-backed job-context auto-fill — is the +:class:`uipath.core.governance.GovernanceCompensationProvider`'s job. +Callers inject a concrete provider implementation, and this module +just builds the :class:`GovernRequest` wire model and hands it off. + +The call is **fire-and-forget**: the server runs the guardrail AND +writes the audit trace from its side. The agent doesn't inspect the +response — it only cares about whether the call reached the server. + +The compensator is **instance-scoped**: each :class:`GovernanceRuntime` +owns its own pool and semaphore. ``uipath eval`` parallel runtimes +don't share workers, queue slots, or saturation state — one runtime's +spam can't silently drop another's compensation calls. + +The compensator does **not** read host env vars and does not resolve +trace ids itself. It propagates the caller's ``contextvars`` (which +hold the live OTel span) across the worker-thread hop via +:func:`contextvars.copy_context`, so the provider can resolve trace +context at HTTP-call time inside the captured context. +""" + +from __future__ import annotations + +import atexit +import contextvars +import logging +import threading +import weakref +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +from uipath.core.governance import ( + FiredRule, + GovernanceCompensationProvider, + GovernRequest, +) + +logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------------- +# Process-wide cleanup machinery +# +# One ``atexit`` hook walks a ``WeakSet`` of live compensators on exit and +# closes each. Bounded atexit registrations (N runtimes → 1 hook, not N) and +# weakref tracking so a disposed compensator can be GC'd. Same pattern as +# :class:`uipath.runtime.governance._audit.base.AuditManager`. +# ---------------------------------------------------------------------------- + +_live_compensators: weakref.WeakSet[GuardrailCompensator] = weakref.WeakSet() +_atexit_registered = False +_atexit_lock = threading.Lock() + + +def _process_cleanup_compensators() -> None: + """Process-exit handler: close every live compensator.""" + for compensator in list(_live_compensators): + try: + compensator.close() + except Exception as exc: # noqa: BLE001 - exit cleanup must not raise + logger.debug("Compensator process cleanup error: %s", exc) + + +def _register_compensator_for_cleanup(compensator: GuardrailCompensator) -> None: + """Add ``compensator`` to the cleanup set + ensure atexit is wired once.""" + global _atexit_registered + _live_compensators.add(compensator) + if _atexit_registered: + return + with _atexit_lock: + if not _atexit_registered: + atexit.register(_process_cleanup_compensators) + _atexit_registered = True + + +# ---------------------------------------------------------------------------- +# Stateless helpers +# ---------------------------------------------------------------------------- + + +def disabled_guardrails(audit: Any, policy_index: Any) -> list[FiredRule]: + """Return per-rule metadata for each fired guardrail-fallback rule. + + A guardrail rule fires only when it is mapped to UiPath + (``mapped_to_uipath`` true) but disabled (``policy_enabled`` false) — + see the ``guardrail_fallback`` operator. The validator name (e.g. + ``pii_detection``) is read from the rule's ``guardrail_fallback`` + check config and used as the validator on the compensating call. + + One :class:`FiredRule` entry is emitted per matching + ``guardrail_fallback`` condition. Rules in this codebase declare a + single fallback condition each, so the returned list has one entry + per fired rule in practice; multi-condition rules would emit more + than one entry sharing the same ``rule_id``. + """ + out: list[FiredRule] = [] + for ev in audit.evaluations: + if not ev.matched: + continue + rule = policy_index.get_rule(ev.rule_id) + if rule is None: + continue + for check in rule.checks: + for cond in check.conditions: + if cond.operator != "guardrail_fallback": + continue + if not isinstance(cond.value, dict): + continue + # The ``guardrail_fallback`` operator at evaluation time + # only matches when ``mapped_to_uipath=True`` AND + # ``policy_enabled=False``. We re-check here defensively + # so a future code path that bypasses the evaluator (or + # a multi-condition rule that fired on a sibling check) + # can't trigger a compensation call for a guardrail + # that isn't actually disabled. + if not bool(cond.value.get("mapped_to_uipath", False)): + continue + if bool(cond.value.get("policy_enabled", True)): + continue + validator = str(cond.value.get("validator", "")) + if validator: + out.append( + FiredRule( + rule_id=ev.rule_id, + rule_name=ev.rule_name, + pack_name=getattr(rule, "pack_name", "") or "", + validator=validator, + ) + ) + return out + + +def _validators(rules: list[FiredRule]) -> list[str]: + """Distinct validator names from the fired rules, preserving order.""" + return list(dict.fromkeys(r.validator for r in rules if r.validator)) + + +# ---------------------------------------------------------------------------- +# GuardrailCompensator +# ---------------------------------------------------------------------------- + + +class GuardrailCompensator: + """Instance-scoped compensating-governance dispatcher. + + Each :class:`GovernanceRuntime` constructs one. Owns: + + - A :class:`ThreadPoolExecutor` (default 4 workers) that runs the + ``/runtime/govern`` POST off the agent's hook thread. + - A :class:`threading.BoundedSemaphore` (default cap = workers × 4) + that bounds total in-flight submissions (running + queued) so a + misbehaving agent firing compensation faster than the server can + absorb can't grow memory without limit. Saturated submissions are + dropped with a warning. + + Process exit cancels queued work via a single process-level atexit + handler (see :func:`_process_cleanup_compensators`); running tasks + finish bounded by the provider's HTTP timeout. + + Fire-and-forget: :meth:`submit` returns immediately. The actual HTTP + work is delegated to :meth:`GovernanceCompensationProvider.compensate` + — this class never touches URL/headers/auth/JSON itself. + """ + + _DEFAULT_MAX_WORKERS = 4 + # Queue depth multiplier — total in-flight cap = max_workers × this. + _INFLIGHT_OVERSUBSCRIPTION = 4 + + def __init__( + self, + provider: GovernanceCompensationProvider, + *, + max_workers: int = _DEFAULT_MAX_WORKERS, + inflight_oversubscription: int = _INFLIGHT_OVERSUBSCRIPTION, + ) -> None: + """Construct a compensator bound to one provider. + + The compensator does not carry a trace id. Trace-id resolution + is the provider's responsibility at HTTP-call time. To preserve + live OTel context across the thread-pool hop (worker threads + don't inherit ``contextvars``), :meth:`submit` runs the worker + callable inside a snapshot captured via + :func:`contextvars.copy_context` — so the caller's OTel span is + still visible when the provider runs on the worker. + + Args: + provider: The :class:`GovernanceCompensationProvider` that + actually fires the ``/runtime/govern`` POST. + max_workers: Concurrent worker threads in the pool. + inflight_oversubscription: How deep the work queue grows + before saturated submissions get dropped. Total cap is + ``max_workers * inflight_oversubscription``. + """ + self._provider = provider + self._inflight_cap = max_workers * inflight_oversubscription + self._pool = ThreadPoolExecutor( + max_workers=max_workers, + thread_name_prefix="governance-compensation", + ) + self._inflight = threading.BoundedSemaphore(self._inflight_cap) + _register_compensator_for_cleanup(self) + + def submit( + self, + rules: list[FiredRule], + data: dict[str, Any], + hook: str, + src_timestamp: str, + agent_name: str, + runtime_id: str, + ) -> None: + """Schedule a /runtime/govern call on the bounded background pool. + + Fire-and-forget. Returns immediately; the call runs on a worker + thread. When the in-flight queue is saturated the call is + dropped with a warning and the agent continues. + + ``rules`` is the per-rule metadata from :func:`disabled_guardrails`; + the validators sent to the guardrail API are derived from it. + + The current :mod:`contextvars` context (which carries the live + OpenTelemetry span) is captured here and re-applied inside the + worker via :meth:`contextvars.Context.run`. This lets the + provider see the live OTel context on the worker thread — + without the snapshot the worker would inherit an empty context + and the provider could only resolve env-based trace ids. + + Never raises — including when the pool has already been shut down. + """ + if not rules: + return + + validators = _validators(rules) + if not validators: + return + + if not self._inflight.acquire(blocking=False): + logger.warning( + "Compensation pool saturated (>%d in flight); dropping call " + "(validators=[%s])", + self._inflight_cap, + ", ".join(validators), + ) + return + + request = GovernRequest( + validators=validators, + rules=rules, + data=data, + hook=hook, + src_timestamp=src_timestamp, + agent_name=agent_name, + runtime_id=runtime_id, + ) + + provider = self._provider + inflight = self._inflight + # Snapshot the caller's contextvars (OTel span lives in there + # for Python OTel >= 1.x). The worker runs inside this snapshot + # so the provider sees the live span at HTTP-call time. + ctx = contextvars.copy_context() + + def _run() -> None: + try: + provider.compensate(request) + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning( + "Compensation worker failed (validators=[%s]): %s", + ", ".join(validators), + exc, + ) + finally: + inflight.release() + + try: + self._pool.submit(ctx.run, _run) + except RuntimeError as exc: + # Pool was shut down (atexit, dispose, or test teardown) — + # release the semaphore slot we took and log; never raise. + self._inflight.release() + logger.warning( + "Compensation pool unavailable (validators=[%s]): %s", + ", ".join(validators), + exc, + ) + + def close(self) -> None: + """Cancel queued tasks. Running tasks finish bounded by the provider HTTP timeout. + + ``wait=False`` returns immediately so caller / process shutdown + isn't held up; ``cancel_futures=True`` drops anything not yet + running. Idempotent — calling close on an already-closed pool + is a logged no-op. + """ + try: + self._pool.shutdown(wait=False, cancel_futures=True) + except Exception as exc: # noqa: BLE001 - shutdown must not raise + logger.debug("Compensator shutdown error: %s", exc) diff --git a/src/uipath/runtime/governance/native/loader.py b/src/uipath/runtime/governance/native/loader.py deleted file mode 100644 index 5b45d210..00000000 --- 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 c8f9dd94..ab3d177d 100644 --- a/src/uipath/runtime/governance/runtime.py +++ b/src/uipath/runtime/governance/runtime.py @@ -1,36 +1,45 @@ """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`` explicitly when it knows the agent type. -The runtime layer does not introspect the delegate's private attributes -to discover that. - -**Staging caveat — policy loading only, no enforcement yet.** This -module is the policy-loading scaffold: ``__init__`` constructs an -instance-scoped :class:`PolicyLoader` and kicks off a background -prefetch. ``execute`` / ``stream`` / ``get_schema`` / ``dispose`` are -pure passthroughs — no per-hook policy evaluation runs. The evaluator -and framework adapter wiring that consumes the loader's policy index -lands in a follow-up slice. Customers constructing -:class:`GovernanceRuntime` today get policy loading without policy -enforcement; this is intentional and will change when the evaluator -slice merges. +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, retains no policy provider, and reads no +host environment variables. + +The caller (typically the host CLI) 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. + +Trace-id is intentionally **not** carried on this wrapper. The +governance compensator captures the live OTel context across the +thread-pool hop via :func:`contextvars.copy_context`, and the +injected provider resolves the canonical trace id at HTTP-call time. +The runtime layer is fully env-free for this path. """ from __future__ import annotations +import json import logging from typing import Any, AsyncGenerator -from uipath.core.governance import GovernancePolicyProvider +from uipath.core.governance import EnforcementMode +from uipath.core.governance.exceptions import GovernanceBlockException +from uipath.core.serialization import serialize_object from uipath.runtime.base import ( UiPathExecuteOptions, @@ -38,89 +47,166 @@ UiPathStreamOptions, ) from uipath.runtime.events import UiPathRuntimeEvent -from uipath.runtime.governance.native.loader import PolicyLoader +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 logger = logging.getLogger(__name__) -class GovernanceRuntime: +def _serialize_payload(payload: Any) -> str: + """Serialize an agent input / output to a string for evaluator checks. + + The native evaluator's BEFORE_AGENT / AFTER_AGENT checks scan a + flat string. ``None`` becomes ``""``, ``str`` passes through (so + regex / sentiment checks don't see JSON quotes around the bare + text), and everything else is normalized via + :func:`uipath.core.serialization.serialize_object` (handles + Pydantic / dataclass / datetime / nested structures) and then + JSON-encoded. + """ + if payload is None: + return "" + if isinstance(payload, str): + return payload + try: + return json.dumps(serialize_object(payload)) + except Exception: # noqa: BLE001 — last-resort string fallback + return str(payload) + + +class UiPathGovernedRuntime: """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. + Holds a caller-resolved :class:`PolicyIndex` and + :class:`EnforcementMode` for the lifetime of the instance. + ``execute`` / ``stream`` / ``get_schema`` / ``dispose`` forward to + the delegate. + + 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 + pass-through. """ def __init__( self, delegate: UiPathRuntimeProtocol, - policy_provider: GovernancePolicyProvider | None, + policy_index: PolicyIndex, + enforcement_mode: EnforcementMode, *, - is_conversational: bool | None = None, + evaluator: GovernanceEvaluator | None = None, + agent_name: str = "", + runtime_id: str = "", ): - """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. + 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 caller is + expected to skip wrapping entirely when the response + mode is :attr:`EnforcementMode.DISABLED`; this + 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 through to the evaluator's hook methods. + runtime_id: Runtime-instance id (conversation id, job id, + or a synthetic per-run id). Passed through so + per-runtime state routes cleanly. """ self._delegate = delegate - self._loader = PolicyLoader( - policy_provider, - is_conversational=is_conversational, - ) - self._loader.prefetch() - - @property - def loader(self) -> PolicyLoader: - """The instance-scoped policy loader. - - Exposed so adapters / evaluators wired into this runtime can - call :meth:`PolicyLoader.get_policy_index` at hook time. + self._policy_index = policy_index + self._enforcement_mode = enforcement_mode + self._evaluator = evaluator + self._agent_name = agent_name + self._runtime_id = runtime_id + + 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._loader + 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, + ) + 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, + ) + 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() diff --git a/tests/_helpers.py b/tests/_helpers.py deleted file mode 100644 index 2d3d924c..00000000 --- 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 ba76eca6..a6c5cd57 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:`UiPathGovernedRuntime` +# 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_commitment_concern.py b/tests/test_commitment_concern.py new file mode 100644 index 00000000..a46149b7 --- /dev/null +++ b/tests/test_commitment_concern.py @@ -0,0 +1,205 @@ +"""Tests for the commitment_concern check (A.10.4). + +The check now uses OR semantics: a verb match, an amount match, or a +deadline match is each sufficient when its enabling flag is on. With +both flags false the rule matches verb-only. + +The verb pattern also covers proposal / SOW style commitment markers +("Cost: $X", "fixed scope", "Deliverables", "Timeline", "I propose") +so formal-business commitments without first-person verbs still fire. + +Amount detection requires a currency marker adjacent to the number to +prevent URL fragments (forum-post IDs, image dimensions, etc.) from +false-positiving. +""" + +from __future__ import annotations + +import pytest + +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator + +# --------------------------------------------------------------------------- +# The proposal-style sample that originally slipped through the rule. +# Contains: "Cost: $780 (fixed for the above scope)", "Deliverables", +# "Timeline: 4 days total", "I propose", a forum URL with a 6-digit ID. +# Triple-quoted so we keep the line breaks the model produced. +# --------------------------------------------------------------------------- +SAMPLE_PROPOSAL = """To address your concerns, I reviewed the official UiPath site you referenced and relevant resources on uipath.com to inform a fast stabilization plan. Notable findings include: a community CI/CD sample for UiPath projects (https://forum.uipath.com/t/announcement-ci-cd-pipeline-sample-implementation-s-for-uipath-projects-alpha/667851). + +Here's how I propose we turn your software around quickly: + +Plan +- Triage (logs + reproduce) +- Quick stabilization + +Deliverables +- Defect triage report + +Timeline: 4 days total +- Day 1: Triage + reproduction + +Cost: $780 (fixed for the above scope) +""" + + +@pytest.mark.parametrize( + "text", + [ + "Cost: $780 (fixed for the above scope)", + "Deliverables: a, b, c", + "Timeline: 4 days total for the whole engagement", + "I propose we turn this around in a week", + "We will refund the difference", + "I'll deliver the report by Friday", + "the warranty covers parts only", + "fixed price of one hundred dollars", + ], +) +def test_verb_match_alone_fires(text: str) -> None: + """Each verb-style commitment marker fires on its own (verb-only mode).""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": False, "require_deadline": False} + ) + is True + ) + + +def test_full_proposal_sample_fires() -> None: + """The originally-missed proposal output now fires.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + SAMPLE_PROPOSAL, + {"require_amount": False, "require_deadline": False}, + ) + is True + ) + + +@pytest.mark.parametrize( + "text", + [ + "$780", + "We charge USD 1,200 per seat", + "The fee is 500 EUR", + ], +) +def test_amount_alone_fires_when_require_amount_true(text: str) -> None: + """Currency-anchored amount alone fires under OR semantics.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": False} + ) + is True + ) + + +@pytest.mark.parametrize( + "text", + [ + "Task is 75% complete.", + "We maintain 99.9% uptime.", + "Battery at 50%.", + "Score: 12%.", + ], +) +def test_bare_percentage_does_not_fire(text: str) -> None: + """Status-only percentages must not trigger commitment_concern. + + Regression for the prior ``\\d{1,3}\\s*%`` branch in the amount + regex, which fired on benign status / progress text. Real + percentage-bearing commitments ("we'll give a 20% discount") + still fire via the verb pattern. + """ + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": False} + ) + is False + ) + + +def test_percentage_with_verb_still_fires() -> None: + """A commitment verb co-occurring with a percentage still fires.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "We will refund 100% of the purchase price.", + {"require_amount": True, "require_deadline": False}, + ) + is True + ) + + +def test_amount_alone_does_not_fire_when_require_amount_false() -> None: + """Amount-only text is silent when require_amount=False and no verb.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "The list price is $780.", + {"require_amount": False, "require_deadline": False}, + ) + is False + ) + + +def test_deadline_alone_fires_when_require_deadline_true() -> None: + """Deadline phrase alone fires under OR semantics.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "Will be done within 5 days.", + {"require_amount": False, "require_deadline": True}, + ) + is True + ) + + +def test_url_fragment_digits_do_not_false_positive() -> None: + """A long URL with embedded digits is not a 'commitment'. + + Catches the prior price-parser misbehaviour where Price.fromstring() + picked up forum-post IDs (e.g. ``667851``) and conflated them with + unrelated currency symbols elsewhere in the text. + """ + text = ( + "See https://forum.example.com/t/topic/667851 for details — " + "no commitment language here." + ) + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": True} + ) + is False + ) + + +@pytest.mark.parametrize( + "text", + [ + "", + " ", + "Just chatting about the weather today.", + "The product is durable and well-made.", + ], +) +def test_no_signal_does_not_fire(text: str) -> None: + """Text without any commitment signal stays silent regardless of flags.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": True} + ) + is False + ) + + +def test_non_dict_params_treated_as_defaults() -> None: + """``params`` of the wrong type degrades to defaults rather than crashing.""" + assert ( + GovernanceEvaluator._check_commitment_concern("we will refund", None) + is True + ) + assert ( + GovernanceEvaluator._check_commitment_concern( + "no verbs here", "garbage" + ) + is False + ) diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py deleted file mode 100644 index 78230fd9..00000000 --- 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_evaluator.py b/tests/test_evaluator.py new file mode 100644 index 00000000..2039182f --- /dev/null +++ b/tests/test_evaluator.py @@ -0,0 +1,420 @@ +"""Tests for the audit + enforcement behavior of GovernanceEvaluator. + +The evaluator's three load-bearing responsibilities: + +1. DISABLED enforcement mode short-circuits — no rules evaluated, no + audit events emitted, no exceptions raised. +2. AUDIT mode evaluates rules and emits audit events, but transforms + matched DENY actions into AUDIT so execution continues. +3. ENFORCE mode evaluates, emits audit, and raises + :class:`GovernanceBlockException` when a DENY rule matches. + +Plus a fail-safe contract: a misbehaving audit sink must not stop +evaluation from completing or propagate as an exception. The +evaluator is constructed with explicit dependencies (audit manager, +enforcement mode); no process-globals are involved. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from uipath.core.governance import EnforcementMode +from uipath.core.governance.exceptions import GovernanceBlockException +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance._audit.base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, +) +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, +) + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +class _CapturingSink(AuditSink): + """Audit sink that records every event for assertions.""" + + def __init__(self) -> None: + self.events: list[AuditEvent] = [] + + @property + def name(self) -> str: + return "capturing" + + def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +def _deny_rule_on_input_contains(needle: str) -> Rule: + """Build a rule that DENIES when agent_input contains ``needle``.""" + return Rule( + rule_id="TEST-01", + name="Test deny on input", + clause="A.1.1", + hook=LifecycleHook.BEFORE_AGENT, + action=Action.DENY, + checks=[ + Check( + conditions=[ + Condition( + operator="contains", + field="agent_input", + value=needle, + ) + ], + action=Action.DENY, + message=f"Input must not contain {needle!r}", + ) + ], + ) + + +def _build_index_with(rule: Rule) -> PolicyIndex: + """Wrap a single rule in a one-pack PolicyIndex.""" + idx = PolicyIndex() + idx.add_pack( + PolicyPack( + name="test_pack", + version="1.0", + description="test", + rules=[rule], + ) + ) + return idx + + +def _ctx(agent_input: str) -> CheckContext: + return CheckContext( + hook=LifecycleHook.BEFORE_AGENT, + agent_name="test-agent", + runtime_id="run-1", + agent_input=agent_input, + ) + + +def _build_evaluator( + rule: Rule, + mode: EnforcementMode, + audit_manager: AuditManager | None = None, +) -> GovernanceEvaluator: + """Construct an evaluator with explicit deps — no process-globals involved.""" + return GovernanceEvaluator( + _build_index_with(rule), + enforcement_mode=mode, + audit_manager=audit_manager, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def audit_setup() -> Any: + """Per-test :class:`AuditManager` + capturing sink — no default sinks. + + Returns ``(manager, sink)`` so a test can build evaluators with the + manager and inspect emitted events through the sink. Synchronous + mode keeps assertions deterministic. + """ + manager = AuditManager(async_mode=False, register_default_sinks=False) + sink = _CapturingSink() + manager.register_sink(sink) + yield manager, sink + manager.close() + + +# --------------------------------------------------------------------------- +# DISABLED mode +# --------------------------------------------------------------------------- + + +def test_disabled_mode_short_circuits_with_empty_record(audit_setup: Any) -> None: + """DISABLED returns an empty AuditRecord and emits nothing.""" + manager, sink = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.DISABLED, + audit_manager=manager, + ) + + audit = evaluator.evaluate(_ctx("definitely contains secret")) + + assert audit.evaluations == [] + assert audit.final_action == Action.ALLOW + assert audit.metadata["enforcement_mode"] == "disabled" + assert sink.events == [] + + +def test_disabled_mode_does_not_raise_on_deny_match(audit_setup: Any) -> None: + """Even when a DENY rule WOULD match, DISABLED never raises.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.DISABLED, + audit_manager=manager, + ) + + # Must not raise. + evaluator.evaluate(_ctx("this is blocked")) + + +# --------------------------------------------------------------------------- +# AUDIT mode +# --------------------------------------------------------------------------- + + +def test_audit_mode_transforms_deny_to_audit(audit_setup: Any) -> None: + """AUDIT mode evaluates rules but never returns a DENY final_action.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + audit = evaluator.evaluate(_ctx("contains secret data")) + + assert len(audit.evaluations) == 1 + assert audit.evaluations[0].matched is True + assert audit.evaluations[0].action == Action.DENY # raw rule action preserved + assert audit.final_action == Action.AUDIT # mode-adjusted + assert audit.metadata["audit_mode_would_deny"] is True + + +def test_audit_mode_does_not_raise_on_deny_match(audit_setup: Any) -> None: + """AUDIT mode never raises GovernanceBlockException, even on a DENY hit.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + evaluator.evaluate(_ctx("this is blocked")) # must not raise + + +def test_audit_mode_emits_per_rule_and_summary_events(audit_setup: Any) -> None: + """One rule_evaluation event per rule + one hook_summary per evaluate().""" + manager, sink = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + evaluator.evaluate(_ctx("contains secret")) + + rule_events = [ + e for e in sink.events if e.event_type == EventType.RULE_EVALUATION + ] + summary_events = [ + e for e in sink.events if e.event_type == EventType.HOOK_END + ] + assert len(rule_events) == 1 + assert rule_events[0].hook == "BEFORE_AGENT" + assert rule_events[0].data["policy_id"] == "TEST-01" + assert rule_events[0].data["matched"] is True + assert rule_events[0].data["action"] == "deny" + # Mode travels on every event (PR #122 contract). + assert rule_events[0].data["enforcement_mode"] == EnforcementMode.AUDIT + + assert len(summary_events) == 1 + assert summary_events[0].data["matched_rules"] == 1 + assert summary_events[0].data["final_action"] == "audit" + assert summary_events[0].data["enforcement_mode"] == EnforcementMode.AUDIT + + +def test_audit_mode_unmatched_rule_logged_as_allow(audit_setup: Any) -> None: + """Unmatched rules still emit a rule_evaluation event with action='allow'.""" + manager, sink = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + evaluator.evaluate(_ctx("benign user query")) + + rule_events = [ + e for e in sink.events if e.event_type == EventType.RULE_EVALUATION + ] + assert len(rule_events) == 1 + assert rule_events[0].data["matched"] is False + assert rule_events[0].data["action"] == "allow" + + +# --------------------------------------------------------------------------- +# ENFORCE mode +# --------------------------------------------------------------------------- + + +def test_enforce_mode_raises_on_deny_match(audit_setup: Any) -> None: + """ENFORCE mode raises GovernanceBlockException when a DENY rule matches.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.ENFORCE, + audit_manager=manager, + ) + + with pytest.raises(GovernanceBlockException) as exc_info: + evaluator.evaluate(_ctx("input is blocked")) + + exc = exc_info.value + assert exc.rule_id == "TEST-01" + assert exc.rule_name == "Test deny on input" + assert exc.audit_record is not None + assert exc.audit_record.final_action == Action.DENY + + +def test_enforce_mode_emits_audit_before_raising(audit_setup: Any) -> None: + """The audit trail must be emitted even when the call raises.""" + manager, sink = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.ENFORCE, + audit_manager=manager, + ) + + with pytest.raises(GovernanceBlockException): + evaluator.evaluate(_ctx("contains blocked")) + + rule_events = [ + e for e in sink.events if e.event_type == EventType.RULE_EVALUATION + ] + summary_events = [ + e for e in sink.events if e.event_type == EventType.HOOK_END + ] + assert len(rule_events) == 1 + assert summary_events[0].data["final_action"] == "deny" + assert summary_events[0].data["enforcement_mode"] == EnforcementMode.ENFORCE + + +def test_enforce_mode_returns_record_when_no_rule_matches(audit_setup: Any) -> None: + """No DENY hit → no raise; the AuditRecord is returned normally.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.ENFORCE, + audit_manager=manager, + ) + + audit = evaluator.evaluate(_ctx("benign query")) + + assert audit.final_action == Action.ALLOW + assert audit.evaluations[0].matched is False + + +# --------------------------------------------------------------------------- +# Sink-failure isolation + no-audit-manager case +# --------------------------------------------------------------------------- + + +def test_sink_failure_does_not_propagate_or_block_evaluation( + audit_setup: Any, +) -> None: + """A broken sink must not make evaluate() raise or lose its return value. + + Contract: AuditManager wraps each sink's emit() in try/except with a + per-sink failure counter (circuit-breaker), so a sink exception + never propagates back to the evaluator. + """ + manager, capturing_sink = audit_setup + + class _BrokenSink(AuditSink): + @property + def name(self) -> str: + return "broken" + + def emit(self, event: AuditEvent) -> None: + raise RuntimeError("sink broke") + + manager.register_sink(_BrokenSink()) + + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + # Must complete without raising even with a broken sink registered. + audit = evaluator.evaluate(_ctx("contains secret")) + + assert audit.final_action == Action.AUDIT + # The non-broken capturing sink still got its events. + assert any( + e.event_type == EventType.RULE_EVALUATION for e in capturing_sink.events + ) + + +def test_no_audit_manager_short_circuits_emission() -> None: + """``audit_manager=None`` is a no-op — evaluation still completes. + + Replaces the previous test that mocked ``get_audit_manager`` to + raise. With explicit injection, the equivalent "no manager + available" state is simply ``audit_manager=None`` at construction. + """ + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=None, + ) + + # Must complete, return record, and not raise. + audit = evaluator.evaluate(_ctx("contains secret")) + + assert audit.final_action == Action.AUDIT + assert audit.evaluations[0].matched is True + + +# --------------------------------------------------------------------------- +# Protocol conformance smoke test +# --------------------------------------------------------------------------- + + +def test_governance_evaluator_satisfies_evaluator_protocol() -> None: + """GovernanceEvaluator must be usable wherever EvaluatorProtocol is expected. + + Mirrors the pattern from test_detached_bridge_satisfies_debug_protocol — + an explicit assignment to the protocol-typed variable documents the + structural contract. + """ + from uipath.core.adapters import EvaluatorProtocol + + evaluator: EvaluatorProtocol = GovernanceEvaluator(PolicyIndex()) + assert isinstance(evaluator, EvaluatorProtocol) + + +def test_evaluator_protocol_methods_resolvable_on_concrete() -> None: + """Every method the protocol declares must be callable on the concrete impl.""" + from uipath.core.adapters import EvaluatorProtocol + + evaluator: Any = GovernanceEvaluator(PolicyIndex()) + for method_name in ( + "evaluate_before_agent", + "evaluate_after_agent", + "evaluate_before_model", + "evaluate_after_model", + "evaluate_tool_call", + "evaluate_after_tool", + ): + assert callable(getattr(evaluator, method_name)) + # The variable annotation also asserts type compatibility at runtime + # because EvaluatorProtocol is @runtime_checkable. + assert isinstance(evaluator, EvaluatorProtocol) diff --git a/tests/test_evaluator_operators.py b/tests/test_evaluator_operators.py new file mode 100644 index 00000000..32e83c66 --- /dev/null +++ b/tests/test_evaluator_operators.py @@ -0,0 +1,672 @@ +"""Tests for ``GovernanceEvaluator`` operators and field resolution. + +Covers each operator implemented in :meth:`_apply_operator` plus the +``_check_*`` helper functions (vader, encoding, entropy, incident, +commitment) and the ``evaluate_*`` dispatchers. +""" + +from __future__ import annotations + +import pytest +from uipath.core.governance import EnforcementMode +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.native.evaluator import ( + _INCIDENT_PATTERNS, + GovernanceEvaluator, +) +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _evaluator() -> GovernanceEvaluator: + """Build a GovernanceEvaluator with an empty PolicyIndex (operators only). + + AUDIT is the default mode; operator tests don't care about + enforcement and we don't need an audit manager for purely + operator-level assertions. + """ + return GovernanceEvaluator(policy_index=PolicyIndex()) + + +def _ctx(**fields) -> CheckContext: + """Construct a CheckContext with sensible defaults plus overrides.""" + defaults = dict( + hook=LifecycleHook.AFTER_MODEL, + agent_name="agent", + runtime_id="rt-1", + ) + defaults.update(fields) + return CheckContext(**defaults) + + +def _rule_with_condition(operator: str, field: str, value, *, negate: bool = False) -> Rule: + return Rule( + rule_id="r1", + name="r1", + clause="", + hook=LifecycleHook.AFTER_MODEL, + action=Action.AUDIT, + checks=[ + Check( + conditions=[ + Condition(operator=operator, field=field, value=value, negate=negate) + ], + ) + ], + ) + + +# Mode is per-instance now — tests construct evaluators with the mode +# they need via the ``enforcement_mode`` kwarg. No process-globals to +# reset. + + +# --------------------------------------------------------------------------- +# Field resolution — _get_field_value +# --------------------------------------------------------------------------- + + +def test_get_field_value_top_level_attr() -> None: + ev = _evaluator() + ctx = _ctx(model_output="hello") + assert ev._get_field_value("model_output", ctx) == "hello" + + +def test_get_field_value_dotted_path_into_dict() -> None: + ev = _evaluator() + ctx = _ctx(session_state={"tool_calls": 7}) + assert ev._get_field_value("session_state.tool_calls", ctx) == 7 + + +def test_get_field_value_missing_segment_returns_none() -> None: + ev = _evaluator() + ctx = _ctx() + assert ev._get_field_value("nonexistent", ctx) is None + assert ev._get_field_value("session_state.absent", ctx) is None + + +# --------------------------------------------------------------------------- +# Existence / guardrail_fallback (special-cased before the None check) +# --------------------------------------------------------------------------- + + +def test_exists_true_when_value_present() -> None: + ev = _evaluator() + ctx = _ctx(model_output="x") + assert ev._apply_operator("exists", ev._get_field_value("model_output", ctx), None) is True + + +def test_exists_false_when_missing() -> None: + ev = _evaluator() + assert ev._apply_operator("exists", None, None) is False + + +def test_not_exists_inverse() -> None: + ev = _evaluator() + assert ev._apply_operator("not_exists", None, None) is True + assert ev._apply_operator("not_exists", "x", None) is False + + +def test_guardrail_fallback_mapped_and_disabled_fires() -> None: + ev = _evaluator() + result = ev._apply_operator( + "guardrail_fallback", + None, + {"mapped_to_uipath": True, "policy_enabled": False, "validator": "pii"}, + ) + assert result is True + + +@pytest.mark.parametrize( + "cfg", + [ + {"mapped_to_uipath": False, "policy_enabled": False}, + {"mapped_to_uipath": True, "policy_enabled": True}, + {"mapped_to_uipath": False, "policy_enabled": True}, + ], +) +def test_guardrail_fallback_silent_when_not_mapped_or_enabled(cfg: dict) -> None: + ev = _evaluator() + assert ev._apply_operator("guardrail_fallback", None, cfg) is False + + +def test_guardrail_fallback_non_dict_value_silent() -> None: + ev = _evaluator() + assert ev._apply_operator("guardrail_fallback", None, "string") is False + + +# --------------------------------------------------------------------------- +# None-field short-circuit (everything except exists / guardrail_fallback) +# --------------------------------------------------------------------------- + + +def test_other_operators_short_circuit_when_field_is_none() -> None: + ev = _evaluator() + for op in ("contains", "regex", "in_list", "gt"): + assert ev._apply_operator(op, None, "anything") is False, op + + +# --------------------------------------------------------------------------- +# Numeric operators +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "op,lhs,rhs,expected", + [ + ("gt", 5, 3, True), + ("gt", 3, 5, False), + ("gt", 3, 3, False), + ("gte", 3, 3, True), + ("gte", 2, 3, False), + ("lt", 1, 3, True), + ("lt", 3, 3, False), + ("lte", 3, 3, True), + ("lte", 4, 3, False), + ], +) +def test_numeric_operators(op: str, lhs: float, rhs: float, expected: bool) -> None: + assert _evaluator()._apply_operator(op, lhs, rhs) is expected + + +def test_numeric_operators_handle_string_coercion() -> None: + ev = _evaluator() + assert ev._apply_operator("gt", "5", "3") is True + + +def test_numeric_operators_return_false_on_uncoercible() -> None: + ev = _evaluator() + assert ev._apply_operator("gt", "not-a-number", 3) is False + assert ev._apply_operator("gt", 3, "not-a-number") is False + + +# --------------------------------------------------------------------------- +# String operators +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "op,lhs,rhs,expected", + [ + ("equals", "abc", "abc", True), + ("equals", "abc", "ABC", False), # equals is case-sensitive + ("eq", "x", "x", True), + ("not_equals", "abc", "xyz", True), + ("ne", "x", "x", False), + ("contains", "Hello World", "world", True), # case-insensitive + ("contains", "Hello", "xyz", False), + ("not_contains", "Hello", "xyz", True), + ("not_contains", "Hello", "hello", False), + ], +) +def test_string_operators(op: str, lhs: str, rhs: str, expected: bool) -> None: + assert _evaluator()._apply_operator(op, lhs, rhs) is expected + + +def test_regex_matches_pattern() -> None: + ev = _evaluator() + assert ev._apply_operator("regex", "Cost: $1,200", r"\$\d+") is True + + +def test_regex_matches_alias() -> None: + """``matches`` is documented as a synonym for ``regex``.""" + ev = _evaluator() + assert ev._apply_operator("matches", "abc-123", r"\d+") is True + + +def test_regex_invalid_pattern_returns_false() -> None: + """Malformed regex is logged and silently returns False.""" + ev = _evaluator() + assert ev._apply_operator("regex", "anything", "(unclosed") is False + + +# --------------------------------------------------------------------------- +# List operators +# --------------------------------------------------------------------------- + + +def test_in_list_membership() -> None: + ev = _evaluator() + assert ev._apply_operator("in_list", "delete_file", ["shell", "delete_file"]) is True + assert ev._apply_operator("in_list", "ls", ["shell", "delete_file"]) is False + + +def test_in_list_non_list_value_returns_false() -> None: + ev = _evaluator() + assert ev._apply_operator("in_list", "x", "not a list") is False + + +def test_not_in_list_inverse() -> None: + ev = _evaluator() + assert ev._apply_operator("not_in_list", "ls", ["shell"]) is True + assert ev._apply_operator("not_in_list", "shell", ["shell"]) is False + + +def test_not_in_list_non_list_value_returns_true() -> None: + """``not_in_list`` against a non-list value safely returns True + (nothing is in a non-list).""" + ev = _evaluator() + assert ev._apply_operator("not_in_list", "x", "not a list") is True + + +# --------------------------------------------------------------------------- +# Unknown operator +# --------------------------------------------------------------------------- + + +def test_unknown_operator_returns_false() -> None: + """Unknown operator strings log a debug message and return False.""" + ev = _evaluator() + assert ev._apply_operator("never_heard_of_this", "x", "y") is False + + +# --------------------------------------------------------------------------- +# Negate flag — flips the result +# --------------------------------------------------------------------------- + + +def test_condition_negate_flips_result() -> None: + ev = _evaluator() + ctx = _ctx(model_output="hello") + # contains "hello" → matches; negate inverts to False. + cond = Condition( + operator="contains", field="model_output", value="hello", negate=True, + ) + assert ev._evaluate_condition(cond, ctx) is False + cond2 = Condition( + operator="contains", field="model_output", value="world", negate=True, + ) + assert ev._evaluate_condition(cond2, ctx) is True + + +# --------------------------------------------------------------------------- +# Check-level logic: "all" (AND) vs "any" (OR), and empty-conditions +# --------------------------------------------------------------------------- + + +def test_empty_check_conditions_always_match() -> None: + """A check with no conditions trivially matches — surfaces rule shape bugs.""" + ev = _evaluator() + check = Check(conditions=[], logic="all") + matched, _ = ev._evaluate_check(check, _ctx()) + assert matched is True + + +def test_check_logic_all_requires_every_condition() -> None: + ev = _evaluator() + check = Check( + conditions=[ + Condition(operator="contains", field="model_output", value="a"), + Condition(operator="contains", field="model_output", value="missing"), + ], + logic="all", + ) + matched, _ = ev._evaluate_check(check, _ctx(model_output="a only")) + assert matched is False + + +def test_check_logic_any_requires_one_condition() -> None: + ev = _evaluator() + check = Check( + conditions=[ + Condition(operator="contains", field="model_output", value="present"), + Condition(operator="contains", field="model_output", value="absent"), + ], + logic="any", + ) + matched, detail = ev._evaluate_check(check, _ctx(model_output="present text")) + assert matched is True + # detail is the check's message on match; empty by default in our builder. + assert detail == "" + + +# --------------------------------------------------------------------------- +# VADER sentiment +# --------------------------------------------------------------------------- + + +def test_vader_concern_negative_text_fires() -> None: + """A clearly-negative sentence trips the default threshold of -0.3.""" + assert ( + GovernanceEvaluator._check_vader_concern( + "I absolutely hate this terrible, awful product.", {"threshold": -0.3} + ) + is True + ) + + +def test_vader_concern_positive_text_does_not_fire() -> None: + assert ( + GovernanceEvaluator._check_vader_concern( + "This is wonderful and I love it!", {"threshold": -0.3} + ) + is False + ) + + +def test_vader_concern_empty_text_silent() -> None: + assert GovernanceEvaluator._check_vader_concern("", {}) is False + assert GovernanceEvaluator._check_vader_concern(" ", {}) is False + + +def test_vader_concern_threshold_as_scalar() -> None: + """``params`` may be a bare number; the operator coerces.""" + assert ( + GovernanceEvaluator._check_vader_concern("I hate everything", -0.3) is True + ) + + +def test_vader_concern_invalid_threshold_falls_back() -> None: + """Non-numeric scalar params fall back to the documented default.""" + # "garbage" -> default -0.3 → should still classify clear negative + assert ( + GovernanceEvaluator._check_vader_concern( + "I hate this awful, terrible thing", "garbage" + ) + is True + ) + + +# --------------------------------------------------------------------------- +# Encoding integrity +# --------------------------------------------------------------------------- + + +def test_encoding_concern_clean_text_silent() -> None: + assert ( + GovernanceEvaluator._check_encoding_concern( + "Just a normal English sentence with no corruption.", {} + ) + is False + ) + + +def test_encoding_concern_empty_silent() -> None: + assert GovernanceEvaluator._check_encoding_concern("", {}) is False + + +def test_encoding_concern_replacement_chars_fire() -> None: + """U+FFFD replacement chars are a strong corruption signal.""" + text = "Hello � � world" + assert ( + GovernanceEvaluator._check_encoding_concern( + text, {"min_corruption_events": 2} + ) + is True + ) + + +def test_encoding_concern_mojibake_bigrams_fire() -> None: + """Latin-1-as-UTF-8 mojibake patterns are a known corruption shape.""" + text = "é é hello é" + assert ( + GovernanceEvaluator._check_encoding_concern( + text, {"min_corruption_events": 2} + ) + is True + ) + + +def test_encoding_concern_hex_escape_literals_fire() -> None: + """Literal ``\\xHH`` sequences mean raw bytes leaked into a string.""" + text = r"Hello \x80 \x81 \x82 world" + assert ( + GovernanceEvaluator._check_encoding_concern( + text, {"min_corruption_events": 2} + ) + is True + ) + + +# --------------------------------------------------------------------------- +# Entropy (stdlib only — deterministic) +# --------------------------------------------------------------------------- + + +def test_entropy_concern_normal_english_does_not_fire() -> None: + """English prose entropy lands ~3.5–4.5 bits/byte — inside default range.""" + text = "The quick brown fox jumps over the lazy dog." * 5 + assert ( + GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 7.5}) + is False + ) + + +def test_entropy_concern_low_entropy_fires() -> None: + """Highly repetitive text approaches 0 bits/byte.""" + text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + assert ( + GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 7.5}) + is True + ) + + +def test_entropy_concern_high_entropy_fires() -> None: + """Random-ish bytes approach 8 bits/byte.""" + # Build text with many distinct chars to push entropy high. + text = "".join(chr(c) for c in range(32, 127)) * 5 + assert ( + GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 6.0}) + is True + ) + + +def test_entropy_concern_empty_silent() -> None: + assert GovernanceEvaluator._check_entropy_concern("", {}) is False + + +def test_entropy_concern_non_dict_params_uses_defaults() -> None: + """Non-dict params don't crash; defaults apply.""" + # Normal English prose still won't trip the default min=1.5, max=7.5 range. + text = "The quick brown fox jumps over the lazy dog." + assert ( + GovernanceEvaluator._check_entropy_concern(text, "garbage") is False + ) + + +# --------------------------------------------------------------------------- +# Incident taxonomy (regex-based, deterministic) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "text,expected_category", + [ + ("I cannot help with that.", "safety_refusal"), + ("I'm sorry, but I cannot answer.", "safety_refusal"), + ("500 internal server error", "tool_failure"), + ("Connection refused", "tool_failure"), + ("timed out", "tool_failure"), + ("401 unauthorized", "auth_failure"), + ("authentication failed", "auth_failure"), + ("429", "quota_exceeded"), + ("rate limit exceeded", "quota_exceeded"), + ("I made that up", "hallucination"), + ("I don't actually know", "hallucination"), + ], +) +def test_incident_concern_categorical_matches(text: str, expected_category: str) -> None: + """Each category in ``_INCIDENT_PATTERNS`` has at least one matching exemplar.""" + assert expected_category in _INCIDENT_PATTERNS + assert GovernanceEvaluator._check_incident_concern(text, {}) is True + + +def test_incident_concern_unmatched_silent() -> None: + assert ( + GovernanceEvaluator._check_incident_concern( + "All systems operating normally.", {} + ) + is False + ) + + +def test_incident_concern_empty_silent() -> None: + assert GovernanceEvaluator._check_incident_concern("", {}) is False + + +def test_incident_concern_category_filter() -> None: + """Limit scanning to a subset of categories via ``categories`` param.""" + # "401 unauthorized" hits auth_failure; with only quota_exceeded enabled, + # the scanner should miss it. + assert ( + GovernanceEvaluator._check_incident_concern( + "401 unauthorized", {"categories": ["quota_exceeded"]} + ) + is False + ) + # With auth_failure enabled, it fires. + assert ( + GovernanceEvaluator._check_incident_concern( + "401 unauthorized", {"categories": ["auth_failure"]} + ) + is True + ) + + +def test_incident_concern_unknown_category_silently_dropped() -> None: + """Categories the system doesn't know about are silently ignored.""" + # Only the unknown category is requested — falls back to no categories, + # so even matching text doesn't fire. + result = GovernanceEvaluator._check_incident_concern( + "401 unauthorized", {"categories": ["unknown_cat_xyz"]} + ) + assert result is False + + +# --------------------------------------------------------------------------- +# evaluate_* dispatchers — verify they build the right CheckContext +# --------------------------------------------------------------------------- + + +def _record_context_evaluator() -> tuple[GovernanceEvaluator, dict]: + """Patch evaluate() to capture the context it receives instead of running rules.""" + captured: dict = {} + ev = _evaluator() + + def _fake_evaluate(ctx): # type: ignore[no-untyped-def] + captured["ctx"] = ctx + from datetime import datetime, timezone + + from uipath.core.governance.models import AuditRecord + + return AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name=ctx.agent_name, + runtime_id=ctx.runtime_id, + hook=ctx.hook, + evaluations=[], + final_action=Action.ALLOW, + ) + + ev.evaluate = _fake_evaluate # type: ignore[assignment] + return ev, captured + + +def test_evaluate_before_agent_builds_context() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_before_agent( + agent_input="user-text", + agent_name="a", + runtime_id="r", + model_name="gpt-5", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.BEFORE_AGENT + assert ctx.agent_input == "user-text" + assert ctx.model_name == "gpt-5" + + +def test_evaluate_after_agent_builds_context() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_after_agent( + agent_output="reply", agent_name="a", runtime_id="r", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.AFTER_AGENT + assert ctx.agent_output == "reply" + + +def test_evaluate_before_model_carries_messages() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_before_model( + model_input="prompt", + agent_name="a", + runtime_id="r", + messages=[{"role": "user", "content": "hi"}], + model_name="gpt-5", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.BEFORE_MODEL + assert ctx.model_input == "prompt" + assert ctx.messages == [{"role": "user", "content": "hi"}] + + +def test_evaluate_after_model_builds_context() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_after_model( + model_output="resp", agent_name="a", runtime_id="r", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.AFTER_MODEL + assert ctx.model_output == "resp" + + +def test_evaluate_tool_call_carries_args() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_tool_call( + tool_name="search", + tool_args={"q": "x"}, + agent_name="a", + runtime_id="r", + session_state={"tool_calls": 1}, + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.TOOL_CALL + assert ctx.tool_name == "search" + assert ctx.tool_args == {"q": "x"} + assert ctx.session_state == {"tool_calls": 1} + + +def test_evaluate_after_tool_carries_result() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_after_tool( + tool_name="search", + tool_result="some-data", + agent_name="a", + runtime_id="r", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.AFTER_TOOL + assert ctx.tool_name == "search" + assert ctx.tool_result == "some-data" + + +# --------------------------------------------------------------------------- +# DISABLED mode — evaluate() short-circuits without emitting audit +# --------------------------------------------------------------------------- + + +def test_disabled_mode_returns_empty_audit_record() -> None: + """DISABLED mode short-circuits the rule loop and audit emission.""" + rule = _rule_with_condition("contains", "model_output", "anything") + pack = PolicyPack(name="p", version="1", description="", rules=[rule]) + idx = PolicyIndex() + idx.add_pack(pack) + ev = GovernanceEvaluator( + policy_index=idx, enforcement_mode=EnforcementMode.DISABLED + ) + + audit = ev.evaluate(_ctx(model_output="contains anything")) + assert audit.final_action == Action.ALLOW + assert audit.evaluations == [] diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py index 810a8819..324147b7 100644 --- a/tests/test_governance_runtime.py +++ b/tests/test_governance_runtime.py @@ -1,25 +1,23 @@ -"""Tests for the GovernanceRuntime wrapper and the provider loader path. +"""Tests for :class:`UiPathGovernedRuntime` — 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 +from uipath.runtime.governance.runtime import UiPathGovernedRuntime SIMPLE_POLICY_YAML = """ standard: provider-pack @@ -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 +# UiPathGovernedRuntime — passthroughs # --------------------------------------------------------------------------- @@ -163,57 +82,53 @@ 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, +) -> UiPathGovernedRuntime: + """Build a runtime with sensible test defaults.""" + return UiPathGovernedRuntime( + delegate or _StubDelegate(), + policy_index if policy_index is not None else PolicyIndex(), + enforcement_mode, ) - 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() +# --------------------------------------------------------------------------- +# Snapshot stored internally — not exposed as a public property +# --------------------------------------------------------------------------- - assert provider.calls, "provider.get_policy was never invoked" - assert provider.calls[0].is_conversational is True +def test_resolved_policy_index_is_held_for_evaluator_use() -> None: + """The wrapper stores the resolved snapshot; the evaluator reads it.""" + index = build_policy_index_from_yaml(SIMPLE_POLICY_YAML) + runtime = _make_runtime(policy_index=index) + # Internal attribute — verify the wrapper kept the exact instance. + assert runtime._policy_index is index -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_enforcement_mode_is_held_for_evaluator_use() -> None: + """The wrapper stores the mode supplied at construction.""" + runtime = _make_runtime(enforcement_mode=EnforcementMode.ENFORCE) + assert runtime._enforcement_mode is EnforcementMode.ENFORCE - assert provider.calls[0].is_conversational is None +def test_empty_policy_index_is_a_valid_construction() -> None: + """``PolicyIndex()`` with no packs is acceptable — wrapper attaches without rules.""" + runtime = _make_runtime(policy_index=PolicyIndex()) + assert runtime._policy_index.total_rules == 0 -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 +# --------------------------------------------------------------------------- +# 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}) @@ -223,7 +138,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})] @@ -233,7 +148,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_guardrail_compensation.py b/tests/test_guardrail_compensation.py new file mode 100644 index 00000000..ef6046a9 --- /dev/null +++ b/tests/test_guardrail_compensation.py @@ -0,0 +1,503 @@ +"""Tests for the instance-scoped GuardrailCompensator. + +The runtime layer owns only the bounded background pool and the +contextvars propagation that keeps live OTel context visible on the +worker thread. HTTP/auth/URL/header concerns — including ``trace_id`` +resolution — live behind the +:class:`uipath.core.governance.GovernanceCompensationProvider` protocol +and are exercised in the concrete provider's own tests. + +These tests cover: + +- ``disabled_guardrails`` — distilling fired ``guardrail_fallback`` rules + into per-rule wire metadata. +- ``GuardrailCompensator.submit`` — pool routing, in-flight + backpressure, shutdown safety, wire-model assembly, and the + ``contextvars.copy_context()`` propagation that keeps the agent's + OTel span visible inside the worker callable. +- Cross-instance isolation — two compensators do not share a pool or + semaphore. +- Process-level cleanup — one ``atexit`` registration, weak refs only. +""" + +from __future__ import annotations + +import gc +import threading +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from uipath.core.governance import ( + FiredRule, + GovernanceCompensationProvider, + GovernRequest, +) + +from uipath.runtime.governance.native import guardrail_compensation +from uipath.runtime.governance.native.guardrail_compensation import ( + GuardrailCompensator, + disabled_guardrails, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _provider() -> MagicMock: + """Mock satisfying the GovernanceCompensationProvider protocol.""" + return MagicMock(spec=GovernanceCompensationProvider) + + +def _rules( + *validators: str, + rule_id: str = "R1", + rule_name: str = "n", + pack: str = "p", +) -> list[FiredRule]: + """Build a list of FiredRule wire models — one per validator.""" + return [ + FiredRule( + rule_id=rule_id, + rule_name=rule_name, + pack_name=pack, + validator=v, + ) + for v in validators + ] + + +def _run_inline(compensator: GuardrailCompensator) -> None: + """Replace the pool's ``submit`` with synchronous execution. + + Lets tests assert provider behavior deterministically without + relying on wait()/sleep(). + """ + + def _sync_submit(fn: Any, *args: Any, **kwargs: Any) -> None: + # The compensator submits ``ctx.run, _run`` (the bound method + # of a captured context plus the callable). Mirror that here so + # the captured context still wraps the worker callable. + if args: + fn(*args, **kwargs) + else: + fn() + + compensator._pool.submit = _sync_submit # type: ignore[method-assign] + + +@pytest.fixture(autouse=True) +def _close_dangling_compensators() -> Any: + """Best-effort teardown: close any compensator weak-refs still in the set. + + Each test should call ``compensator.close()``, but a failing + assertion mid-test could leak. The sweep prevents pytest from + hanging at exit on a leftover worker pool. + """ + yield + for compensator in list(guardrail_compensation._live_compensators): + try: + compensator.close() + except Exception: # noqa: BLE001 - best-effort teardown + pass + guardrail_compensation._live_compensators.clear() + + +# --------------------------------------------------------------------------- +# disabled_guardrails +# --------------------------------------------------------------------------- + + +def test_disabled_guardrails_returns_fired_rule_for_matched_disabled_guardrail() -> None: + cond = SimpleNamespace( + operator="guardrail_fallback", + value={ + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": False, + }, + ) + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="") + audit = SimpleNamespace( + evaluations=[ + SimpleNamespace(matched=True, rule_id="R1", rule_name="PII guardrail") + ] + ) + policy_index = SimpleNamespace( + get_rule=lambda rid: rule if rid == "R1" else None + ) + + out = disabled_guardrails(audit, policy_index) + + assert len(out) == 1 + fr = out[0] + assert isinstance(fr, FiredRule) + assert fr.rule_id == "R1" + assert fr.rule_name == "PII guardrail" + assert fr.pack_name == "" + assert fr.validator == "pii_detection" + + +def test_disabled_guardrails_skips_unmatched_evaluations() -> None: + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=False, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: None) + assert disabled_guardrails(audit, policy_index) == [] + + +def test_disabled_guardrails_skips_non_guardrail_conditions() -> None: + cond = SimpleNamespace(operator="regex", value="some-pattern") + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])]) + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: rule) + assert disabled_guardrails(audit, policy_index) == [] + + +def test_disabled_guardrails_skips_enabled_guardrails() -> None: + """Mapped to UiPath AND enabled → no compensation needed.""" + cond = SimpleNamespace( + operator="guardrail_fallback", + value={ + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": True, + }, + ) + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="") + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: rule) + assert disabled_guardrails(audit, policy_index) == [] + + +def test_disabled_guardrails_skips_unmapped_guardrails() -> None: + """Not mapped to UiPath → server can't fall back; skip.""" + cond = SimpleNamespace( + operator="guardrail_fallback", + value={ + "validator": "pii_detection", + "mapped_to_uipath": False, + "policy_enabled": False, + }, + ) + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="") + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: rule) + assert disabled_guardrails(audit, policy_index) == [] + + +# --------------------------------------------------------------------------- +# GuardrailCompensator.submit — short-circuits + pool routing + backpressure +# --------------------------------------------------------------------------- + + +def test_submit_empty_rules_short_circuits() -> None: + """No rules → no pool submit, no provider call.""" + provider = _provider() + compensator = GuardrailCompensator(provider) + with patch.object(compensator, "_pool") as mock_pool: + compensator.submit([], {}, "before_model", "ts", "a", "r") + mock_pool.submit.assert_not_called() + provider.compensate.assert_not_called() + + +def test_submit_no_validators_short_circuits() -> None: + """Rules with empty validator strings → no call (nothing to dispatch).""" + provider = _provider() + compensator = GuardrailCompensator(provider) + rules = [FiredRule(rule_id="R", rule_name="n", pack_name="p", validator="")] + with patch.object(compensator, "_pool") as mock_pool: + compensator.submit(rules, {}, "before_model", "ts", "a", "r") + mock_pool.submit.assert_not_called() + provider.compensate.assert_not_called() + + +def test_submit_routes_through_pool() -> None: + """A non-empty rules list submits a single task to the pool.""" + provider = _provider() + compensator = GuardrailCompensator(provider) + with patch.object(compensator, "_pool") as mock_pool: + compensator.submit( + _rules("pii_detection"), + {"content": "x"}, + "before_model", + "ts", + "agent", + "run", + ) + mock_pool.submit.assert_called_once() + + +def test_submit_drops_when_pool_saturated() -> None: + """When the in-flight semaphore is exhausted, the call is dropped.""" + provider = _provider() + compensator = GuardrailCompensator(provider) + + # Force the semaphore into "exhausted" state. + drained = threading.BoundedSemaphore(1) + drained.acquire() # next acquire(blocking=False) returns False + compensator._inflight = drained + + with patch.object(compensator, "_pool") as mock_pool: + compensator.submit( + _rules("pii_detection"), + {}, + "before_model", + "ts", + "agent", + "run", + ) + + mock_pool.submit.assert_not_called() + provider.compensate.assert_not_called() + + +def test_submit_swallows_pool_shutdown_runtimeerror() -> None: + """If the pool was shut down, submit must not raise.""" + + class _ShutdownPool: + def submit(self, fn: Any, *args: Any, **kwargs: Any) -> None: + raise RuntimeError("cannot schedule new futures after shutdown") + + compensator = GuardrailCompensator(_provider()) + compensator._pool = _ShutdownPool() # type: ignore[assignment] + compensator._inflight = threading.BoundedSemaphore(4) + + # Must not raise. + compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") + + +# --------------------------------------------------------------------------- +# GuardrailCompensator.submit — wire-model assembly + provider invocation +# --------------------------------------------------------------------------- + + +def test_submit_invokes_provider_with_govern_request() -> None: + """The provider receives a GovernRequest carrying every wire field. + + ``trace_id`` is left empty on the wire — the injected provider + resolves it at HTTP-call time. + """ + provider = _provider() + compensator = GuardrailCompensator(provider) + _run_inline(compensator) + rules = _rules("pii_detection", "harmful_content") + + compensator.submit( + rules, + {"content": "x"}, + "before_model", + "2026-06-06T00:00:00Z", + "langchain", + "patch-langchain", + ) + + provider.compensate.assert_called_once() + (request,) = provider.compensate.call_args.args + assert isinstance(request, GovernRequest) + # distinct validators drive the guardrail API call + assert request.validators == ["pii_detection", "harmful_content"] + assert request.rules == rules + assert request.data == {"content": "x"} + assert request.hook == "before_model" + # ``trace_id`` is intentionally empty — the provider resolves at HTTP time. + assert request.trace_id == "" + assert request.src_timestamp == "2026-06-06T00:00:00Z" + assert request.agent_name == "langchain" + assert request.runtime_id == "patch-langchain" + # Job-context fields are left for the provider to auto-fill from env. + assert request.folder_key is None + assert request.job_key is None + assert request.process_key is None + assert request.reference_id is None + assert request.agent_version is None + + +def test_submit_dedupes_validators() -> None: + """Multiple rules with the same validator collapse on the wire.""" + provider = _provider() + compensator = GuardrailCompensator(provider) + _run_inline(compensator) + rules = _rules("pii_detection") + _rules("pii_detection", rule_id="R2") + + compensator.submit(rules, {}, "before_model", "ts", "a", "r") + + (request,) = provider.compensate.call_args.args + assert request.validators == ["pii_detection"] + # Per-rule metadata is preserved (one record per rule even with shared validator). + assert len(request.rules) == 2 + + +def test_submit_swallows_provider_errors() -> None: + """A provider exception must never propagate to the caller / agent.""" + provider = _provider() + provider.compensate.side_effect = RuntimeError("network down") + compensator = GuardrailCompensator(provider) + _run_inline(compensator) + + # Must not raise. + compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") + + provider.compensate.assert_called_once() + + +def test_submit_releases_semaphore_on_provider_error() -> None: + """Provider failure must not leak a semaphore slot.""" + provider = _provider() + provider.compensate.side_effect = RuntimeError("transient") + # 4 workers × 1 oversubscription = 4 slots total. + compensator = GuardrailCompensator(provider, inflight_oversubscription=1) + _run_inline(compensator) + + # Fire 8 — all 8 must reach the provider; the semaphore must release + # on each error so the next submit can acquire. + for _ in range(8): + compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") + + assert provider.compensate.call_count == 8, ( + "All 8 submissions should fire — semaphore must release on error" + ) + + +# --------------------------------------------------------------------------- +# contextvars propagation — live OTel context visible inside the worker +# --------------------------------------------------------------------------- + + +def test_submit_propagates_otel_context_to_worker_thread() -> None: + """The worker callable runs inside the caller's contextvars snapshot. + + Without ``contextvars.copy_context()``, a worker thread started by + ``ThreadPoolExecutor`` would see an empty OTel context — the + the provider could only resolve env-based trace ids on the worker. + With the snapshot, the worker sees the same live span the agent + hook saw, so the provider can resolve the agent's actual trace id. + """ + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + + tracer = TracerProvider().get_tracer("test") + provider = _provider() + compensator = GuardrailCompensator(provider) + + done = threading.Event() + captured: dict[str, Any] = {} + + def _capture(request: GovernRequest) -> None: + # Runs on the worker thread but inside the captured context — + # the agent's live span should still be visible here. + ctx = trace.get_current_span().get_span_context() + captured["worker_trace_id_hex"] = ( + format(ctx.trace_id, "032x") if ctx.is_valid else "" + ) + captured["worker_thread_name"] = threading.current_thread().name + done.set() + + provider.compensate.side_effect = _capture + + with tracer.start_as_current_span("agent-run") as span: + expected = format(span.get_span_context().trace_id, "032x") + compensator.submit( + _rules("pii_detection"), + {"content": "x"}, + "before_model", + "2026-06-06T00:00:00Z", + "agent", + "rt", + ) + assert done.wait(timeout=2.0), "compensation worker never ran" + + # Worker ran on the dedicated pool thread (not the caller). + assert captured["worker_thread_name"].startswith("governance-compensation") + # And the captured contextvars context propagated the OTel span across + # the thread hop — the worker sees the same trace_id the agent saw. + assert captured["worker_trace_id_hex"] == expected + + +# --------------------------------------------------------------------------- +# Cross-instance isolation — the architectural motivation for the refactor +# --------------------------------------------------------------------------- + + +def test_two_compensators_do_not_share_pool_or_semaphore() -> None: + """Parallel runtimes cannot saturate each other's compensation pool.""" + p1 = _provider() + p2 = _provider() + c1 = GuardrailCompensator(p1) + c2 = GuardrailCompensator(p2) + + assert c1._pool is not c2._pool + assert c1._inflight is not c2._inflight + + # Drain c1's semaphore to its cap; c2 must remain unaffected. + drained = threading.BoundedSemaphore(1) + drained.acquire() + c1._inflight = drained + + _run_inline(c2) + c2.submit(_rules("pii_detection"), {}, "before_model", "ts", "a", "r") + p2.compensate.assert_called_once() + p1.compensate.assert_not_called() + + +# --------------------------------------------------------------------------- +# Lifecycle — bounded atexit + weakref tracking (mirrors AuditManager pattern) +# --------------------------------------------------------------------------- + + +def test_three_compensators_register_one_process_atexit_hook() -> None: + """N compensators → 1 atexit registration, not N. + + Regression: a per-instance ``atexit.register(self.close)`` would + grow the atexit list linearly. The fix routes everyone through one + process-level cleanup hook keyed by a WeakSet. + """ + with patch.object(guardrail_compensation.atexit, "register") as mock_register: + guardrail_compensation._atexit_registered = False + GuardrailCompensator(_provider()) + GuardrailCompensator(_provider()) + GuardrailCompensator(_provider()) + assert mock_register.call_count == 1, ( + "Each compensator must NOT register its own atexit handler" + ) + + +def test_disposed_compensator_can_be_garbage_collected() -> None: + """The WeakSet must NOT keep a disposed compensator alive.""" + import weakref + + compensator = GuardrailCompensator(_provider()) + ref = weakref.ref(compensator) + + assert compensator in guardrail_compensation._live_compensators + + compensator.close() + del compensator + gc.collect() + + assert ref() is None, ( + "GuardrailCompensator kept alive — strong reference leak in cleanup machinery" + ) + + +def test_process_cleanup_handles_already_closed_compensator() -> None: + """If a compensator was explicitly closed, the process hook is a no-op for it.""" + c = GuardrailCompensator(_provider()) + c.close() + # Must not raise. + guardrail_compensation._process_cleanup_compensators() + + +def test_close_is_idempotent() -> None: + """Calling close() twice is a logged no-op, not a crash.""" + c = GuardrailCompensator(_provider()) + c.close() + c.close() # must not raise diff --git a/tests/test_loader.py b/tests/test_loader.py deleted file mode 100644 index 87e453b2..00000000 --- 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_text_extraction.py b/tests/test_text_extraction.py new file mode 100644 index 00000000..e1639329 --- /dev/null +++ b/tests/test_text_extraction.py @@ -0,0 +1,307 @@ +"""Tests for ``_extract_governable_text`` content extraction. + +Replaces the old ``str(value)[:2000]`` path in ``_check_before_agent`` +and ``_check_after_agent``. Pulls clean text out of structured shapes +(dicts, list-of-blocks, pydantic models) instead of letting dict-repr +noise leak into the regex-scanned blob. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +# The wrapper lands in a later slice of the governance stack; skip (don't +# error at collection) when it isn't present yet. +_wrapper = pytest.importorskip( + "uipath.runtime.governance.wrapper", + reason="governance wrapper not yet present in this slice", +) +_GOVERNANCE_TEXT_CAP = _wrapper._GOVERNANCE_TEXT_CAP +_extract_governable_text = _wrapper._extract_governable_text + + +def test_plain_string_passes_through() -> None: + assert _extract_governable_text("hello world") == "hello world" + + +def test_none_returns_empty() -> None: + assert _extract_governable_text(None) == "" + + +def test_dict_with_content_key_extracts_content_first() -> None: + """The classic coded-agent output shape — content comes through clean.""" + out = _extract_governable_text( + {"content": "Estimated cost: $780", "_meta": {"id": "abc"}} + ) + assert out.startswith("Estimated cost: $780") + # No dict-syntax noise — the prior str(...) path produced ``{'content': '...'}``. + assert "{'content'" not in out + assert "'_meta'" not in out + + +def test_dict_priority_keys_lead() -> None: + """``content`` / ``text`` / etc. lead before remaining keys.""" + out = _extract_governable_text( + {"trailing_meta": "noise-meta", "content": "primary-text"} + ) + assert out.index("primary-text") < out.index("noise-meta") + + +def test_list_of_text_blocks_concatenates() -> None: + """Anthropic-style content blocks.""" + out = _extract_governable_text( + [ + {"type": "text", "text": "first part"}, + {"type": "image", "source": {"data": "..."}}, + {"type": "text", "text": "second part"}, + ] + ) + assert "first part" in out + assert "second part" in out + + +def test_openai_function_call_shape_extracts_arguments() -> None: + """``arguments`` field on OpenAI-style function-call blocks.""" + out = _extract_governable_text( + [ + { + "type": "function_call", + "name": "end_execution", + "arguments": '{"content":"Cost: $1,200"}', + "id": "fc_abc", + } + ] + ) + assert "Cost: $1,200" in out + + +def test_numeric_scalars_are_skipped() -> None: + """Numbers / booleans aren't governance text — they shouldn't pad the blob.""" + out = _extract_governable_text( + {"content": "hello", "count": 42, "ok": True, "rate": 3.14} + ) + assert out == "hello" + + +def test_pydantic_like_model_dump_is_walked() -> None: + """Anything with ``model_dump()`` is walked as its dict form.""" + + class Stub: + def model_dump(self) -> dict: + return {"content": "from pydantic"} + + assert _extract_governable_text(Stub()) == "from pydantic" + + +def test_dataclass_via_dict_method() -> None: + """Objects exposing a ``dict()`` callable also walk via that path.""" + + class Stub: + def dict(self) -> dict: + return {"content": "from dict"} + + assert _extract_governable_text(Stub()) == "from dict" + + +def test_plain_object_attribute_fallback() -> None: + """Public attributes on opaque objects feed the walker.""" + + @dataclass + class Result: + content: str + _private: str = "ignored" + + out = _extract_governable_text(Result(content="visible")) + assert "visible" in out + assert "ignored" not in out + + +def test_cycle_in_structure_does_not_recurse_forever() -> None: + a: dict = {"content": "outer"} + b: dict = {"loop": a} + a["loop"] = b + # Should return without recursing infinitely. + out = _extract_governable_text(a) + assert "outer" in out + + +def test_text_is_capped_at_budget() -> None: + """Long content is truncated so a runaway payload can't dominate scans.""" + big = "x" * (_GOVERNANCE_TEXT_CAP + 1000) + out = _extract_governable_text(big) + assert len(out) == _GOVERNANCE_TEXT_CAP + + +def test_nested_dict_content_extracted() -> None: + """LangGraph-style state with messages nested under a key.""" + out = _extract_governable_text( + { + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "Cost: $50"}, + ] + } + ) + assert "Cost: $50" in out + + +def test_unknown_block_type_with_no_text_returns_empty() -> None: + """Image-only block with no text payload contributes nothing.""" + out = _extract_governable_text( + [{"type": "image", "source": {"type": "base64", "data": "..."}}] + ) + # Could be empty or contain just the base64 data — but should NOT + # contain Python dict syntax characters that the old path emitted. + assert "{'type'" not in out + + +# --------------------------------------------------------------------------- +# Budget — 64K is the current cap (raised from 8K to fit multi-turn chat). +# --------------------------------------------------------------------------- + + +def test_budget_cap_is_64k() -> None: + """Documents the cap so a future drop won't go unnoticed.""" + assert _GOVERNANCE_TEXT_CAP == 64000 + + +# --------------------------------------------------------------------------- +# Reverse list iteration — latest entry gets the budget first. +# --------------------------------------------------------------------------- + + +def test_lists_are_walked_in_reverse() -> None: + """Latest list entry leads the extracted blob. + + Critical for chat history: the new user message lives at the end of + the messages list and must be visible even when prior turns would + otherwise fill the budget first. + """ + out = _extract_governable_text( + [{"text": "earliest"}, {"text": "middle"}, {"text": "latest"}] + ) + assert out.index("latest") < out.index("middle") < out.index("earliest") + + +def test_long_chat_history_keeps_latest_user_message() -> None: + """A long history must not push the latest message out of the budget. + + Regression for the prior 8K-cap + forward-walk combination, which + silently dropped the latest user message once the conversation + grew past ~7,800 chars of prior content. + """ + bulky_prior = "x" * 2000 + messages = [{"role": "user", "content": bulky_prior}] * 40 # ~80K chars + messages.append({"role": "user", "content": "Cost: $1,200 — latest"}) + + out = _extract_governable_text({"messages": messages}) + assert "Cost: $1,200 — latest" in out + + +# --------------------------------------------------------------------------- +# latest_only — BEFORE_AGENT in a conversational agent +# --------------------------------------------------------------------------- + + +def test_latest_only_extracts_just_the_last_list_item() -> None: + """``latest_only=True`` drops every list entry but the last one.""" + out = _extract_governable_text( + { + "messages": [ + {"role": "user", "content": "old message"}, + {"role": "assistant", "content": "old response"}, + {"role": "user", "content": "Cost: $1,200"}, + ] + }, + latest_only=True, + ) + assert "Cost: $1,200" in out + assert "old message" not in out + assert "old response" not in out + + +def test_latest_only_resets_inside_chosen_item() -> None: + """Multi-block content inside the latest message is still walked fully. + + ``latest_only`` reduces the OUTER list (chat history) to its last + entry, but multi-block content (text + tool_call + thinking) + inside that latest message must still be extracted in full — + otherwise we'd lose answer text that arrives in a non-final block. + """ + out = _extract_governable_text( + { + "messages": [ + {"role": "user", "content": "old"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "part A"}, + { + "type": "function_call", + "arguments": '{"answer":"part B"}', + }, + ], + }, + ] + }, + latest_only=True, + ) + assert "part A" in out + assert "part B" in out + assert "old" not in out + + +def test_latest_only_top_level_list() -> None: + """``latest_only`` applies when the input itself is a list.""" + out = _extract_governable_text( + [ + {"content": "history item 1"}, + {"content": "history item 2"}, + {"content": "latest input"}, + ], + latest_only=True, + ) + assert "latest input" in out + assert "history item 1" not in out + assert "history item 2" not in out + + +def test_latest_only_default_false_still_walks_all() -> None: + """Default behavior unchanged — AFTER_AGENT etc. still see everything.""" + out = _extract_governable_text( + { + "messages": [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ] + } + ) + assert "first" in out + assert "second" in out + + +def test_latest_only_empty_list_is_empty() -> None: + """Empty history → empty extraction.""" + assert _extract_governable_text({"messages": []}, latest_only=True) == "" + + +def test_messages_is_a_priority_content_key() -> None: + """``messages`` (plural) leads ahead of non-priority keys. + + Without ``messages`` in the priority list, an input that also + carries siblings like ``thread_id`` / ``metadata`` could siphon + budget before the actual chat history is walked. + """ + out = _extract_governable_text( + { + "thread_id": "abc-xyz", + "metadata": {"foo": "bar"}, + "messages": [{"role": "user", "content": "primary content"}], + } + ) + assert "primary content" in out + assert out.index("primary content") < ( + out.find("abc-xyz") if "abc-xyz" in out else len(out) + ) diff --git a/uv.lock b/uv.lock index 4f29f887..99d4f879 100644 --- a/uv.lock +++ b/uv.lock @@ -1148,16 +1148,16 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.22" -source = { registry = "https://pypi.org/simple" } +version = "0.5.24.dev1017616976" +source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/e0/1cdf0537ae1db831b066604e0e83132a2dd559371ac6e5d56e96b9039163/uipath_core-0.5.22.tar.gz", hash = "sha256:01ae7c3770369469acf5cef31908e8b878a5b1123f2d930f8537ea2d97d7d621", size = 136212, upload-time = "2026-06-23T16:18:43.081Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/9f/ab/a6d8edda9d02f8506698245c240c8d1b1b6c1d3398eaedfabd9756405ae3/uipath_core-0.5.24.dev1017616976.tar.gz", hash = "sha256:e0f14e00db1864d8b8ae76a9422b75293387bf7493afb91d99ca1202d9902e17", size = 130551, upload-time = "2026-06-27T10:16:48.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/97/2258d51969ec71b1056d67f39d612eac2d7c6e9458d3b3c9a0b10f42e730/uipath_core-0.5.22-py3-none-any.whl", hash = "sha256:60df655b207e02a6d3bfae8c61e1fc9bc0bf11576f7ead07b8b38f23d13fc4d6", size = 58222, upload-time = "2026-06-23T16:18:41.536Z" }, + { url = "https://test-files.pythonhosted.org/packages/4b/9e/4c0deb7d4c216be3612b18103362eeeebd2e5691e7be9ef12bce22aa4aa5/uipath_core-0.5.24.dev1017616976-py3-none-any.whl", hash = "sha256:a5755b7b6ab19298220104c9873c664ab8c9c2c2ef6afdead7c67189171899f0", size = 55002, upload-time = "2026-06-27T10:16:47.823Z" }, ] [[package]] @@ -1191,7 +1191,7 @@ dev = [ requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "uipath-core", specifier = ">=0.5.22,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.24.dev1017610000,<0.5.24.dev1017620000", index = "https://test.pypi.org/simple/" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] From fd0529af9f904e6c17d3071dec5b97c02f32ecee Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Sun, 28 Jun 2026 08:20:14 +0530 Subject: [PATCH 16/18] chore(deps): pin uipath-core + uipath-platform to PR #1761 testpypi dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-only pin (uv-specific tables; not in [project.dependencies], so the published wheel's Requires-Dist is unaffected). Aligns the local resolver with the dev builds from uipath-python PR #1761 (``refactor(core): drop AdapterRegistry + BaseAdapter; keep EvaluatorProtocol``) — those carry the AuditRecord.trace_id field drop the runtime now relies on. - ``override-dependencies`` pins ``uipath-core==0.5.24.dev1017616976`` and ``uipath-platform==0.1.79.dev1017616976`` (the ``uipath`` sub- package block in PR #1761). - ``[tool.uv.sources]`` adds ``uipath-platform = { index = "testpypi" }`` so the platform pin is resolvable; the existing entry for ``uipath-core`` stays put. - ``[tool.uv.exclude-newer-package]`` adds ``uipath-platform = false`` so the 2-day age guard doesn't filter out the dev build (mirrors the existing toggle for ``uipath-core``). The pin will be reverted once PR #1761 lands and a stable ``uipath-core`` is published. Verified: ``uv pip show uipath-core`` → ``0.5.24.dev1017616976``; 346 tests + 1 skipped pass, ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 6 ++++++ uv.lock | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8d8792f8..35493d0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,12 @@ exclude_lines = [ [tool.uv] exclude-newer = "2 days" +# Pin to the testpypi dev builds from uipath-python PR #1761 +# (refactor: drop AdapterRegistry + BaseAdapter; keep EvaluatorProtocol). +# Local-only — does not affect the published wheel's Requires-Dist. +override-dependencies = [ + "uipath-core==0.5.24.dev1017616976" +] [tool.uv.exclude-newer-package] uipath-core = false diff --git a/uv.lock b/uv.lock index 99d4f879..3938598a 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,9 @@ exclude-newer-span = "P2D" [options.exclude-newer-package] uipath-core = false +[manifest] +overrides = [{ name = "uipath-core", specifier = "==0.5.24.dev1017616976", index = "https://test.pypi.org/simple/" }] + [[package]] name = "annotated-types" version = "0.7.0" @@ -1191,7 +1194,7 @@ dev = [ requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "uipath-core", specifier = ">=0.5.24.dev1017610000,<0.5.24.dev1017620000", index = "https://test.pypi.org/simple/" }, + { name = "uipath-core", specifier = ">=0.5.22,<0.6.0", index = "https://test.pypi.org/simple/" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] From 0725a6cbd7ffc999fc3532287a874ff8260f57b1 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Mon, 29 Jun 2026 17:22:03 +0530 Subject: [PATCH 17/18] docs(governance): scope evaluator + compensation docstrings to runtime layer Apply the same docstring-scoping rule as PR #122 to the new evaluator and guardrail-compensation modules. Drop references to the wire-side endpoint (/runtime/govern), to "the governance-server", to "Reinstall uipath-core" in ImportError logs, and to the packs/compile_packs.py sibling tool. Each module now describes only what the runtime layer owns. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 11 +- .../governance/native/_yaml_to_index.py | 27 +- .../runtime/governance/native/evaluator.py | 91 ++++-- .../native/guardrail_compensation.py | 214 +++---------- tests/test_evaluator.py | 5 +- tests/test_guardrail_compensation.py | 288 ++---------------- uv.lock | 13 +- 7 files changed, 143 insertions(+), 506 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35493d0a..b85ca490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Runtime abstractions and interfaces for building agents and autom readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.22, <0.6.0", + "uipath-core>=0.5.25, <0.6.0", "pyyaml>=6.0, <7.0", "vaderSentiment>=3.3.2, <4.0", "chardet>=5.2.0, <8.0", @@ -122,19 +122,10 @@ exclude_lines = [ [tool.uv] exclude-newer = "2 days" -# Pin to the testpypi dev builds from uipath-python PR #1761 -# (refactor: drop AdapterRegistry + BaseAdapter; keep EvaluatorProtocol). -# Local-only — does not affect the published wheel's Requires-Dist. -override-dependencies = [ - "uipath-core==0.5.24.dev1017616976" -] [tool.uv.exclude-newer-package] uipath-core = false -[tool.uv.sources] -uipath-core = { index = "testpypi" } - [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py index 9abdec3a..b4c29950 100644 --- a/src/uipath/runtime/governance/native/_yaml_to_index.py +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -1,11 +1,9 @@ """Runtime YAML → PolicyIndex parser. -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 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`. +Builds a :class:`PolicyIndex` directly from parsed YAML data. The 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 @@ -209,12 +207,9 @@ def _build_check( ) -> Check | None: """Build one Check from a YAML check entry. - Supports the same check types as ``compile_packs.py``: explicit - conditions, regex, budget, tool_allowlist, parameter_validation, - rate_limit, field_regex, sentiment_concern, data_quality_score, - incident_taxonomy, commitment_extractor, plus ``guardrail_fallback`` - (reads the rule-level ``mapped_to_uipath`` / ``policy_enabled`` flags - threaded in from ``_build_rule``). + The ``guardrail_fallback`` branch reads the rule-level + ``mapped_to_uipath`` / ``policy_enabled`` flags threaded in from + :func:`_build_rule`. Unknown check types are skipped. """ conditions: list[Condition] = [] message = "" @@ -384,10 +379,10 @@ def _build_check( # Centralized guardrail compensating control. The on/off state # lives at the RULE level (mapped_to_uipath / policy_enabled), # threaded in from ``_build_rule``; ``validator`` names which - # guardrail check the server should run on behalf of the agent. - # The condition matches only when the guardrail is mapped to - # UiPath but disabled — see the ``guardrail_fallback`` operator - # in :class:`GovernanceEvaluator`. + # guardrail check the compensating call should run. The + # condition matches only when the guardrail is mapped to UiPath + # but disabled — see the ``guardrail_fallback`` operator in + # :class:`GovernanceEvaluator`. conditions.append( Condition( operator="guardrail_fallback", diff --git a/src/uipath/runtime/governance/native/evaluator.py b/src/uipath/runtime/governance/native/evaluator.py index f629902b..fec2987c 100644 --- a/src/uipath/runtime/governance/native/evaluator.py +++ b/src/uipath/runtime/governance/native/evaluator.py @@ -43,12 +43,12 @@ def _compensation_data_for_hook(context: CheckContext) -> dict[str, Any]: - """Build the ``data`` payload for the /runtime/govern compensating call. + """Build the ``data`` payload for the compensating-governance call. - The server runs the guardrail check against the same content the - evaluator was looking at — so we forward whichever - :class:`CheckContext` field is populated for the active hook. Fields - not relevant to the hook are omitted to keep the payload tight. + Forwards whichever :class:`CheckContext` field is populated for the + active hook so the compensating call evaluates the same content + the evaluator was looking at. Fields not relevant to the hook are + omitted to keep the payload tight. """ if context.hook in (LifecycleHook.BEFORE_AGENT,): return {"content": context.agent_input} @@ -109,7 +109,7 @@ def _get_vader_analyzer() -> Any: except ImportError: logger.error( "vaderSentiment failed to import despite being a hard dependency; " - "sentiment_concern checks will not fire. Reinstall uipath-core." + "sentiment_concern checks will not fire." ) _vader_analyzer = None return _vader_analyzer @@ -134,7 +134,7 @@ def _get_chardet() -> Any: logger.error( "chardet failed to import despite being a hard dependency; " "encoding_concern confidence check will not fire (stdlib " - "signals still apply). Reinstall uipath-core." + "signals still apply)." ) _chardet_module = None return _chardet_module @@ -417,14 +417,13 @@ def evaluate(self, context: CheckContext) -> AuditRecord: self._emit_audit(audit, mode) - # For any guardrail mapped to UiPath but currently disabled, hand - # the disabled guardrails to the governance-server's - # /runtime/govern endpoint. The SERVER runs the guardrail check - # AND writes the trace (the payload carries traceId / src_timestamp - # / hook / agent so it can correlate) — the agent does NOT emit a - # trace itself, to avoid double-writing. Fire-and-forget on a - # daemon thread so a slow or unreachable endpoint never blocks - # the agent. + # For any guardrail mapped to UiPath but currently disabled, + # dispatch a compensating-governance call via the injected + # compensator. The compensating call (provider-owned) runs the + # real guardrail check and writes its own audit trace — the + # evaluator does NOT emit a Python-side trace for these rules, + # to avoid double-writing. Fire-and-forget so a slow downstream + # never blocks the agent hook. self._dispatch_compensation(audit, context) if final_action == Action.DENY: @@ -494,18 +493,25 @@ def _emit_audit(self, audit: AuditRecord, mode: EnforcementMode) -> None: hook_name = audit.hook.name - # ``guardrail_fallback`` rules are server-traced: the agent POSTs - # to ``/runtime/govern`` (see :meth:`_dispatch_compensation`) and - # the governance-server emits the audit event with the actual - # validator verdict. Emitting a Python-side ``rule_evaluation`` - # event here would produce a duplicate trace carrying no - # verdict, so filter these rules out of every event the Python - # evaluator emits (per-rule AND the hook summary's counts). + # ``guardrail_fallback`` rules are traced by the compensating + # path (see :meth:`_dispatch_compensation`), which carries the + # actual validator verdict. Emitting a Python-side + # ``rule_evaluation`` event here would produce a duplicate + # trace carrying no verdict, so filter these rules out of every + # event the evaluator emits (per-rule AND the hook summary). emittable = [ ev for ev in audit.evaluations if not self._is_guardrail_fallback_rule(ev.rule_id) ] + # No emittable rules means every match this hook was a + # guardrail-fallback already traced by the compensation path. + # Skip the hook summary entirely — emitting it with + # total_rules=0 + the audit's overall final_action would + # double-count the compensation-owned verdict. + if not emittable: + return + for evaluation in emittable: manager.emit_rule_evaluation( policy_id=evaluation.rule_id, @@ -520,22 +526,51 @@ def _emit_audit(self, audit: AuditRecord, mode: EnforcementMode) -> None: description=evaluation.description, ) + # Derive the summary's final action from the emittable subset + # only. ``audit.final_action`` folded in fallback rules whose + # verdict travels on the compensation path; including them + # here would mix the two trace paths' verdicts. + summary_final_action = self._apply_enforcement_mode( + self._most_restrictive_matched_action(emittable) + ) + manager.emit_hook_summary( hook=hook_name, agent_name=audit.agent_name, total_rules=len(emittable), matched_rules=sum(1 for ev in emittable if ev.matched), - final_action=audit.final_action.value, + final_action=summary_final_action.value, enforcement_mode=mode, ) + @staticmethod + def _most_restrictive_matched_action( + evals: list[RuleEvaluation], + ) -> Action: + """Return the most-restrictive matched action across ``evals``. + + Mirrors the cross-rule aggregation in :meth:`evaluate`: + DENY > ESCALATE > AUDIT > ALLOW. Unmatched rules contribute + nothing. + """ + result = Action.ALLOW + for ev in evals: + if not ev.matched: + continue + a = ev.action + if a == Action.DENY: + return Action.DENY + if a == Action.ESCALATE and result != Action.DENY: + result = Action.ESCALATE + elif a == Action.AUDIT and result == Action.ALLOW: + result = Action.AUDIT + return result + def _is_guardrail_fallback_rule(self, rule_id: str) -> bool: - """Return True if the rule is a UiPath-compensating fallback rule. + """Return True if the rule carries a ``guardrail_fallback`` condition. - Such rules carry a ``guardrail_fallback`` condition; their audit - trace is emitted by the governance-server in response to the - ``/runtime/govern`` POST, so the Python evaluator must not emit - a duplicate trace for them. + Such rules are traced by the compensating path, so the + evaluator must not emit a duplicate Python-side trace for them. """ rule = self._policy_index.get_rule(rule_id) if rule is None: diff --git a/src/uipath/runtime/governance/native/guardrail_compensation.py b/src/uipath/runtime/governance/native/guardrail_compensation.py index d3466115..f113e1aa 100644 --- a/src/uipath/runtime/governance/native/guardrail_compensation.py +++ b/src/uipath/runtime/governance/native/guardrail_compensation.py @@ -1,45 +1,22 @@ """Compensating governance for disabled centralized guardrails. -When a ``guardrail_fallback`` rule fires (the guardrail is mapped to -UiPath but the centralized policy is disabled), the framework asks the -governance-server to run the real guardrail check via its -``/{org_id}/agenticgovernance_/api/v1/runtime/govern`` endpoint. - -This module owns only the **local concerns**: a bounded background -pool that schedules the call without blocking the agent hook, and a -trace-id capture that runs on the caller thread before the worker hop -(the worker has no OpenTelemetry context). - -The actual HTTP call — URL composition, auth, headers, JSON -serialisation, env-backed job-context auto-fill — is the -:class:`uipath.core.governance.GovernanceCompensationProvider`'s job. -Callers inject a concrete provider implementation, and this module -just builds the :class:`GovernRequest` wire model and hands it off. - -The call is **fire-and-forget**: the server runs the guardrail AND -writes the audit trace from its side. The agent doesn't inspect the -response — it only cares about whether the call reached the server. - -The compensator is **instance-scoped**: each :class:`GovernanceRuntime` -owns its own pool and semaphore. ``uipath eval`` parallel runtimes -don't share workers, queue slots, or saturation state — one runtime's -spam can't silently drop another's compensation calls. - -The compensator does **not** read host env vars and does not resolve -trace ids itself. It propagates the caller's ``contextvars`` (which -hold the live OTel span) across the worker-thread hop via -:func:`contextvars.copy_context`, so the provider can resolve trace -context at HTTP-call time inside the captured context. +When a ``guardrail_fallback`` rule fires, this module builds the +:class:`GovernRequest` wire payload and calls the injected +:class:`uipath.core.governance.GovernanceCompensationProvider`. The +provider runs the actual guardrail check and writes its own audit +trace; the runtime layer here only assembles the request. + +Synchronous dispatch on the caller's thread. The provider owns +non-blocking semantics (internal batching, async fire-and-forget, or +whatever scheduling the host wires) — the runtime layer no longer +holds a worker pool, semaphore, or process-exit cleanup for this +path. Same architectural shape as :class:`AuditManager`: runtime is +synchronous and pure; async export is the sink/provider's concern. """ from __future__ import annotations -import atexit -import contextvars import logging -import threading -import weakref -from concurrent.futures import ThreadPoolExecutor from typing import Any from uipath.core.governance import ( @@ -51,41 +28,6 @@ logger = logging.getLogger(__name__) -# ---------------------------------------------------------------------------- -# Process-wide cleanup machinery -# -# One ``atexit`` hook walks a ``WeakSet`` of live compensators on exit and -# closes each. Bounded atexit registrations (N runtimes → 1 hook, not N) and -# weakref tracking so a disposed compensator can be GC'd. Same pattern as -# :class:`uipath.runtime.governance._audit.base.AuditManager`. -# ---------------------------------------------------------------------------- - -_live_compensators: weakref.WeakSet[GuardrailCompensator] = weakref.WeakSet() -_atexit_registered = False -_atexit_lock = threading.Lock() - - -def _process_cleanup_compensators() -> None: - """Process-exit handler: close every live compensator.""" - for compensator in list(_live_compensators): - try: - compensator.close() - except Exception as exc: # noqa: BLE001 - exit cleanup must not raise - logger.debug("Compensator process cleanup error: %s", exc) - - -def _register_compensator_for_cleanup(compensator: GuardrailCompensator) -> None: - """Add ``compensator`` to the cleanup set + ensure atexit is wired once.""" - global _atexit_registered - _live_compensators.add(compensator) - if _atexit_registered: - return - with _atexit_lock: - if not _atexit_registered: - atexit.register(_process_cleanup_compensators) - _atexit_registered = True - - # ---------------------------------------------------------------------------- # Stateless helpers # ---------------------------------------------------------------------------- @@ -154,64 +96,28 @@ def _validators(rules: list[FiredRule]) -> list[str]: class GuardrailCompensator: - """Instance-scoped compensating-governance dispatcher. - - Each :class:`GovernanceRuntime` constructs one. Owns: - - - A :class:`ThreadPoolExecutor` (default 4 workers) that runs the - ``/runtime/govern`` POST off the agent's hook thread. - - A :class:`threading.BoundedSemaphore` (default cap = workers × 4) - that bounds total in-flight submissions (running + queued) so a - misbehaving agent firing compensation faster than the server can - absorb can't grow memory without limit. Saturated submissions are - dropped with a warning. + """Synchronous dispatcher for compensating-governance calls. - Process exit cancels queued work via a single process-level atexit - handler (see :func:`_process_cleanup_compensators`); running tasks - finish bounded by the provider's HTTP timeout. + Builds the :class:`GovernRequest` payload and invokes the injected + provider's ``compensate`` method on the caller's thread. The + provider is expected to be non-blocking (batched internally, async + fire-and-forget, or otherwise scheduled off the agent's hook + thread) — the runtime layer owns no worker pool, semaphore, or + process-exit cleanup for this path. - Fire-and-forget: :meth:`submit` returns immediately. The actual HTTP - work is delegated to :meth:`GovernanceCompensationProvider.compensate` - — this class never touches URL/headers/auth/JSON itself. + Per-call exceptions are caught and logged so a provider failure + never breaks the agent hook. """ - _DEFAULT_MAX_WORKERS = 4 - # Queue depth multiplier — total in-flight cap = max_workers × this. - _INFLIGHT_OVERSUBSCRIPTION = 4 - - def __init__( - self, - provider: GovernanceCompensationProvider, - *, - max_workers: int = _DEFAULT_MAX_WORKERS, - inflight_oversubscription: int = _INFLIGHT_OVERSUBSCRIPTION, - ) -> None: + def __init__(self, provider: GovernanceCompensationProvider) -> None: """Construct a compensator bound to one provider. - The compensator does not carry a trace id. Trace-id resolution - is the provider's responsibility at HTTP-call time. To preserve - live OTel context across the thread-pool hop (worker threads - don't inherit ``contextvars``), :meth:`submit` runs the worker - callable inside a snapshot captured via - :func:`contextvars.copy_context` — so the caller's OTel span is - still visible when the provider runs on the worker. - Args: - provider: The :class:`GovernanceCompensationProvider` that - actually fires the ``/runtime/govern`` POST. - max_workers: Concurrent worker threads in the pool. - inflight_oversubscription: How deep the work queue grows - before saturated submissions get dropped. Total cap is - ``max_workers * inflight_oversubscription``. + provider: Host-supplied + :class:`GovernanceCompensationProvider`. ``compensate`` + is invoked synchronously on the agent's hook thread. """ self._provider = provider - self._inflight_cap = max_workers * inflight_oversubscription - self._pool = ThreadPoolExecutor( - max_workers=max_workers, - thread_name_prefix="governance-compensation", - ) - self._inflight = threading.BoundedSemaphore(self._inflight_cap) - _register_compensator_for_cleanup(self) def submit( self, @@ -222,40 +128,17 @@ def submit( agent_name: str, runtime_id: str, ) -> None: - """Schedule a /runtime/govern call on the bounded background pool. + """Build the wire payload and hand it to the provider. - Fire-and-forget. Returns immediately; the call runs on a worker - thread. When the in-flight queue is saturated the call is - dropped with a warning and the agent continues. - - ``rules`` is the per-rule metadata from :func:`disabled_guardrails`; - the validators sent to the guardrail API are derived from it. - - The current :mod:`contextvars` context (which carries the live - OpenTelemetry span) is captured here and re-applied inside the - worker via :meth:`contextvars.Context.run`. This lets the - provider see the live OTel context on the worker thread — - without the snapshot the worker would inherit an empty context - and the provider could only resolve env-based trace ids. - - Never raises — including when the pool has already been shut down. + Short-circuits on empty rules / empty validators. Per-call + provider exceptions are caught and logged. """ if not rules: return - validators = _validators(rules) if not validators: return - if not self._inflight.acquire(blocking=False): - logger.warning( - "Compensation pool saturated (>%d in flight); dropping call " - "(validators=[%s])", - self._inflight_cap, - ", ".join(validators), - ) - return - request = GovernRequest( validators=validators, rules=rules, @@ -266,46 +149,19 @@ def submit( runtime_id=runtime_id, ) - provider = self._provider - inflight = self._inflight - # Snapshot the caller's contextvars (OTel span lives in there - # for Python OTel >= 1.x). The worker runs inside this snapshot - # so the provider sees the live span at HTTP-call time. - ctx = contextvars.copy_context() - - def _run() -> None: - try: - provider.compensate(request) - except Exception as exc: # noqa: BLE001 - fail-open by contract - logger.warning( - "Compensation worker failed (validators=[%s]): %s", - ", ".join(validators), - exc, - ) - finally: - inflight.release() - try: - self._pool.submit(ctx.run, _run) - except RuntimeError as exc: - # Pool was shut down (atexit, dispose, or test teardown) — - # release the semaphore slot we took and log; never raise. - self._inflight.release() + self._provider.compensate(request) + except Exception as exc: # noqa: BLE001 - fail-open by contract logger.warning( - "Compensation pool unavailable (validators=[%s]): %s", + "Compensation provider call failed (validators=[%s]): %s", ", ".join(validators), exc, ) def close(self) -> None: - """Cancel queued tasks. Running tasks finish bounded by the provider HTTP timeout. + """No-op — the compensator holds no resources. - ``wait=False`` returns immediately so caller / process shutdown - isn't held up; ``cancel_futures=True`` drops anything not yet - running. Idempotent — calling close on an already-closed pool - is a logged no-op. + Kept on the API so callers that wire ``close()`` (e.g. shared + teardown patterns) don't need a branch for this class. """ - try: - self._pool.shutdown(wait=False, cancel_futures=True) - except Exception as exc: # noqa: BLE001 - shutdown must not raise - logger.debug("Compensator shutdown error: %s", exc) + return diff --git a/tests/test_evaluator.py b/tests/test_evaluator.py index 2039182f..17ec2853 100644 --- a/tests/test_evaluator.py +++ b/tests/test_evaluator.py @@ -129,10 +129,9 @@ def audit_setup() -> Any: """Per-test :class:`AuditManager` + capturing sink — no default sinks. Returns ``(manager, sink)`` so a test can build evaluators with the - manager and inspect emitted events through the sink. Synchronous - mode keeps assertions deterministic. + manager and inspect emitted events through the sink. """ - manager = AuditManager(async_mode=False, register_default_sinks=False) + manager = AuditManager(register_default_sinks=False) sink = _CapturingSink() manager.register_sink(sink) yield manager, sink diff --git a/tests/test_guardrail_compensation.py b/tests/test_guardrail_compensation.py index ef6046a9..5d8a674b 100644 --- a/tests/test_guardrail_compensation.py +++ b/tests/test_guardrail_compensation.py @@ -1,41 +1,33 @@ -"""Tests for the instance-scoped GuardrailCompensator. +"""Tests for the synchronous GuardrailCompensator. -The runtime layer owns only the bounded background pool and the -contextvars propagation that keeps live OTel context visible on the -worker thread. HTTP/auth/URL/header concerns — including ``trace_id`` +The runtime layer builds the wire payload and hands it to the +injected provider. The provider owns batching / async / fire-and- +forget. HTTP/auth/URL/header concerns — including ``trace_id`` resolution — live behind the -:class:`uipath.core.governance.GovernanceCompensationProvider` protocol -and are exercised in the concrete provider's own tests. +:class:`uipath.core.governance.GovernanceCompensationProvider` +protocol and are exercised in the concrete provider's own tests. These tests cover: - ``disabled_guardrails`` — distilling fired ``guardrail_fallback`` rules into per-rule wire metadata. -- ``GuardrailCompensator.submit`` — pool routing, in-flight - backpressure, shutdown safety, wire-model assembly, and the - ``contextvars.copy_context()`` propagation that keeps the agent's - OTel span visible inside the worker callable. -- Cross-instance isolation — two compensators do not share a pool or - semaphore. -- Process-level cleanup — one ``atexit`` registration, weak refs only. +- ``GuardrailCompensator.submit`` — short-circuits on empty input, + wire-model assembly, provider invocation, and fail-open behavior + when the provider raises. +- ``close`` is a no-op (kept for API symmetry). """ from __future__ import annotations -import gc -import threading from types import SimpleNamespace -from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import pytest from uipath.core.governance import ( FiredRule, GovernanceCompensationProvider, GovernRequest, ) -from uipath.runtime.governance.native import guardrail_compensation from uipath.runtime.governance.native.guardrail_compensation import ( GuardrailCompensator, disabled_guardrails, @@ -69,42 +61,6 @@ def _rules( ] -def _run_inline(compensator: GuardrailCompensator) -> None: - """Replace the pool's ``submit`` with synchronous execution. - - Lets tests assert provider behavior deterministically without - relying on wait()/sleep(). - """ - - def _sync_submit(fn: Any, *args: Any, **kwargs: Any) -> None: - # The compensator submits ``ctx.run, _run`` (the bound method - # of a captured context plus the callable). Mirror that here so - # the captured context still wraps the worker callable. - if args: - fn(*args, **kwargs) - else: - fn() - - compensator._pool.submit = _sync_submit # type: ignore[method-assign] - - -@pytest.fixture(autouse=True) -def _close_dangling_compensators() -> Any: - """Best-effort teardown: close any compensator weak-refs still in the set. - - Each test should call ``compensator.close()``, but a failing - assertion mid-test could leak. The sweep prevents pytest from - hanging at exit on a leftover worker pool. - """ - yield - for compensator in list(guardrail_compensation._live_compensators): - try: - compensator.close() - except Exception: # noqa: BLE001 - best-effort teardown - pass - guardrail_compensation._live_compensators.clear() - - # --------------------------------------------------------------------------- # disabled_guardrails # --------------------------------------------------------------------------- @@ -195,17 +151,15 @@ def test_disabled_guardrails_skips_unmapped_guardrails() -> None: # --------------------------------------------------------------------------- -# GuardrailCompensator.submit — short-circuits + pool routing + backpressure +# GuardrailCompensator.submit — short-circuits # --------------------------------------------------------------------------- def test_submit_empty_rules_short_circuits() -> None: - """No rules → no pool submit, no provider call.""" + """No rules → no provider call.""" provider = _provider() compensator = GuardrailCompensator(provider) - with patch.object(compensator, "_pool") as mock_pool: - compensator.submit([], {}, "before_model", "ts", "a", "r") - mock_pool.submit.assert_not_called() + compensator.submit([], {}, "before_model", "ts", "a", "r") provider.compensate.assert_not_called() @@ -214,67 +168,10 @@ def test_submit_no_validators_short_circuits() -> None: provider = _provider() compensator = GuardrailCompensator(provider) rules = [FiredRule(rule_id="R", rule_name="n", pack_name="p", validator="")] - with patch.object(compensator, "_pool") as mock_pool: - compensator.submit(rules, {}, "before_model", "ts", "a", "r") - mock_pool.submit.assert_not_called() - provider.compensate.assert_not_called() - - -def test_submit_routes_through_pool() -> None: - """A non-empty rules list submits a single task to the pool.""" - provider = _provider() - compensator = GuardrailCompensator(provider) - with patch.object(compensator, "_pool") as mock_pool: - compensator.submit( - _rules("pii_detection"), - {"content": "x"}, - "before_model", - "ts", - "agent", - "run", - ) - mock_pool.submit.assert_called_once() - - -def test_submit_drops_when_pool_saturated() -> None: - """When the in-flight semaphore is exhausted, the call is dropped.""" - provider = _provider() - compensator = GuardrailCompensator(provider) - - # Force the semaphore into "exhausted" state. - drained = threading.BoundedSemaphore(1) - drained.acquire() # next acquire(blocking=False) returns False - compensator._inflight = drained - - with patch.object(compensator, "_pool") as mock_pool: - compensator.submit( - _rules("pii_detection"), - {}, - "before_model", - "ts", - "agent", - "run", - ) - - mock_pool.submit.assert_not_called() + compensator.submit(rules, {}, "before_model", "ts", "a", "r") provider.compensate.assert_not_called() -def test_submit_swallows_pool_shutdown_runtimeerror() -> None: - """If the pool was shut down, submit must not raise.""" - - class _ShutdownPool: - def submit(self, fn: Any, *args: Any, **kwargs: Any) -> None: - raise RuntimeError("cannot schedule new futures after shutdown") - - compensator = GuardrailCompensator(_provider()) - compensator._pool = _ShutdownPool() # type: ignore[assignment] - compensator._inflight = threading.BoundedSemaphore(4) - - # Must not raise. - compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") - - # --------------------------------------------------------------------------- # GuardrailCompensator.submit — wire-model assembly + provider invocation # --------------------------------------------------------------------------- @@ -288,7 +185,6 @@ def test_submit_invokes_provider_with_govern_request() -> None: """ provider = _provider() compensator = GuardrailCompensator(provider) - _run_inline(compensator) rules = _rules("pii_detection", "harmful_content") compensator.submit( @@ -308,8 +204,8 @@ def test_submit_invokes_provider_with_govern_request() -> None: assert request.rules == rules assert request.data == {"content": "x"} assert request.hook == "before_model" - # ``trace_id`` is intentionally empty — the provider resolves at HTTP time. - assert request.trace_id == "" + # ``trace_id`` is not carried on the wire — the provider resolves at HTTP time. + assert request.trace_id in (None, "") assert request.src_timestamp == "2026-06-06T00:00:00Z" assert request.agent_name == "langchain" assert request.runtime_id == "patch-langchain" @@ -325,7 +221,6 @@ def test_submit_dedupes_validators() -> None: """Multiple rules with the same validator collapse on the wire.""" provider = _provider() compensator = GuardrailCompensator(provider) - _run_inline(compensator) rules = _rules("pii_detection") + _rules("pii_detection", rule_id="R2") compensator.submit(rules, {}, "before_model", "ts", "a", "r") @@ -341,7 +236,6 @@ def test_submit_swallows_provider_errors() -> None: provider = _provider() provider.compensate.side_effect = RuntimeError("network down") compensator = GuardrailCompensator(provider) - _run_inline(compensator) # Must not raise. compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") @@ -349,155 +243,25 @@ def test_submit_swallows_provider_errors() -> None: provider.compensate.assert_called_once() -def test_submit_releases_semaphore_on_provider_error() -> None: - """Provider failure must not leak a semaphore slot.""" - provider = _provider() - provider.compensate.side_effect = RuntimeError("transient") - # 4 workers × 1 oversubscription = 4 slots total. - compensator = GuardrailCompensator(provider, inflight_oversubscription=1) - _run_inline(compensator) - - # Fire 8 — all 8 must reach the provider; the semaphore must release - # on each error so the next submit can acquire. - for _ in range(8): - compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") - - assert provider.compensate.call_count == 8, ( - "All 8 submissions should fire — semaphore must release on error" - ) - - -# --------------------------------------------------------------------------- -# contextvars propagation — live OTel context visible inside the worker -# --------------------------------------------------------------------------- - - -def test_submit_propagates_otel_context_to_worker_thread() -> None: - """The worker callable runs inside the caller's contextvars snapshot. - - Without ``contextvars.copy_context()``, a worker thread started by - ``ThreadPoolExecutor`` would see an empty OTel context — the - the provider could only resolve env-based trace ids on the worker. - With the snapshot, the worker sees the same live span the agent - hook saw, so the provider can resolve the agent's actual trace id. - """ - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - - tracer = TracerProvider().get_tracer("test") +def test_submit_recovers_after_provider_error() -> None: + """A failed call doesn't poison the compensator — the next call still fires.""" provider = _provider() + provider.compensate.side_effect = [RuntimeError("transient"), None] compensator = GuardrailCompensator(provider) - done = threading.Event() - captured: dict[str, Any] = {} - - def _capture(request: GovernRequest) -> None: - # Runs on the worker thread but inside the captured context — - # the agent's live span should still be visible here. - ctx = trace.get_current_span().get_span_context() - captured["worker_trace_id_hex"] = ( - format(ctx.trace_id, "032x") if ctx.is_valid else "" - ) - captured["worker_thread_name"] = threading.current_thread().name - done.set() - - provider.compensate.side_effect = _capture - - with tracer.start_as_current_span("agent-run") as span: - expected = format(span.get_span_context().trace_id, "032x") - compensator.submit( - _rules("pii_detection"), - {"content": "x"}, - "before_model", - "2026-06-06T00:00:00Z", - "agent", - "rt", - ) - assert done.wait(timeout=2.0), "compensation worker never ran" - - # Worker ran on the dedicated pool thread (not the caller). - assert captured["worker_thread_name"].startswith("governance-compensation") - # And the captured contextvars context propagated the OTel span across - # the thread hop — the worker sees the same trace_id the agent saw. - assert captured["worker_trace_id_hex"] == expected - - -# --------------------------------------------------------------------------- -# Cross-instance isolation — the architectural motivation for the refactor -# --------------------------------------------------------------------------- - - -def test_two_compensators_do_not_share_pool_or_semaphore() -> None: - """Parallel runtimes cannot saturate each other's compensation pool.""" - p1 = _provider() - p2 = _provider() - c1 = GuardrailCompensator(p1) - c2 = GuardrailCompensator(p2) - - assert c1._pool is not c2._pool - assert c1._inflight is not c2._inflight - - # Drain c1's semaphore to its cap; c2 must remain unaffected. - drained = threading.BoundedSemaphore(1) - drained.acquire() - c1._inflight = drained + compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") + compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") - _run_inline(c2) - c2.submit(_rules("pii_detection"), {}, "before_model", "ts", "a", "r") - p2.compensate.assert_called_once() - p1.compensate.assert_not_called() + assert provider.compensate.call_count == 2 # --------------------------------------------------------------------------- -# Lifecycle — bounded atexit + weakref tracking (mirrors AuditManager pattern) +# close — no-op for API symmetry # --------------------------------------------------------------------------- -def test_three_compensators_register_one_process_atexit_hook() -> None: - """N compensators → 1 atexit registration, not N. - - Regression: a per-instance ``atexit.register(self.close)`` would - grow the atexit list linearly. The fix routes everyone through one - process-level cleanup hook keyed by a WeakSet. - """ - with patch.object(guardrail_compensation.atexit, "register") as mock_register: - guardrail_compensation._atexit_registered = False - GuardrailCompensator(_provider()) - GuardrailCompensator(_provider()) - GuardrailCompensator(_provider()) - assert mock_register.call_count == 1, ( - "Each compensator must NOT register its own atexit handler" - ) - - -def test_disposed_compensator_can_be_garbage_collected() -> None: - """The WeakSet must NOT keep a disposed compensator alive.""" - import weakref - - compensator = GuardrailCompensator(_provider()) - ref = weakref.ref(compensator) - - assert compensator in guardrail_compensation._live_compensators - - compensator.close() - del compensator - gc.collect() - - assert ref() is None, ( - "GuardrailCompensator kept alive — strong reference leak in cleanup machinery" - ) - - -def test_process_cleanup_handles_already_closed_compensator() -> None: - """If a compensator was explicitly closed, the process hook is a no-op for it.""" - c = GuardrailCompensator(_provider()) - c.close() - # Must not raise. - guardrail_compensation._process_cleanup_compensators() - - -def test_close_is_idempotent() -> None: - """Calling close() twice is a logged no-op, not a crash.""" +def test_close_is_a_noop() -> None: + """``close()`` holds no resources to release; calling it twice is safe.""" c = GuardrailCompensator(_provider()) c.close() c.close() # must not raise diff --git a/uv.lock b/uv.lock index 3938598a..76fa1ec3 100644 --- a/uv.lock +++ b/uv.lock @@ -9,9 +9,6 @@ exclude-newer-span = "P2D" [options.exclude-newer-package] uipath-core = false -[manifest] -overrides = [{ name = "uipath-core", specifier = "==0.5.24.dev1017616976", index = "https://test.pypi.org/simple/" }] - [[package]] name = "annotated-types" version = "0.7.0" @@ -1151,16 +1148,16 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.24.dev1017616976" -source = { registry = "https://test.pypi.org/simple/" } +version = "0.5.25" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://test-files.pythonhosted.org/packages/9f/ab/a6d8edda9d02f8506698245c240c8d1b1b6c1d3398eaedfabd9756405ae3/uipath_core-0.5.24.dev1017616976.tar.gz", hash = "sha256:e0f14e00db1864d8b8ae76a9422b75293387bf7493afb91d99ca1202d9902e17", size = 130551, upload-time = "2026-06-27T10:16:48.961Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/f3/8c4220bcd6b5a85a0be48e5e48afc0a0182454526f76b4927be2b307b065/uipath_core-0.5.25.tar.gz", hash = "sha256:8645b65b7987b4fc7d686d07310a3a31b447c2e7dc5e567cff55612a96f24ab2", size = 130395, upload-time = "2026-06-29T10:01:34.602Z" } wheels = [ - { url = "https://test-files.pythonhosted.org/packages/4b/9e/4c0deb7d4c216be3612b18103362eeeebd2e5691e7be9ef12bce22aa4aa5/uipath_core-0.5.24.dev1017616976-py3-none-any.whl", hash = "sha256:a5755b7b6ab19298220104c9873c664ab8c9c2c2ef6afdead7c67189171899f0", size = 55002, upload-time = "2026-06-27T10:16:47.823Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/c87da71a367fecd10a5f0f9cb4b05489ea214d80ba597ca636394cd5ebfa/uipath_core-0.5.25-py3-none-any.whl", hash = "sha256:5cf95a9ffa7bc2bd95d394aeb7f44dc4979a69e067c1ef92b41fd05ead802e7d", size = 54759, upload-time = "2026-06-29T10:01:33.189Z" }, ] [[package]] @@ -1194,7 +1191,7 @@ dev = [ requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "uipath-core", specifier = ">=0.5.22,<0.6.0", index = "https://test.pypi.org/simple/" }, + { name = "uipath-core", specifier = ">=0.5.25,<0.6.0" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] From 6c2023575f0cc5b81bc78dd27809776fbc1ada05 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Mon, 29 Jun 2026 20:05:01 +0530 Subject: [PATCH 18/18] docs(governance): clear stale comments after sync-compensator refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep across all 17 commits on this branch following Radu's r3492300304 ("outdated comment") review. Fixes: - evaluator: `_dispatch_compensation` docstring still claimed the compensator owned concurrency, queue caps, and process-exit cancellation — all removed in the prior sync-dispatch refactor. - evaluator: `compensator` parameter doc and the inline comment in `evaluate()` referenced `/runtime/govern` and the old fire-and- forget pool semantics. - runtime: module docstring claimed a "thread-pool hop via contextvars.copy_context" — no thread pool to hop anymore. - test_traces_severity: dropped the "§4 of the cross-product unification doc" external-spec citation; kept the verdict-split contract in local terms. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../runtime/governance/native/evaluator.py | 79 ++++++++----------- src/uipath/runtime/governance/runtime.py | 7 +- tests/test_traces_severity.py | 16 ++-- 3 files changed, 45 insertions(+), 57 deletions(-) diff --git a/src/uipath/runtime/governance/native/evaluator.py b/src/uipath/runtime/governance/native/evaluator.py index fec2987c..71d41dda 100644 --- a/src/uipath/runtime/governance/native/evaluator.py +++ b/src/uipath/runtime/governance/native/evaluator.py @@ -14,7 +14,7 @@ import re from collections import Counter from datetime import datetime, timezone -from functools import lru_cache +from functools import cache, lru_cache from typing import Any from uipath.core.governance import EnforcementMode @@ -92,52 +92,40 @@ def _compile_regex(pattern: str) -> re.Pattern[str] | None: # critical path. The except branch is defence against a corrupted # install (file present in METADATA but module unimportable) — the # operator no-ops rather than crashing the agent. -_VADER_UNINITIALIZED = object() -_vader_analyzer: Any = _VADER_UNINITIALIZED - - +@cache def _get_vader_analyzer() -> Any: """Return a cached SentimentIntensityAnalyzer, or None if unavailable.""" - global _vader_analyzer - if _vader_analyzer is _VADER_UNINITIALIZED: - try: - from vaderSentiment.vaderSentiment import ( # type: ignore[import-untyped] - SentimentIntensityAnalyzer, - ) + try: + from vaderSentiment.vaderSentiment import ( # type: ignore[import-untyped] + SentimentIntensityAnalyzer, + ) - _vader_analyzer = SentimentIntensityAnalyzer() - except ImportError: - logger.error( - "vaderSentiment failed to import despite being a hard dependency; " - "sentiment_concern checks will not fire." - ) - _vader_analyzer = None - return _vader_analyzer + return SentimentIntensityAnalyzer() + except ImportError: + logger.error( + "vaderSentiment failed to import despite being a hard dependency; " + "sentiment_concern checks will not fire." + ) + return None # --- chardet: lazy-imported module for encoding integrity (A.7.4) --- # Hard dependency, lazy-loaded for symmetry with the other library # wrappers. The except branch covers corrupted installs only. -_CHARDET_UNINITIALIZED = object() -_chardet_module: Any = _CHARDET_UNINITIALIZED - - +@cache def _get_chardet() -> Any: """Return the chardet module, or None if unavailable.""" - global _chardet_module - if _chardet_module is _CHARDET_UNINITIALIZED: - try: - import chardet - - _chardet_module = chardet - except ImportError: - logger.error( - "chardet failed to import despite being a hard dependency; " - "encoding_concern confidence check will not fire (stdlib " - "signals still apply)." - ) - _chardet_module = None - return _chardet_module + try: + import chardet + + return chardet + except ImportError: + logger.error( + "chardet failed to import despite being a hard dependency; " + "encoding_concern confidence check will not fire (stdlib " + "signals still apply)." + ) + return None # --- Static patterns for encoding_concern (A.7.4) --- @@ -306,7 +294,7 @@ def __init__( emitted). Tests that don't care about emission can leave this out. compensator: Per-runtime :class:`GuardrailCompensator` - used to dispatch ``/runtime/govern`` POSTs for + used to dispatch compensating-governance calls for guardrail-fallback rules. When ``None`` such dispatch is skipped — the evaluator still records the matched rules in the :class:`AuditRecord`. @@ -422,8 +410,9 @@ def evaluate(self, context: CheckContext) -> AuditRecord: # compensator. The compensating call (provider-owned) runs the # real guardrail check and writes its own audit trace — the # evaluator does NOT emit a Python-side trace for these rules, - # to avoid double-writing. Fire-and-forget so a slow downstream - # never blocks the agent hook. + # to avoid double-writing. The provider is expected to be + # non-blocking (batched / async / fire-and-forget internally); + # the runtime layer no longer owns that scheduling. self._dispatch_compensation(audit, context) if final_action == Action.DENY: @@ -434,12 +423,12 @@ def evaluate(self, context: CheckContext) -> AuditRecord: def _dispatch_compensation( self, audit: AuditRecord, context: CheckContext ) -> None: - """Schedule compensating governance for any matched fallback rules. + """Dispatch compensating governance for any matched fallback rules. - Delegates to the injected :class:`GuardrailCompensator`. The - compensator owns concurrency, queue caps, exception isolation, - and graceful process-exit cancellation — this method just - builds the payload, logs the summary, and submits. + Delegates to the injected :class:`GuardrailCompensator`, which + builds the wire payload and hands it to the provider. This + method picks the fallback rules out of the audit, logs the + summary, and submits. No-op when no compensator was supplied at construction (e.g. unit tests that don't care about the dispatch path). diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py index ab3d177d..be8a6af5 100644 --- a/src/uipath/runtime/governance/runtime.py +++ b/src/uipath/runtime/governance/runtime.py @@ -25,10 +25,9 @@ adapters that observe per-step events. Trace-id is intentionally **not** carried on this wrapper. The -governance compensator captures the live OTel context across the -thread-pool hop via :func:`contextvars.copy_context`, and the -injected provider resolves the canonical trace id at HTTP-call time. -The runtime layer is fully env-free for this path. +governance compensator dispatches synchronously on the caller's +thread; the injected provider resolves the canonical trace id at +call time. The runtime layer is fully env-free for this path. """ from __future__ import annotations diff --git a/tests/test_traces_severity.py b/tests/test_traces_severity.py index 30fd3565..c0920047 100644 --- a/tests/test_traces_severity.py +++ b/tests/test_traces_severity.py @@ -1,10 +1,10 @@ """Tests for trace-span verbosity / status semantics. ``TracesAuditSink`` emits an OpenTelemetry span for every governance -hook end and every rule evaluation. The contract follows §4 of the -cross-product unification doc — verdict is split into ``evaluator_result`` -(what the rule decided, mode-independent) and ``action_applied`` (what -actually happened, derived from evaluator_result + mode). +hook end and every rule evaluation. The verdict is split into +``evaluator_result`` (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 emitter from its per-instance ``EnforcementMode``) so parallel runtimes running @@ -15,12 +15,12 @@ the agent (ENFORCE mode + configured action ``deny``). - ``verbosityLevel = 3`` (Warning) and ``Status.UNSET`` for advisory outcomes (``action_applied`` in ``{AUDIT, HITL}``). HITL is its own - spec bucket — escalation pauses for human review, it doesn't fail - the run, so it stays Warning even in ENFORCE mode. + bucket — escalation pauses for human review, it doesn't fail the + run, so it stays Warning even in ENFORCE mode. - Hook spans never set Status, regardless of mode or final_action. They're summary containers; severity belongs on the per-rule span. -- ``ALLOW`` / ``NONE`` results leave verbosityLevel unset (Orchestrator - default = 2, Information) and never call set_status. +- ``ALLOW`` / ``NONE`` results leave verbosityLevel unset (consumers + apply their default) and never call set_status. """ from __future__ import annotations