diff --git a/pyproject.toml b/pyproject.toml index e9f9950..1c06982 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Runtime abstractions and interfaces for building agents and autom readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.19, <0.6.0", + "uipath-core>=0.5.21, <0.6.0", "pyyaml>=6.0, <7.0", "vaderSentiment>=3.3.2, <4.0", "chardet>=5.2.0, <8.0", diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py deleted file mode 100644 index f74d51d..0000000 --- a/src/uipath/runtime/governance/config.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Runtime-level governance enforcement-mode state. - -The feature-flag gate (``is_governance_enabled``) lives in -:mod:`uipath.core.governance.config` because it is process-level and -must be resolvable by callers that do not depend on -``uipath-runtime``. The enforcement mode is *per-policy* — 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 - -# ``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 "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 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. - """ - 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 backend-supplied mode on each - fetch so the evaluator picks up the platform-controlled value. - """ - _state.mode = mode \ No newline at end of file diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py new file mode 100644 index 0000000..3bf264c --- /dev/null +++ b/src/uipath/runtime/governance/native/_yaml_to_index.py @@ -0,0 +1,468 @@ +"""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. + +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, + Logic, + PolicyIndex, + PolicyPack, + Rule, + Severity, +) + +logger = logging.getLogger(__name__) + + +_HOOK_MAP: dict[str, LifecycleHook] = { + "before_agent": LifecycleHook.BEFORE_AGENT, + "after_agent": LifecycleHook.AFTER_AGENT, + "before_model": LifecycleHook.BEFORE_MODEL, + "after_model": LifecycleHook.AFTER_MODEL, + "wrap_tool_call": LifecycleHook.TOOL_CALL, + "tool_call": LifecycleHook.TOOL_CALL, + "after_tool": LifecycleHook.AFTER_TOOL, +} + +_ACTION_MAP: dict[str, Action] = { + "block": Action.DENY, + "deny": Action.DENY, + "log": Action.AUDIT, + "audit": Action.AUDIT, + "allow": Action.ALLOW, + "require_approval": Action.ESCALATE, + "escalate": Action.ESCALATE, +} + +_SEVERITY_MAP: dict[str, Severity] = { + "low": Severity.LOW, + "medium": Severity.MEDIUM, + "high": Severity.HIGH, + "critical": Severity.CRITICAL, +} + + +def build_policy_index_from_yaml(yaml_text: str) -> PolicyIndex: + """Parse YAML policy packs into a 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", "")) + + 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", "")) + + 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", "")) + + 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 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" + 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) + + +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/loader.py b/src/uipath/runtime/governance/native/loader.py new file mode 100644 index 0000000..5b45d21 --- /dev/null +++ b/src/uipath/runtime/governance/native/loader.py @@ -0,0 +1,342 @@ +"""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 new file mode 100644 index 0000000..c8f9dd9 --- /dev/null +++ b/src/uipath/runtime/governance/runtime.py @@ -0,0 +1,126 @@ +"""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. +""" + +from __future__ import annotations + +import logging +from typing import Any, AsyncGenerator + +from uipath.core.governance import GovernancePolicyProvider + +from uipath.runtime.base import ( + UiPathExecuteOptions, + UiPathRuntimeProtocol, + UiPathStreamOptions, +) +from uipath.runtime.events import UiPathRuntimeEvent +from uipath.runtime.governance.native.loader import PolicyLoader +from uipath.runtime.result import UiPathRuntimeResult +from uipath.runtime.schema import UiPathRuntimeSchema + +logger = logging.getLogger(__name__) + + +class GovernanceRuntime: + """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. + + Constructs an instance-scoped :class:`PolicyLoader` bound to the + supplied provider and kicks off a non-blocking prefetch so the + policy pack overlaps with the rest of agent setup. When + ``policy_provider`` is ``None``, the loader yields an empty + PolicyIndex and the agent runs without any governance policies for + the lifetime of this instance. + + **Policy loading only — no enforcement yet.** ``execute`` / ``stream`` + / ``get_schema`` / ``dispose`` are passthroughs to the delegate; no + per-hook policy evaluation runs in this slice. The evaluator and + framework adapter wiring that consumes the loader's policy index is + staged separately. + """ + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + policy_provider: GovernancePolicyProvider | None, + *, + is_conversational: bool | None = 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. + 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._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, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the delegate. Policy evaluation hooks are wired separately.""" + return await self._delegate.execute(input, options=options) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events from the delegate. Hooks are wired separately.""" + async for event in self._delegate.stream(input, options=options): + yield event + + async def get_schema(self) -> UiPathRuntimeSchema: + """Passthrough schema for the delegate.""" + return await self._delegate.get_schema() + + async def dispose(self) -> None: + """Dispose the delegate.""" + await self._delegate.dispose() diff --git a/tests/_helpers.py b/tests/_helpers.py index 7d839ea..2d3d924 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,19 +1,46 @@ """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: 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 -from uipath.runtime.governance import config +import time + +from uipath.core.governance import PolicyContext, PolicyResponse -def reset_enforcement_mode() -> None: - """Clear the process-wide enforcement mode so the AUDIT default re-applies. +class StubPolicyProvider: + """Minimal in-memory :class:`GovernancePolicyProvider` for tests. - Test isolation only — production code never resets the mode; the policy - loader sets it from the backend ``/runtime/policy`` response. + Records every :class:`PolicyContext` it receives so tests can assert + on the selector that travelled to the provider. Either returns a + pre-canned :class:`PolicyResponse` or raises a pre-canned exception; + the optional ``slow`` knob lets tests exercise the prefetch-wait + path. """ - config._state.mode = None \ No newline at end of file + + def __init__( + self, + response: PolicyResponse | None = None, + raises: Exception | None = None, + slow: float = 0.0, + ): + self.calls: list[PolicyContext] = [] + self._response = response + self._raises = raises + self._slow = slow + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + self.calls.append(context) + if self._slow: + time.sleep(self._slow) + if self._raises is not None: + raise self._raises + assert self._response is not None + return self._response diff --git a/tests/conftest.py b/tests/conftest.py index e337e96..ba76eca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,31 +19,7 @@ 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 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. - - ``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. - """ - 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() - yield - set_agent_conversational(None) - resolve_job_context.cache_clear() +# 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 992641a..78230fd 100644 --- a/tests/test_enforcement_mode_default.py +++ b/tests/test_enforcement_mode_default.py @@ -1,60 +1,114 @@ -"""Tests for the default enforcement-mode resolution. +"""Tests for the default enforcement-mode resolution on :class:`PolicyLoader`. The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at -runtime construction and the background policy fetch can run. If the -backend 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). -2. Default ``AUDIT``. +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 -import pytest - -from tests._helpers import reset_enforcement_mode -from uipath.runtime.governance.config import ( - EnforcementMode, - get_enforcement_mode, - set_enforcement_mode, -) - +from uipath.core.governance import EnforcementMode, PolicyResponse -@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 new file mode 100644 index 0000000..810a881 --- /dev/null +++ b/tests/test_governance_runtime.py @@ -0,0 +1,241 @@ +"""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 typing import Any + +from uipath.core.governance import ( + EnforcementMode, + PolicyResponse, +) + +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 + +SIMPLE_POLICY_YAML = """ +standard: provider-pack +version: "1.0" +rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["leak"] +""" + + +# Each test constructs a fresh ``PolicyLoader`` / ``GovernanceRuntime`` +# — no module-level state to reset. + + +# --------------------------------------------------------------------------- +# PolicyLoader — provider plumbing (mode application, context, errors) +# --------------------------------------------------------------------------- + + +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() + + 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() + 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 +# --------------------------------------------------------------------------- + + +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: Any = None, options: Any = None) -> Any: + self.execute_calls.append((input, options)) + return "result" + + 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) -> Any: + self.schema_called = True + return "schema" + + 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) + ) + + runtime = GovernanceRuntime(_StubDelegate(), policy_provider=provider) + + assert isinstance(runtime.loader, PolicyLoader) + assert runtime.loader._provider is provider + + +def test_governance_runtime_forwards_is_conversational_to_loader() -> None: + """The constructor's explicit ``is_conversational`` reaches PolicyContext.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + runtime = GovernanceRuntime( + _StubDelegate(), policy_provider=provider, is_conversational=True + ) + # Force the prefetch to land — load synchronously so we can read calls[0]. + runtime.loader.get_policy_index() + + assert provider.calls, "provider.get_policy was never invoked" + assert provider.calls[0].is_conversational is True + + +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() + + assert provider.calls[0].is_conversational is None + + +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 + + +async def test_governance_runtime_execute_delegates() -> None: + 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() -> None: + 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() -> None: + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + assert await runtime.get_schema() == "schema" + await runtime.dispose() + assert delegate.schema_called + assert delegate.disposed diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..87e453b --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,307 @@ +"""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_yaml_to_index.py b/tests/test_yaml_to_index.py new file mode 100644 index 0000000..5e8d338 --- /dev/null +++ b/tests/test_yaml_to_index.py @@ -0,0 +1,795 @@ +"""Tests for ``build_policy_index_from_yaml``. + +Covers every supported check type plus the pack / rule plumbing +(default action, severity defaults, hook resolution, multi-doc YAML, +malformed input handling). +""" + +from __future__ import annotations + +import pytest +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.native._yaml_to_index import ( + build_policy_index_from_yaml, +) +from uipath.runtime.governance.native.models import Severity + + +def _single_rule(yaml_text: str): + """Compile YAML and return the single rule; fail if not exactly one.""" + idx = build_policy_index_from_yaml(yaml_text) + rules = idx.all_rules + assert len(rules) == 1, f"expected 1 rule, got {len(rules)}" + return rules[0] + + +# --------------------------------------------------------------------------- +# Pack / document handling +# --------------------------------------------------------------------------- + + +def test_empty_yaml_returns_empty_index() -> None: + idx = build_policy_index_from_yaml("") + assert idx.total_rules == 0 + assert idx.pack_names == [] + + +def test_pack_without_rules_is_omitted() -> None: + """Packs with no parseable rules are dropped — never registered.""" + idx = build_policy_index_from_yaml( + """ + standard: empty-pack + version: "1.0" + rules: [] + """ + ) + assert idx.total_rules == 0 + assert "empty-pack" not in idx.pack_names + + +def test_pack_missing_name_is_skipped() -> None: + idx = build_policy_index_from_yaml( + """ + version: "1.0" + rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["foo"] + """ + ) + assert idx.total_rules == 0 + + +def test_pack_uses_standard_or_name_field() -> None: + """Either ``standard:`` or ``name:`` works as the pack identifier.""" + a = build_policy_index_from_yaml( + """ + standard: iso42001 + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + b = build_policy_index_from_yaml( + """ + name: iso42001 + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert "iso42001" in a.pack_names + assert "iso42001" in b.pack_names + + +def test_multi_document_yaml_concatenates_packs() -> None: + # YAML doc separators must be at column 0; dedent inline. + yaml_text = ( + "standard: pack-a\n" + "rules:\n" + " - id: a-r1\n" + " hook: before_model\n" + ' checks: [{type: regex, patterns: ["a"]}]\n' + "---\n" + "standard: pack-b\n" + "rules:\n" + " - id: b-r1\n" + " hook: after_model\n" + ' checks: [{type: regex, patterns: ["b"]}]\n' + ) + idx = build_policy_index_from_yaml(yaml_text) + assert set(idx.pack_names) == {"pack-a", "pack-b"} + assert idx.total_rules == 2 + + +def test_non_dict_top_level_documents_are_ignored() -> None: + """A YAML doc that's a string / list at top level is skipped silently.""" + yaml_text = ( + "just_a_string\n" + "---\n" + "standard: real-pack\n" + "rules:\n" + " - id: r\n" + " hook: before_model\n" + ' checks: [{type: regex, patterns: ["x"]}]\n' + ) + idx = build_policy_index_from_yaml(yaml_text) + assert idx.pack_names == ["real-pack"] + + +# --------------------------------------------------------------------------- +# Rule-level plumbing +# --------------------------------------------------------------------------- + + +def test_unknown_hook_skips_rule() -> None: + """A rule referencing an unknown hook is dropped, the rest survive.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: bad + hook: invented_hook + checks: [{type: regex, patterns: ["x"]}] + - id: good + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + rule_ids = [r.rule_id for r in idx.all_rules] + assert "bad" not in rule_ids + assert "good" in rule_ids + + +def test_non_dict_rule_entry_ignored() -> None: + """Rules entries that aren't dicts (lists, scalars) are skipped.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - "this is a string, not a rule" + - id: good + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert [r.rule_id for r in idx.all_rules] == ["good"] + + +def test_action_resolution_inherits_pack_default() -> None: + """When the rule omits action, the pack's default_action is used.""" + rule = _single_rule( + """ + standard: p + default_action: log + rules: + - id: r + hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.action == Action.AUDIT # log -> AUDIT per _ACTION_MAP + + +def test_action_resolution_unknown_falls_back_to_default() -> None: + """Unknown action string falls back to the pack default.""" + rule = _single_rule( + """ + standard: p + default_action: deny + rules: + - id: r + hook: before_model + action: bogus + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.action == Action.DENY + + +def test_severity_resolution_explicit() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + severity: critical + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.CRITICAL + + +def test_severity_default_high_for_deny_action() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + action: deny + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.HIGH + + +def test_severity_default_medium_for_non_deny_action() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + action: log + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.MEDIUM + + +def test_unknown_severity_falls_back_to_high() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + severity: ridiculous + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.severity == Severity.HIGH + + +def test_disabled_flag_propagates() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + enabled: false + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert rule.enabled is False + + +def test_rule_without_id_gets_index_based_id() -> None: + """When ``id:`` is missing, a positional fallback ``RULE-N`` is used.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - hook: before_model + checks: [{type: regex, patterns: ["x"]}] + """ + ) + assert idx.all_rules[0].rule_id == "RULE-0" + + +def test_rule_with_zero_parsed_checks_is_skipped() -> None: + """A rule whose declared checks all fail to parse is dropped. + + Without this guard, a rule with no checks ``always matches`` in the + evaluator and would fire on every request. + """ + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: junk + hook: before_model + checks: + - type: totally_unknown_check_type + """ + ) + assert idx.total_rules == 0 + + +# --------------------------------------------------------------------------- +# Check types +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "hook_name,expected", + [ + ("before_agent", LifecycleHook.BEFORE_AGENT), + ("after_agent", LifecycleHook.AFTER_AGENT), + ("before_model", LifecycleHook.BEFORE_MODEL), + ("after_model", LifecycleHook.AFTER_MODEL), + ("tool_call", LifecycleHook.TOOL_CALL), + ("wrap_tool_call", LifecycleHook.TOOL_CALL), # alias + ("after_tool", LifecycleHook.AFTER_TOOL), + ], +) +def test_hook_resolution(hook_name: str, expected: LifecycleHook) -> None: + rule = _single_rule( + f""" + standard: p + rules: + - id: r + hook: {hook_name} + checks: [{{type: regex, patterns: ["x"]}}] + """ + ) + assert rule.hook == expected + + +def test_regex_check_multi_pattern_defaults_to_any_logic() -> None: + """Multiple regex patterns default to OR (any) — common case for ASI rules.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["pwn", "ignore_previous"] + """ + ) + assert rule.checks[0].logic == "any" + assert len(rule.checks[0].conditions) == 2 + + +def test_regex_check_single_pattern_defaults_to_all_logic() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["pwn"] + """ + ) + assert rule.checks[0].logic == "all" + + +def test_regex_check_explicit_logic_wins() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["a", "b"] + logic: all + """ + ) + assert rule.checks[0].logic == "all" + + +@pytest.mark.parametrize( + "scope,expected_field", + [ + (["human"], "model_input"), + (["system"], "model_input"), + (["ai"], "model_output"), + ("ai", "model_output"), # string form + (["tool_result"], "tool_result"), + (["unknown_thing"], "model_input"), # fallback + ], +) +def test_regex_scope_maps_to_field(scope, expected_field: str) -> None: + rule = _single_rule( + f""" + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex + patterns: ["x"] + scope: {scope!r} + """ + ) + assert rule.checks[0].conditions[0].field == expected_field + + +def test_budget_check_max_per_session() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: budget + max_tool_calls_per_session: 5 + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "gt" + assert cond.field == "session_state.tool_calls" + assert cond.value == 5 + + +def test_budget_check_multiple_thresholds() -> None: + """All three budget knobs become independent conditions.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: budget + max_tool_calls_per_session: 10 + max_tool_calls_per_minute: 5 + max_consecutive_tool_calls: 3 + """ + ) + assert len(rule.checks[0].conditions) == 3 + + +def test_tool_allowlist_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: tool_allowlist + blocked_tools: ["delete_file", "shell"] + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "in_list" + assert cond.field == "tool_name" + assert cond.value == ["delete_file", "shell"] + + +def test_tool_allowlist_empty_blocked_list_skipped() -> None: + """Empty ``blocked_tools`` means there's nothing to enforce — drop the rule.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: tool_allowlist + blocked_tools: [] + """ + ) + assert idx.total_rules == 0 + + +def test_parameter_validation_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: tool_call + checks: + - type: parameter_validation + additional_patterns: ["rm -rf", "/etc/passwd"] + """ + ) + check = rule.checks[0] + assert len(check.conditions) == 2 + assert all(c.field == "tool_args" for c in check.conditions) + # Multi-pattern parameter_validation defaults to OR logic + assert check.logic == "any" + + +def test_rate_limit_check_session_and_minute() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: rate_limit + max_llm_calls_per_session: 20 + max_llm_calls_per_minute: 5 + """ + ) + fields = {c.field for c in rule.checks[0].conditions} + assert fields == { + "session_state.llm_calls", + "session_state.llm_calls_per_minute", + } + + +def test_field_regex_check_threads_through_conditions() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: field_regex + conditions: + - operator: regex + field: model_output + value: "(?i)password" + message: "leaked password" + """ + ) + check = rule.checks[0] + assert check.message == "leaked password" + assert check.conditions[0].operator == "regex" + + +def test_data_quality_score_both_encoding_and_entropy() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_tool + checks: + - type: data_quality_score + field: tool_result + min_confidence: 0.8 + entropy_min: 2.0 + entropy_max: 6.0 + """ + ) + ops = {c.operator for c in rule.checks[0].conditions} + assert ops == {"encoding_concern", "entropy_concern"} + + +def test_data_quality_score_check_encoding_disabled() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_tool + checks: + - type: data_quality_score + check_encoding: false + check_entropy: true + """ + ) + ops = [c.operator for c in rule.checks[0].conditions] + assert "encoding_concern" not in ops + assert "entropy_concern" in ops + + +def test_incident_taxonomy_with_categories() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: incident_taxonomy + field: model_output + categories: [safety_refusal, tool_failure] + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "incident_concern" + assert cond.value == {"categories": ["safety_refusal", "tool_failure"]} + + +def test_incident_taxonomy_without_categories_uses_empty_dict() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: incident_taxonomy + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.value == {} + + +def test_commitment_extractor_default_flags() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: commitment_extractor + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "commitment_concern" + assert cond.value == {"require_amount": True, "require_deadline": False} + + +def test_commitment_extractor_custom_flags() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: after_model + checks: + - type: commitment_extractor + require_amount: false + require_deadline: true + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.value == {"require_amount": False, "require_deadline": True} + + +def test_sentiment_concern_check() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: sentiment_concern + threshold: -0.5 + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "vader_concern" + assert cond.value == {"threshold": -0.5} + + +def test_guardrail_fallback_inherits_rule_flags() -> None: + """Rule-level ``mapped_to_uipath`` / ``policy_enabled`` thread into the condition.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + mapped_to_uipath: true + policy_enabled: false + checks: + - type: guardrail_fallback + validator: pii_detection + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "guardrail_fallback" + assert cond.value == { + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": False, + } + + +def test_guardrail_fallback_default_flags_are_unmapped_and_enabled() -> None: + """When the rule omits the flags, the fallback never fires (disabled-only contract).""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: guardrail_fallback + validator: pii_detection + """ + ) + cond = rule.checks[0].conditions[0] + # ``guardrail_fallback`` operator fires only when mapped=True AND + # enabled=False; defaults of False / True ensure it stays silent. + assert cond.value["mapped_to_uipath"] is False + assert cond.value["policy_enabled"] is True + + +def test_explicit_conditions_win_over_check_type() -> None: + """Explicit ``conditions:`` short-circuits the per-type templating.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: regex # ignored, conditions wins + conditions: + - operator: contains + field: model_input + value: "secret" + message: "no secrets" + """ + ) + cond = rule.checks[0].conditions[0] + assert cond.operator == "contains" # not "regex" + assert cond.value == "secret" + assert rule.checks[0].message == "no secrets" + + +def test_explicit_conditions_negate_flag_propagates() -> None: + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - conditions: + - operator: contains + field: model_input + value: "allowed" + negate: true + """ + ) + assert rule.checks[0].conditions[0].negate is True + + +def test_non_dict_condition_in_explicit_list_is_skipped() -> None: + """A condition entry that isn't a dict is silently dropped. + + The first dict-with-``operator`` entry is what trips the + "explicit conditions" branch in ``_build_check``; out-of-order + scalar entries appear after the leading dict. + """ + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - conditions: + - operator: contains + field: model_input + value: "x" + - "not a dict" + """ + ) + assert len(rule.checks[0].conditions) == 1 + + +def test_unknown_check_type_skipped() -> None: + """Unknown check types are dropped without taking down sibling checks.""" + idx = build_policy_index_from_yaml( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - type: future_check_type + - type: regex + patterns: ["x"] + """ + ) + rule = idx.all_rules[0] + # Only the regex check survived. + assert len(rule.checks) == 1 + assert rule.checks[0].conditions[0].operator == "regex" + + +def test_non_dict_check_entry_skipped() -> None: + """Checks list entries that aren't dicts are silently ignored.""" + rule = _single_rule( + """ + standard: p + rules: + - id: r + hook: before_model + checks: + - "scalar instead of mapping" + - type: regex + patterns: ["x"] + """ + ) + assert len(rule.checks) == 1 diff --git a/uv.lock b/uv.lock index cc63250..39942dd 100644 --- a/uv.lock +++ b/uv.lock @@ -1148,16 +1148,16 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.19" +version = "0.5.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/f7/6250b221d99bfbfb3ac2cb79efb3049deac4b406d5b2ae93e67e21c34ba2/uipath_core-0.5.19.tar.gz", hash = "sha256:8a4b0425b4e1edf9588e04997c0fdbcc4d3051ac00337eb626d4d62129d73af4", size = 132274, upload-time = "2026-06-17T08:15:34.924Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/df/0b49804f00cda5641f41fdfc5f2f3b11d2dfb7f8ec956f74ffe02b4f76d3/uipath_core-0.5.21.tar.gz", hash = "sha256:be0d8a148cf27ffd86a06d2582e948d9ab181012616849181947c10dbcbfc81c", size = 135316, upload-time = "2026-06-23T11:16:43.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/b6/a8081ef1e08f0b12df36beac7d4176a53443d2d8da1f001cfc09da29083a/uipath_core-0.5.19-py3-none-any.whl", hash = "sha256:085ec8bb2c61711859127506bc472289ecad3b196378c3d42bc59e333496a2c6", size = 54889, upload-time = "2026-06-17T08:15:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3d/e3c5f935013cd2cb17edc4c826cbe1f3d513f2a4a07faf8dec80659420c5/uipath_core-0.5.21-py3-none-any.whl", hash = "sha256:dce40f766987e907655311e13d4b8106766152da3995794cdbcbf712603388dd", size = 57273, upload-time = "2026-06-23T11:16:41.701Z" }, ] [[package]] @@ -1191,7 +1191,7 @@ dev = [ requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "uipath-core", specifier = ">=0.5.19,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.21,<0.6.0" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ]