Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ description = "Runtime abstractions and interfaces for building agents and autom
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
dependencies = [
"uipath-core>=0.5.17, <0.6.0"
"uipath-core>=0.5.19, <0.6.0",
"pyyaml>=6.0, <7.0",
"vaderSentiment>=3.3.2, <4.0",
"chardet>=5.2.0, <8.0",
]
classifiers = [
"Intended Audience :: Developers",
Expand Down Expand Up @@ -40,6 +43,7 @@ dev = [
"pytest-cov>=4.1.0",
"pytest-mock>=3.11.1",
"pre-commit>=4.1.0",
"types-PyYAML>=6.0",
]

[tool.hatch.build.targets.wheel]
Expand Down
60 changes: 60 additions & 0 deletions src/uipath/runtime/governance/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this still leaves enforcement mode as process-global state in this PR, just behind _state instead of a global variable. shouldn t the resolved mode live under the evaluator instance?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I'll move the resolved mode onto the evaluator instance in a follow up

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#130
created an issue for better tracking



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
158 changes: 158 additions & 0 deletions src/uipath/runtime/governance/native/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Native policy model.

Rules, checks, conditions and pack indexes consumed by the native
governance evaluator.

These are the inputs of the native evaluator. The evaluator-agnostic
*output* types (``Action``, ``AuditRecord``, …) live in
:mod:`uipath.core.governance.models`.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum
from typing import Any

from uipath.core.governance.models import Action, LifecycleHook


class Severity(Enum):
"""Rule severity levels."""

LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"


class Logic(str, Enum):
"""How a check combines its conditions."""

ALL = "all" # AND — every condition must hold.
ANY = "any" # OR — any matching condition is a hit.


@dataclass
class Condition:
"""A single condition within a rule check."""

operator: str
field: str
value: Any
negate: bool = False


@dataclass
class Check:
"""A check within a rule - contains conditions and action."""

conditions: list[Condition]
action: Action = Action.DENY
message: str = ""
logic: Logic = Logic.ALL


@dataclass
class Rule:
"""A compliance rule with checks evaluated at a specific lifecycle hook."""

rule_id: str
name: str
clause: str
hook: LifecycleHook
action: Action

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we also have an action per check. what happens if

  Rule.action = Action.AUDIT
  Check.action = Action.DENY 

?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Rule.action = AUDIT, Check.action = DENY: the matched check's action wins, so the rule resolves to DENY. Then enforcement mode decides: ENFORCE blocks it, AUDIT downgrades it to log-only, DISABLED skips.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, thanks.
I would include this in a docstring as well

severity: Severity = Severity.HIGH
checks: list[Check] = field(default_factory=list)
enabled: bool = True
description: str = ""
pack_name: str = ""

# Approval configuration (for ESCALATE action)
approval_config: dict[str, Any] = field(default_factory=dict)


@dataclass
class CheckContext:

@radu-mocanu radu-mocanu Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of a dataclass with a large bag of fields we should use hook-specific contexts as pydantic objects

  BeforeAgentContext
  AfterAgentContext
  BeforeModelContext
  ToolCallContext
  AfterToolContext

and create a union type for them. this will provide out-of-the-box model validation so one can t write stuff like:

  CheckContext(
      hook=LifecycleHook.TOOL_CALL,
      agent_output="some output",
      tool_name=""
  )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting CheckContext into per hook Pydantic types plus a union touches multiple files: the models definition, every hook construction site in the evaluator, the places reading the context fields, and the context tests. Since it spans the stack, I'll track it in the parent PR and do it once these get merged.

"""Context passed to rule evaluation."""

hook: LifecycleHook
agent_name: str
runtime_id: str
trace_id: str

# Content fields (populated based on hook)
agent_input: str = ""
agent_output: str = ""
model_input: str = ""
model_output: str = ""
model_name: str = ""
tool_name: str = ""
tool_args: dict[str, Any] = field(default_factory=dict)
tool_result: str = ""
messages: list[dict[str, Any]] = field(default_factory=list)

# Session state
session_state: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)

# Ring level (privilege level: 0=system, 1=admin, 2=user, 3=untrusted)
ring: int = 2


@dataclass
class PolicyPack:
"""A collection of rules for a compliance standard."""

name: str
version: str
description: str
rules: list[Rule]
enabled: bool = True


@dataclass
class PolicyIndex:
"""Index of all loaded policy packs and rules."""

packs: dict[str, PolicyPack] = field(default_factory=dict)
_rules_by_id: dict[str, Rule] = field(default_factory=dict)
_rules_by_hook: dict[LifecycleHook, list[Rule]] = field(default_factory=dict)

def add_pack(self, pack: PolicyPack) -> None:
"""Add a policy pack to the index."""
self.packs[pack.name] = pack
for rule in pack.rules:
rule.pack_name = pack.name
self._rules_by_id[rule.rule_id] = rule
if rule.hook not in self._rules_by_hook:
self._rules_by_hook[rule.hook] = []
self._rules_by_hook[rule.hook].append(rule)

def get_rule(self, rule_id: str) -> Rule | None:
"""Get a rule by ID."""
return self._rules_by_id.get(rule_id)

def get_rules_for_hook(self, hook: LifecycleHook) -> list[Rule]:
"""Get all rules for a lifecycle hook."""
return self._rules_by_hook.get(hook, [])

def get_rules_for_pack(self, pack_name: str) -> list[Rule]:
"""Get all rules for a pack."""
pack = self.packs.get(pack_name)
return pack.rules if pack else []

@property
def pack_names(self) -> list[str]:
"""Get all pack names."""
return list(self.packs.keys())

@property
def total_rules(self) -> int:
"""Get total number of rules."""
return len(self._rules_by_id)

@property
def all_rules(self) -> list[Rule]:
"""Get all rules."""
return list(self._rules_by_id.values())
19 changes: 19 additions & 0 deletions tests/_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Shared test-only helpers.

Keeps test concerns out of the production governance package: the
enforcement-mode reset used for per-test isolation lives here rather than
in :mod:`uipath.runtime.governance.config`.
"""

from __future__ import annotations

from uipath.runtime.governance import config


def reset_enforcement_mode() -> None:
"""Clear the process-wide enforcement mode so the AUDIT default re-applies.

Test isolation only — production code never resets the mode; the policy
loader sets it from the backend ``/runtime/policy`` response.
"""
config._state.mode = None
30 changes: 30 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,33 @@ def temp_dir() -> Generator[str, None, None]:
"""Provide a temporary directory for test files."""
with tempfile.TemporaryDirectory() as tmp_dir:
yield tmp_dir


@pytest.fixture(autouse=True)
def _reset_governance_process_state() -> Generator[None, None, None]:
"""Clear process-level governance state around every test.

The native governance layer keeps two pieces of state at module scope:
the conversational/autonomous selector consumed by the policy fetch,
and the memoized job-context. Both are stable per process in
production but leak across tests when not reset, masking ordering
bugs and producing flakes.

``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()
60 changes: 60 additions & 0 deletions tests/test_enforcement_mode_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""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.

Resolution (per :func:`get_enforcement_mode`):
1. The backend-supplied value set via ``set_enforcement_mode`` (the
``/runtime/policy`` response, applied by the policy loader).
2. Default ``AUDIT``.
"""

from __future__ import annotations

import pytest

from tests._helpers import reset_enforcement_mode
from uipath.runtime.governance.config import (
EnforcementMode,
get_enforcement_mode,
set_enforcement_mode,
)


@pytest.fixture(autouse=True)
def _isolate_mode():
"""Each test starts from a clean module-state slate."""
reset_enforcement_mode()
yield
reset_enforcement_mode()


def test_default_mode_is_audit() -> None:
"""No backend-supplied mode → AUDIT.

AUDIT is the default so the wrapper attaches and the background
policy fetch can run. The backend can flip the 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
Loading
Loading