diff --git a/pyproject.toml b/pyproject.toml index e9f9950..1c06982 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Runtime abstractions and interfaces for building agents and autom readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.19, <0.6.0", + "uipath-core>=0.5.21, <0.6.0", "pyyaml>=6.0, <7.0", "vaderSentiment>=3.3.2, <4.0", "chardet>=5.2.0, <8.0", diff --git a/src/uipath/runtime/governance/config.py b/src/uipath/runtime/governance/config.py index f74d51d..d766dfd 100644 --- a/src/uipath/runtime/governance/config.py +++ b/src/uipath/runtime/governance/config.py @@ -3,10 +3,10 @@ The feature-flag gate (``is_governance_enabled``) lives in :mod:`uipath.core.governance.config` because it is process-level and must be resolvable by callers that do not depend on -``uipath-runtime``. The enforcement mode is *per-policy* — owned by the -backend and delivered on each policy fetch via the ``/runtime/policy`` -endpoint — and therefore lives here in the runtime package alongside the -policy loader that applies it. +``uipath-runtime``. The enforcement mode is *per-policy* — +provider-supplied on each policy load — and therefore lives here in +the runtime package alongside the policy loader that applies it via +:func:`set_enforcement_mode`. """ from __future__ import annotations @@ -23,30 +23,30 @@ class _EnforcementModeState: A single module-level instance backs the get/set/reset helpers, so the mode is updated by mutating an attribute rather than rebinding a module - global. ``mode is None`` means "not yet set by the backend" — until - then (and if the backend omits a mode) governance defaults to AUDIT. + global. ``mode is None`` means "no provider has supplied a mode yet" — + until then (and if the provider omits a mode) governance defaults to + AUDIT. """ def __init__(self) -> None: self.mode: EnforcementMode | None = None -# The enforcement mode is owned by the backend: the policy loader applies -# the mode from the ``/runtime/policy`` response via -# :func:`set_enforcement_mode`. +# The enforcement mode is supplied by the policy provider on each load; +# the loader applies it via :func:`set_enforcement_mode`. _state = _EnforcementModeState() def get_enforcement_mode() -> EnforcementMode: """Return the current enforcement mode. - The canonical source is the backend ``/runtime/policy`` response, - applied by the policy loader via :func:`set_enforcement_mode`. Until - that fetch lands (or if the backend returns no mode), the default is - :attr:`EnforcementMode.AUDIT` — evaluate and log without blocking. - Defaulting to AUDIT avoids the chicken-and-egg where a DISABLED - default would short-circuit evaluation before the background policy - fetch could ever opt the tenant in. + The canonical source is whatever the policy provider supplied on + the most recent load, applied via :func:`set_enforcement_mode`. + Until that load lands (or if the provider returns no mode), the + default is :attr:`EnforcementMode.AUDIT` — evaluate and log without + blocking. Defaulting to AUDIT avoids the chicken-and-egg where a + DISABLED default would short-circuit evaluation before the + background policy load could ever opt the tenant in. """ return _state.mode if _state.mode is not None else EnforcementMode.AUDIT @@ -54,7 +54,7 @@ def get_enforcement_mode() -> EnforcementMode: def set_enforcement_mode(mode: EnforcementMode) -> None: """Set the enforcement mode programmatically. - The policy loader calls this with the backend-supplied mode on each - fetch so the evaluator picks up the platform-controlled value. + The policy loader calls this with the provider-supplied mode on + each load so the evaluator picks up the platform-controlled value. """ - _state.mode = mode \ No newline at end of file + _state.mode = mode diff --git a/src/uipath/runtime/governance/native/_yaml_to_index.py b/src/uipath/runtime/governance/native/_yaml_to_index.py 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..6603a50 --- /dev/null +++ b/src/uipath/runtime/governance/native/loader.py @@ -0,0 +1,321 @@ +"""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. +""" + +from __future__ import annotations + +import logging +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 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 + +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 + + +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 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 the provider is never invoked. + """ + 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. + logger.warning( + "Policy prefetch did not complete in %.1fs; " + "agent will run without any policies", + _PROVIDER_WAIT_SECONDS, + ) + _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. + _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: + _log_index_summary(index) + logger.info( + "Policy index ready: source=provider, total_ms=%.1f", + (time.perf_counter() - start) * 1000, + ) + return 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 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_provider(provider: GovernancePolicyProvider) -> PolicyIndex | None: + """Fetch and parse the policy index via a :class:`GovernancePolicyProvider`. + + 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() + + 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 + + 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.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(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 + + +def clear_policy_cache() -> None: + """Clear the cached policy index and any in-flight prefetch state. + + 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") diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py new file mode 100644 index 0000000..12001c2 --- /dev/null +++ b/src/uipath/runtime/governance/runtime.py @@ -0,0 +1,162 @@ +"""Governance runtime wrapper. + +Wraps a :class:`UiPathRuntimeProtocol` delegate so policy data is sourced +through a :class:`GovernancePolicyProvider`. The provider owns the wire +/ transport (auth, retries, telemetry); the runtime only consumes the +parsed :class:`PolicyResponse`. There is no direct backend fallback — +when ``policy_provider`` is ``None`` the agent runs without any +governance policies. + +**Staging caveat — policy loading only, no enforcement yet.** This +module is the policy-loading scaffold: ``__init__`` registers the +provider, extracts the conversational/autonomous selector, and kicks +off a background prefetch into the loader cache. ``execute`` / +``stream`` / ``get_schema`` / ``dispose`` are pure passthroughs — no +per-hook policy evaluation runs. The evaluator + adapter wiring that +consumes :func:`get_policy_index` lands in a follow-up slice. Customers +constructing :class:`GovernanceRuntime` today get policy loading without +policy enforcement; this is intentional and will change when the +evaluator slice merges. +""" + +from __future__ import annotations + +import logging +from typing import Any, AsyncGenerator + +from uipath.core.governance import GovernancePolicyProvider +from uipath.core.governance.config import is_governance_enabled + +from uipath.runtime.base import ( + UiPathExecuteOptions, + UiPathRuntimeProtocol, + UiPathStreamOptions, +) +from uipath.runtime.events import UiPathRuntimeEvent +from uipath.runtime.governance.native.loader import ( + prefetch_policy_index, + set_agent_conversational, + set_policy_provider, +) +from uipath.runtime.result import UiPathRuntimeResult +from uipath.runtime.schema import UiPathRuntimeSchema + +logger = logging.getLogger(__name__) + +# Bound on how deeply we walk ``_delegate`` / ``delegate`` chains when +# looking for an :class:`AgentDefinition`. Wrappers like +# :class:`UiPathExecutionRuntime` and :class:`UiPathResumableRuntime` +# add at most a handful of layers; 10 is well above any realistic +# stack and keeps a pathological self-referential wrapper from looping. +_MAX_DELEGATE_UNWRAP_DEPTH = 10 + + +def _extract_is_conversational(delegate: object) -> bool | None: + """Read ``is_conversational`` off the delegate's agent definition. + + Walks ``delegate._agent_definition.is_conversational`` (the + LicensedRuntime pattern published by the agents SDK), unwrapping + the ``_delegate`` / ``delegate`` chain up to + :data:`_MAX_DELEGATE_UNWRAP_DEPTH` so wrapper layers don't hide the + licensed runtime. + + Returns ``None`` when no agent definition is reachable — the + provider then applies its default rather than the runtime guessing + a value. + """ + node: object | None = delegate + for _ in range(_MAX_DELEGATE_UNWRAP_DEPTH): + if node is None: + break + agent_def = getattr(node, "_agent_definition", None) + if agent_def is not None: + value = getattr(agent_def, "is_conversational", None) + if value is not None: + return bool(value) + node = getattr(node, "_delegate", None) or getattr(node, "delegate", None) + return None + + +class GovernanceRuntime: + """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. + + Registers the supplied :class:`GovernancePolicyProvider` with the + policy loader and kicks off a non-blocking prefetch so the policy + pack overlaps with the rest of agent setup. When ``policy_provider`` + is ``None``, no provider is registered and the agent runs without + any governance policies (the loader yields an empty PolicyIndex). + + **Policy loading only — no enforcement yet.** ``execute`` / ``stream`` + / ``get_schema`` / ``dispose`` are passthroughs to the delegate; no + per-hook policy evaluation runs in this slice. The evaluator and + framework adapter wiring that consumes :func:`get_policy_index` is + staged separately. Constructing this wrapper today gives you the + policy load (provider invoked, index cached) but no actual + enforcement of the loaded rules. + """ + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + policy_provider: GovernancePolicyProvider | None, + ): + """Initialize the governance runtime. + + Args: + delegate: The wrapped runtime to forward execution to. + policy_provider: Source of the policy pack. ``None`` means + no policies will be loaded — the agent runs without + governance for the lifetime of this instance. + """ + self._delegate = delegate + self._policy_provider = policy_provider + + if is_governance_enabled(): + # Record agent-type before the prefetch fires so the + # provider's first ``get_policy`` call sees the right + # selector on its ``PolicyContext``. Wrapped in try/except + # so a misbehaving delegate getattr can't break runtime + # init — fail-open: on failure the selector keeps whatever + # value an integration may have set externally. + # + # Only write when extraction returned a concrete bool. An + # extraction miss (``None``) leaves the selector untouched + # so an externally-set value (e.g. an integration that + # pre-seeded the selector from a different signal) is not + # silently clobbered by our init. + try: + extracted = _extract_is_conversational(delegate) + except Exception as exc: # noqa: BLE001 - fail-open + logger.warning( + "Failed to extract is_conversational from delegate: %s", exc + ) + else: + if extracted is not None: + set_agent_conversational(extracted) + set_policy_provider(policy_provider) + prefetch_policy_index() + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the delegate. Policy evaluation hooks are wired separately.""" + return await self._delegate.execute(input, options=options) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events from the delegate. Hooks are wired separately.""" + async for event in self._delegate.stream(input, options=options): + yield event + + async def get_schema(self) -> UiPathRuntimeSchema: + """Passthrough schema for the delegate.""" + return await self._delegate.get_schema() + + async def dispose(self) -> None: + """Dispose the delegate.""" + await self._delegate.dispose() diff --git a/tests/_helpers.py b/tests/_helpers.py index 7d839ea..c7dbbd8 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,12 +1,16 @@ """Shared test-only helpers. -Keeps test concerns out of the production governance package: the -enforcement-mode reset used for per-test isolation lives here rather than -in :mod:`uipath.runtime.governance.config`. +Keeps test concerns out of the production governance package: per-test +isolation utilities and shared stubs live here rather than inside the +production modules. """ from __future__ import annotations +import time + +from uipath.core.governance import PolicyContext, PolicyResponse + from uipath.runtime.governance import config @@ -14,6 +18,37 @@ def reset_enforcement_mode() -> None: """Clear the process-wide enforcement mode so the AUDIT default re-applies. Test isolation only — production code never resets the mode; the policy - loader sets it from the backend ``/runtime/policy`` response. + loader sets it from the provider-supplied :class:`PolicyResponse`. + """ + config._state.mode = None + + +class StubPolicyProvider: + """Minimal in-memory :class:`GovernancePolicyProvider` for tests. + + Records every :class:`PolicyContext` it receives so tests can assert + on the selector that travelled to the provider. Either returns a + pre-canned :class:`PolicyResponse` or raises a pre-canned exception; + the optional ``slow`` knob lets tests exercise the prefetch-wait + path. """ - config._state.mode = None \ No newline at end of file + + def __init__( + self, + response: PolicyResponse | None = None, + raises: Exception | None = None, + slow: float = 0.0, + ): + self.calls: list[PolicyContext] = [] + self._response = response + self._raises = raises + self._slow = slow + + def get_policy(self, context: PolicyContext) -> PolicyResponse: + self.calls.append(context) + if self._slow: + time.sleep(self._slow) + if self._raises is not None: + raise self._raises + assert self._response is not None + return self._response diff --git a/tests/conftest.py b/tests/conftest.py index e337e96..78ea6ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,27 +23,23 @@ def temp_dir() -> Generator[str, None, None]: def _reset_governance_process_state() -> Generator[None, None, None]: """Clear process-level governance state around every test. - The native governance layer keeps two pieces of state at module scope: - the conversational/autonomous selector consumed by the policy fetch, - and the memoized job-context. Both are stable per process in + The loader keeps the conversational selector and the registered + policy provider at module scope. Both are stable per process in production but leak across tests when not reset, masking ordering - bugs and producing flakes. - - ``backend_client`` is imported lazily and guarded: this shared - conftest ships alongside the foundation slice, where that module may - not exist yet, and the reset is simply a no-op until it does. + bugs and producing flakes. Import is guarded so this fixture is a + no-op when the governance package isn't built yet. """ try: - from uipath.runtime.governance.native.backend_client import ( - resolve_job_context, + from uipath.runtime.governance.native.loader import ( set_agent_conversational, + set_policy_provider, ) except ImportError: yield return set_agent_conversational(None) - resolve_job_context.cache_clear() + set_policy_provider(None) yield set_agent_conversational(None) - resolve_job_context.cache_clear() + set_policy_provider(None) diff --git a/tests/test_enforcement_mode_default.py b/tests/test_enforcement_mode_default.py index 992641a..5159c78 100644 --- a/tests/test_enforcement_mode_default.py +++ b/tests/test_enforcement_mode_default.py @@ -1,13 +1,13 @@ """Tests for the default enforcement-mode resolution. The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at -runtime construction and the background policy fetch can run. If the -backend later returns ``disabled``, ``set_enforcement_mode`` flips the -mode and ``evaluate()`` short-circuits per-call. +runtime construction and the background policy load can run. If the +provider later returns ``disabled``, ``set_enforcement_mode`` flips +the mode and ``evaluate()`` short-circuits per-call. Resolution (per :func:`get_enforcement_mode`): -1. The backend-supplied value set via ``set_enforcement_mode`` (the - ``/runtime/policy`` response, applied by the policy loader). +1. The provider-supplied value applied via ``set_enforcement_mode`` by + the policy loader. 2. Default ``AUDIT``. """ diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py new file mode 100644 index 0000000..b2b126b --- /dev/null +++ b/tests/test_governance_runtime.py @@ -0,0 +1,460 @@ +"""Tests for the GovernanceRuntime wrapper and the provider loader path.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +from uipath.core.governance import ( + EnforcementMode, + PolicyResponse, +) + +from tests._helpers import StubPolicyProvider, reset_enforcement_mode +from uipath.runtime.governance.config import get_enforcement_mode +from uipath.runtime.governance.native import loader +from uipath.runtime.governance.native.loader import ( + _load_from_provider, + clear_policy_cache, + load_policy_index, + set_agent_conversational, + set_policy_provider, +) +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.runtime import ( + GovernanceRuntime, + _extract_is_conversational, +) + +SIMPLE_POLICY_YAML = """ +standard: provider-pack +version: "1.0" +rules: + - id: r1 + hook: before_model + checks: + - type: regex + patterns: ["leak"] +""" + + +@pytest.fixture(autouse=True) +def _enable_ff_and_reset(monkeypatch: pytest.MonkeyPatch): + """Reset module state and turn the governance FF on per test.""" + from uipath.core.feature_flags import FeatureFlags + + clear_policy_cache() + reset_enforcement_mode() + set_policy_provider(None) + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True}) + yield + clear_policy_cache() + reset_enforcement_mode() + set_policy_provider(None) + FeatureFlags.reset_flags() + + +# --------------------------------------------------------------------------- +# _load_from_provider — direct unit tests +# --------------------------------------------------------------------------- + + +def test_load_from_provider_builds_index_and_applies_mode() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.ENFORCE, policies=SIMPLE_POLICY_YAML) + ) + + index = _load_from_provider(provider) + + assert isinstance(index, PolicyIndex) + assert index.total_rules == 1 + assert "provider-pack" in index.pack_names + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +def test_load_from_provider_passes_is_conversational_in_context() -> None: + set_agent_conversational(True) + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + _load_from_provider(provider) + + assert len(provider.calls) == 1 + assert provider.calls[0].is_conversational is True + + +def test_load_from_provider_returns_none_when_provider_raises() -> None: + provider = StubPolicyProvider(raises=RuntimeError("boom")) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_empty_policies() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_zero_rules() -> None: + empty_pack_yaml = "standard: empty\nrules: []\n" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=empty_pack_yaml) + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_returns_none_on_malformed_yaml() -> None: + provider = StubPolicyProvider( + response=PolicyResponse( + mode=EnforcementMode.AUDIT, policies="key: : invalid: : yaml" + ) + ) + + assert _load_from_provider(provider) is None + + +def test_load_from_provider_does_not_change_mode_when_none() -> None: + from uipath.runtime.governance.config import set_enforcement_mode + + set_enforcement_mode(EnforcementMode.ENFORCE) + provider = StubPolicyProvider( + response=PolicyResponse(mode=None, policies=SIMPLE_POLICY_YAML) + ) + + _load_from_provider(provider) + + assert get_enforcement_mode() == EnforcementMode.ENFORCE + + +# --------------------------------------------------------------------------- +# load_policy_index dispatch — registered provider vs empty fallback +# --------------------------------------------------------------------------- + + +def test_load_policy_index_uses_registered_provider() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + set_policy_provider(provider) + + index = load_policy_index() + + assert index.total_rules == 1 + assert provider.calls, "provider.get_policy was not called" + + +def test_load_policy_index_returns_empty_when_no_provider() -> None: + """No provider registered → empty PolicyIndex (no fallback path).""" + index = load_policy_index() + assert index.total_rules == 0 + + +def test_load_policy_index_empty_when_provider_yields_nothing() -> None: + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies="") + ) + set_policy_provider(provider) + + index = load_policy_index() + + assert index.total_rules == 0 + + +# --------------------------------------------------------------------------- +# GovernanceRuntime +# --------------------------------------------------------------------------- + + +class _StubDelegate: + """Captures delegate calls so the passthroughs can be asserted.""" + + def __init__(self) -> None: + self.execute_calls: list[tuple[Any, Any]] = [] + self.stream_calls: list[tuple[Any, Any]] = [] + self.disposed = False + self.schema_called = False + + async def execute(self, input=None, options=None): + self.execute_calls.append((input, options)) + return "result" + + async def stream(self, input=None, options=None): + self.stream_calls.append((input, options)) + for event in ("a", "b"): + yield event + + async def get_schema(self): + self.schema_called = True + return "schema" + + async def dispose(self): + self.disposed = True + + +def test_governance_runtime_registers_provider_and_prefetches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Init wires provider into loader state and kicks off prefetch.""" + provider = StubPolicyProvider( + response=PolicyResponse(mode=EnforcementMode.AUDIT, policies=SIMPLE_POLICY_YAML) + ) + + # Spy on prefetch + set_policy_provider so we don't need a real + # background thread in the unit test. + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + delegate = _StubDelegate() + + GovernanceRuntime(delegate, policy_provider=provider) + + set_provider_spy.assert_called_once_with(provider) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_with_none_provider_still_prefetches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Passing ``None`` registers None → loader yields an empty PolicyIndex.""" + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + GovernanceRuntime(_StubDelegate(), policy_provider=None) + + set_provider_spy.assert_called_once_with(None) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_skips_prefetch_when_ff_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FF off → no provider registration, no prefetch.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + + prefetch_spy = MagicMock() + set_provider_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + + GovernanceRuntime(_StubDelegate(), policy_provider=StubPolicyProvider()) + + assert not set_provider_spy.called + assert not prefetch_spy.called + + +async def test_governance_runtime_execute_delegates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + result = await runtime.execute({"x": 1}) + + assert result == "result" + assert delegate.execute_calls == [({"x": 1}, None)] + + +async def test_governance_runtime_stream_delegates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + events = [e async for e in runtime.stream({"x": 1})] + + assert events == ["a", "b"] + assert delegate.stream_calls == [({"x": 1}, None)] + + +async def test_governance_runtime_schema_and_dispose_delegate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", MagicMock() + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", MagicMock() + ) + delegate = _StubDelegate() + runtime = GovernanceRuntime(delegate, policy_provider=None) + + assert await runtime.get_schema() == "schema" + await runtime.dispose() + assert delegate.schema_called + assert delegate.disposed + + +# --------------------------------------------------------------------------- +# _extract_is_conversational +# --------------------------------------------------------------------------- + + +def test_extract_is_conversational_true_from_agent_definition() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True) + ) + assert _extract_is_conversational(delegate) is True + + +def test_extract_is_conversational_false_from_agent_definition() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=False) + ) + assert _extract_is_conversational(delegate) is False + + +def test_extract_is_conversational_returns_none_when_unreachable() -> None: + """No ``_agent_definition`` anywhere on the chain → ``None`` (let the provider default).""" + assert _extract_is_conversational(SimpleNamespace()) is None + + +def test_extract_is_conversational_returns_none_when_field_is_none() -> None: + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=None) + ) + assert _extract_is_conversational(delegate) is None + + +def test_extract_is_conversational_unwraps_via_underscore_delegate() -> None: + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=True)) + outer = SimpleNamespace(_delegate=inner) + assert _extract_is_conversational(outer) is True + + +def test_extract_is_conversational_unwraps_via_delegate_attr() -> None: + inner = SimpleNamespace(_agent_definition=SimpleNamespace(is_conversational=False)) + outer = SimpleNamespace(delegate=inner) + assert _extract_is_conversational(outer) is False + + +def test_extract_is_conversational_depth_capped() -> None: + """A pathological self-referential wrapper can't loop forever.""" + self_ref = SimpleNamespace() + self_ref._delegate = self_ref # type: ignore[attr-defined] + assert _extract_is_conversational(self_ref) is None + + +# --------------------------------------------------------------------------- +# GovernanceRuntime wires the selector +# --------------------------------------------------------------------------- + + +def test_governance_runtime_sets_agent_type_from_delegate() -> None: + """Init reads ``delegate._agent_definition.is_conversational`` and writes the selector.""" + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True), + execute=_StubDelegate().execute, + stream=_StubDelegate().stream, + get_schema=_StubDelegate().get_schema, + dispose=_StubDelegate().dispose, + ) + + # Don't run the real prefetch thread — just confirm the selector + # ended up where the provider would read it. + GovernanceRuntime(delegate, policy_provider=None) + + assert loader._agent_is_conversational is True + + +def test_governance_runtime_sets_none_when_agent_definition_missing() -> None: + """No ``_agent_definition`` → selector stays unset (``None``).""" + GovernanceRuntime(_StubDelegate(), policy_provider=None) + assert loader._agent_is_conversational is None + + +def test_governance_runtime_preserves_externally_set_selector_on_extraction_miss() -> None: + """Externally-set selector survives a runtime init that finds no ``_agent_definition``. + + Regression: previously ``__init__`` unconditionally wrote whatever + ``_extract_is_conversational`` returned, so an extraction miss + (``None``) silently clobbered a value an integration had pre-seeded. + """ + set_agent_conversational(True) + GovernanceRuntime(_StubDelegate(), policy_provider=None) + assert loader._agent_is_conversational is True + + +def test_governance_runtime_fails_open_when_extraction_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A pathological delegate accessor raising mid-extraction can't break init.""" + monkeypatch.setattr( + "uipath.runtime.governance.runtime._extract_is_conversational", + MagicMock(side_effect=RuntimeError("boom")), + ) + set_provider_spy = MagicMock() + prefetch_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime.set_policy_provider", set_provider_spy + ) + monkeypatch.setattr( + "uipath.runtime.governance.runtime.prefetch_policy_index", prefetch_spy + ) + + # No exception escapes; the rest of init still runs. + GovernanceRuntime(_StubDelegate(), policy_provider=None) + + set_provider_spy.assert_called_once_with(None) + prefetch_spy.assert_called_once_with() + + +def test_governance_runtime_skips_extraction_when_ff_off( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """FF off → no selector write, no provider registration, no prefetch.""" + from uipath.core.feature_flags import FeatureFlags + + FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": False}) + + extract_spy = MagicMock() + monkeypatch.setattr( + "uipath.runtime.governance.runtime._extract_is_conversational", extract_spy + ) + + delegate = SimpleNamespace( + _agent_definition=SimpleNamespace(is_conversational=True), + execute=_StubDelegate().execute, + stream=_StubDelegate().stream, + get_schema=_StubDelegate().get_schema, + dispose=_StubDelegate().dispose, + ) + GovernanceRuntime(delegate, policy_provider=None) + + assert not extract_spy.called + assert loader._agent_is_conversational is None diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..23df2d6 --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,269 @@ +"""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`. +""" + +from __future__ import annotations + +import threading +import time +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 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.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 + ) + + +@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() + 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 +# --------------------------------------------------------------------------- + + +def test_empty_index_reason_no_provider() -> None: + msg = _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() + assert "provider returned no policies" in msg + + +# --------------------------------------------------------------------------- +# load_policy_index — public entry +# --------------------------------------------------------------------------- + + +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 == 0 + + +def test_load_policy_index_uses_registered_provider() -> None: + provider = StubPolicyProvider(response=_ok_response()) + set_policy_provider(provider) + + 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_when_provider_raises() -> None: + set_policy_provider(StubPolicyProvider(raises=RuntimeError("boom"))) + index = load_policy_index() + 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-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 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() + 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_get(context: PolicyContext) -> PolicyResponse: + block.wait(timeout=2.0) + return _ok_response() + + 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: + """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 + + 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.""" + 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)})() + 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 provider failure.""" + 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: + 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: + 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_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" }, ]