Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
28 changes: 27 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ 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.18, <0.6.0",
# Governance native-evaluator deps. Live here because the native
# evaluator implementation lives in uipath.runtime.governance.native;
# uipath-core only carries the small governance contracts.
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
"pyyaml>=6.0",
"vaderSentiment>=3.3.2", # sentiment_concern (A.3.3)
"chardet>=5.2.0", # encoding_concern (A.7.4)
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
]
classifiers = [
"Intended Audience :: Developers",
Expand Down Expand Up @@ -40,6 +46,7 @@ dev = [
"pytest-cov>=4.1.0",
"pytest-mock>=3.11.1",
"pre-commit>=4.1.0",
"types-PyYAML>=6.0",
]

[tool.hatch.build.targets.wheel]
Expand Down Expand Up @@ -83,6 +90,25 @@ no_implicit_reexport = true

disallow_untyped_defs = false

# Third-party governance-evaluator libs have no type stubs / py.typed marker
[[tool.mypy.overrides]]
module = [
"yaml",
"vaderSentiment.*",
"chardet",
"price_parser",
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
# uipath.platform.common is imported lazily from traces.py / audit
# sinks to read UiPathConfig context attributes. It's first-party but
# not a uipath-runtime dep, so its stubs aren't installable here.
"uipath.platform.*",
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
# Optional framework adapters; the absence of the framework simply
# means the adapter no-ops at import time.
"agents",
"langchain_core.*",
"langgraph.*",
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
]
ignore_missing_imports = true

[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
Expand Down
85 changes: 85 additions & 0 deletions src/uipath/runtime/governance/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Runtime-level governance enforcement-mode state.

The feature-flag gate (``is_governance_enabled``) lives in
:mod:`uipath.core.governance.config` because it is process-level and
must be resolvable by callers that do not depend on
``uipath-runtime``. The enforcement mode is *per-policy* — set by the
backend on each policy fetch via the ``/runtime/policy`` endpoint —
and therefore lives here in the runtime package alongside the policy
loader that applies it.
"""

from __future__ import annotations

import logging
import os
from enum import Enum

logger = logging.getLogger(__name__)

ENV_ENFORCEMENT_MODE = "UIPATH_GOVERNANCE_MODE"


class EnforcementMode(str, Enum):
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated

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 need to either delete this class now and update the uipath-core dependency once this PR gets merged, or at least leave a ToDo comment here and delete this class in a subsequent PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Raising the uipath-core PR: UiPath/uipath-python#1727. I'll raise the runtime changes once the uipath-core changes are merged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deleted the class

"""Governance enforcement modes."""

AUDIT = "audit" # Evaluate and log; never block.
ENFORCE = "enforce" # Block on DENY rules.
DISABLED = "disabled" # Skip evaluation entirely.


_enforcement_mode: EnforcementMode | None = None


def get_enforcement_mode() -> EnforcementMode:
"""Return the current enforcement mode.

The mode is cached after first read. Resolution order:

1. A value previously set via :func:`set_enforcement_mode` (the
policy loader calls this with the backend-supplied mode on every
successful policy fetch — that's the canonical source).
2. ``UIPATH_GOVERNANCE_MODE`` env var (developer override).
3. Default :attr:`EnforcementMode.AUDIT` — evaluate and log without
blocking. The wrapper attaches at runtime construction so the
background policy fetch can run; if the backend returns
``disabled``, ``set_enforcement_mode`` flips the cache and
subsequent ``evaluate()`` calls short-circuit (the evaluator
skips evaluation in disabled mode). Defaulting to AUDIT avoids
the chicken-and-egg where a DISABLED
default would short-circuit before the policy fetch could ever
opt the tenant in.
"""
global _enforcement_mode
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
if _enforcement_mode is not None:
return _enforcement_mode

mode_str = os.getenv(ENV_ENFORCEMENT_MODE, "audit").lower()
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
try:
_enforcement_mode = EnforcementMode(mode_str)
except ValueError:
logger.warning(
"Invalid %s=%r; defaulting to %s",
ENV_ENFORCEMENT_MODE,
mode_str,
EnforcementMode.AUDIT.value,
)
_enforcement_mode = EnforcementMode.AUDIT

return _enforcement_mode


def set_enforcement_mode(mode: EnforcementMode) -> None:
"""Set the enforcement mode programmatically.

The policy loader calls this with the backend-supplied mode on each
fetch so the evaluator picks up the platform-controlled value.
"""
global _enforcement_mode
_enforcement_mode = mode


def reset_enforcement_mode() -> None:
"""Clear cached enforcement mode (intended for tests)."""
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
global _enforcement_mode
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
_enforcement_mode = None
153 changes: 153 additions & 0 deletions src/uipath/runtime/governance/native/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""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"


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

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


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

conditions: list[Condition]
action: Action = Action.DENY
message: str = ""
logic: str = "all" # "all" (AND) or "any" (OR)
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated


@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
Contributor 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
Contributor 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 = (

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.

CheckContext narrows most runtime payloads to str, but runtime inputs/outputs/tool results are often structured objects. should these be Any/dict/message types, or should serialization happen explicitly in the evaluator?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll pick this up together with the per-hook context split, since both touch the same CheckContext types and evaluator serialization. The fields are typed str today but the evaluator already stringifies, so I'll widen them to accept structured payloads and add explicit serialization in the evaluator as part of that refactor.

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.

#129
created an issue for better tracking

"" # LLM model name (e.g., "gpt-4", "claude-3-opus") - available at agent start
Comment thread
radu-mocanu marked this conversation as resolved.
Outdated
)
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())
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()
Loading
Loading