Skip to content

Commit 5b3582f

Browse files
aditik0303claude
andcommitted
feat(governance): enforcement-mode config, policy models, deps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4233a02 commit 5b3582f

6 files changed

Lines changed: 553 additions & 6 deletions

File tree

pyproject.toml

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ description = "Runtime abstractions and interfaces for building agents and autom
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath-core>=0.5.17, <0.6.0"
8+
"uipath-core>=0.5.18, <0.6.0",
9+
# Governance native-evaluator deps. Live here because the native
10+
# evaluator implementation lives in uipath.runtime.governance.native;
11+
# uipath-core only carries the small governance contracts.
12+
"pyyaml>=6.0",
13+
"vaderSentiment>=3.3.2", # sentiment_concern (A.3.3)
14+
"chardet>=5.2.0", # encoding_concern (A.7.4)
915
]
1016
classifiers = [
1117
"Intended Audience :: Developers",
@@ -40,6 +46,7 @@ dev = [
4046
"pytest-cov>=4.1.0",
4147
"pytest-mock>=3.11.1",
4248
"pre-commit>=4.1.0",
49+
"types-PyYAML>=6.0",
4350
]
4451

4552
[tool.hatch.build.targets.wheel]
@@ -83,6 +90,25 @@ no_implicit_reexport = true
8390

8491
disallow_untyped_defs = false
8592

93+
# Third-party governance-evaluator libs have no type stubs / py.typed marker
94+
[[tool.mypy.overrides]]
95+
module = [
96+
"yaml",
97+
"vaderSentiment.*",
98+
"chardet",
99+
"price_parser",
100+
# uipath.platform.common is imported lazily from traces.py / audit
101+
# sinks to read UiPathConfig context attributes. It's first-party but
102+
# not a uipath-runtime dep, so its stubs aren't installable here.
103+
"uipath.platform.*",
104+
# Optional framework adapters; the absence of the framework simply
105+
# means the adapter no-ops at import time.
106+
"agents",
107+
"langchain_core.*",
108+
"langgraph.*",
109+
]
110+
ignore_missing_imports = true
111+
86112
[tool.pydantic-mypy]
87113
init_forbid_extra = true
88114
init_typed = true
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Runtime-level governance enforcement-mode state.
2+
3+
The feature-flag gate (``is_governance_enabled``) lives in
4+
:mod:`uipath.core.governance.config` because it is process-level and
5+
must be resolvable by callers that do not depend on
6+
``uipath-runtime``. The enforcement mode is *per-policy* — set by the
7+
backend on each policy fetch via the ``/runtime/policy`` endpoint —
8+
and therefore lives here in the runtime package alongside the policy
9+
loader that applies it.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
import os
16+
from enum import Enum
17+
18+
logger = logging.getLogger(__name__)
19+
20+
ENV_ENFORCEMENT_MODE = "UIPATH_GOVERNANCE_MODE"
21+
22+
23+
class EnforcementMode(str, Enum):
24+
"""Governance enforcement modes."""
25+
26+
AUDIT = "audit" # Evaluate and log; never block.
27+
ENFORCE = "enforce" # Block on DENY rules.
28+
DISABLED = "disabled" # Skip evaluation entirely.
29+
30+
31+
_enforcement_mode: EnforcementMode | None = None
32+
33+
34+
def get_enforcement_mode() -> EnforcementMode:
35+
"""Return the current enforcement mode.
36+
37+
The mode is cached after first read. Resolution order:
38+
39+
1. A value previously set via :func:`set_enforcement_mode` (the
40+
policy loader calls this with the backend-supplied mode on every
41+
successful policy fetch — that's the canonical source).
42+
2. ``UIPATH_GOVERNANCE_MODE`` env var (developer override).
43+
3. Default :attr:`EnforcementMode.AUDIT` — evaluate and log without
44+
blocking. The wrapper attaches at runtime construction so the
45+
background policy fetch can run; if the backend returns
46+
``disabled``, ``set_enforcement_mode`` flips the cache and
47+
subsequent ``evaluate()`` calls short-circuit at evaluator.py:332.
48+
Defaulting to AUDIT avoids the chicken-and-egg where a DISABLED
49+
default would short-circuit before the policy fetch could ever
50+
opt the tenant in.
51+
"""
52+
global _enforcement_mode
53+
if _enforcement_mode is not None:
54+
return _enforcement_mode
55+
56+
mode_str = os.getenv(ENV_ENFORCEMENT_MODE, "audit").lower()
57+
try:
58+
_enforcement_mode = EnforcementMode(mode_str)
59+
except ValueError:
60+
_enforcement_mode = EnforcementMode.AUDIT
61+
62+
return _enforcement_mode
63+
64+
65+
def set_enforcement_mode(mode: EnforcementMode) -> None:
66+
"""Set the enforcement mode programmatically.
67+
68+
The policy loader calls this with the backend-supplied mode on each
69+
fetch so the evaluator picks up the platform-controlled value.
70+
"""
71+
global _enforcement_mode
72+
_enforcement_mode = mode
73+
74+
75+
def reset_enforcement_mode() -> None:
76+
"""Clear cached enforcement mode (intended for tests)."""
77+
global _enforcement_mode
78+
_enforcement_mode = None
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Native policy model.
2+
3+
Rules, checks, conditions and pack indexes consumed by
4+
:class:`uipath.runtime.governance.native.evaluator.GovernanceEvaluator`.
5+
6+
These are the inputs of the native evaluator. The evaluator-agnostic
7+
*output* types (``Action``, ``AuditRecord``, …) live in
8+
:mod:`uipath.core.governance.models`.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from dataclasses import dataclass, field
14+
from enum import Enum
15+
from typing import Any
16+
17+
from uipath.core.governance.models import Action, LifecycleHook
18+
19+
20+
class Severity(Enum):
21+
"""Rule severity levels."""
22+
23+
LOW = "low"
24+
MEDIUM = "medium"
25+
HIGH = "high"
26+
CRITICAL = "critical"
27+
28+
29+
@dataclass
30+
class Condition:
31+
"""A single condition within a rule check."""
32+
33+
operator: str
34+
field: str
35+
value: Any
36+
negate: bool = False
37+
38+
39+
@dataclass
40+
class Check:
41+
"""A check within a rule - contains conditions and action."""
42+
43+
conditions: list[Condition]
44+
action: Action = Action.DENY
45+
message: str = ""
46+
logic: str = "all" # "all" (AND) or "any" (OR)
47+
48+
49+
@dataclass
50+
class Rule:
51+
"""A compliance rule with checks evaluated at a specific lifecycle hook."""
52+
53+
rule_id: str
54+
name: str
55+
clause: str
56+
hook: LifecycleHook
57+
action: Action
58+
severity: Severity = Severity.HIGH
59+
checks: list[Check] = field(default_factory=list)
60+
enabled: bool = True
61+
description: str = ""
62+
pack_name: str = ""
63+
64+
# Approval configuration (for ESCALATE action)
65+
approval_config: dict[str, Any] = field(default_factory=dict)
66+
67+
68+
@dataclass
69+
class CheckContext:
70+
"""Context passed to rule evaluation."""
71+
72+
hook: LifecycleHook
73+
agent_name: str
74+
runtime_id: str
75+
trace_id: str
76+
77+
# Content fields (populated based on hook)
78+
agent_input: str = ""
79+
agent_output: str = ""
80+
model_input: str = ""
81+
model_output: str = ""
82+
model_name: str = (
83+
"" # LLM model name (e.g., "gpt-4", "claude-3-opus") - available at agent start
84+
)
85+
tool_name: str = ""
86+
tool_args: dict[str, Any] = field(default_factory=dict)
87+
tool_result: str = ""
88+
messages: list[dict[str, Any]] = field(default_factory=list)
89+
90+
# Session state
91+
session_state: dict[str, Any] = field(default_factory=dict)
92+
metadata: dict[str, Any] = field(default_factory=dict)
93+
94+
# Ring level (privilege level: 0=system, 1=admin, 2=user, 3=untrusted)
95+
ring: int = 2
96+
97+
98+
@dataclass
99+
class PolicyPack:
100+
"""A collection of rules for a compliance standard."""
101+
102+
name: str
103+
version: str
104+
description: str
105+
rules: list[Rule]
106+
enabled: bool = True
107+
108+
109+
@dataclass
110+
class PolicyIndex:
111+
"""Index of all loaded policy packs and rules."""
112+
113+
packs: dict[str, PolicyPack] = field(default_factory=dict)
114+
_rules_by_id: dict[str, Rule] = field(default_factory=dict)
115+
_rules_by_hook: dict[LifecycleHook, list[Rule]] = field(default_factory=dict)
116+
117+
def add_pack(self, pack: PolicyPack) -> None:
118+
"""Add a policy pack to the index."""
119+
self.packs[pack.name] = pack
120+
for rule in pack.rules:
121+
rule.pack_name = pack.name
122+
self._rules_by_id[rule.rule_id] = rule
123+
if rule.hook not in self._rules_by_hook:
124+
self._rules_by_hook[rule.hook] = []
125+
self._rules_by_hook[rule.hook].append(rule)
126+
127+
def get_rule(self, rule_id: str) -> Rule | None:
128+
"""Get a rule by ID."""
129+
return self._rules_by_id.get(rule_id)
130+
131+
def get_rules_for_hook(self, hook: LifecycleHook) -> list[Rule]:
132+
"""Get all rules for a lifecycle hook."""
133+
return self._rules_by_hook.get(hook, [])
134+
135+
def get_rules_for_pack(self, pack_name: str) -> list[Rule]:
136+
"""Get all rules for a pack."""
137+
pack = self.packs.get(pack_name)
138+
return pack.rules if pack else []
139+
140+
@property
141+
def pack_names(self) -> list[str]:
142+
"""Get all pack names."""
143+
return list(self.packs.keys())
144+
145+
@property
146+
def total_rules(self) -> int:
147+
"""Get total number of rules."""
148+
return len(self._rules_by_id)
149+
150+
@property
151+
def all_rules(self) -> list[Rule]:
152+
"""Get all rules."""
153+
return list(self._rules_by_id.values())

tests/conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,25 @@ def temp_dir() -> Generator[str, None, None]:
1717
"""Provide a temporary directory for test files."""
1818
with tempfile.TemporaryDirectory() as tmp_dir:
1919
yield tmp_dir
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def _reset_governance_process_state() -> Generator[None, None, None]:
24+
"""Clear process-level governance state around every test.
25+
26+
The native governance layer keeps two pieces of state at module scope:
27+
the conversational/autonomous selector consumed by the policy fetch,
28+
and the memoized job-context. Both are stable per process in
29+
production but leak across tests when not reset, masking ordering
30+
bugs and producing flakes.
31+
"""
32+
from uipath.runtime.governance.native.backend_client import (
33+
resolve_job_context,
34+
set_agent_conversational,
35+
)
36+
37+
set_agent_conversational(None)
38+
resolve_job_context.cache_clear()
39+
yield
40+
set_agent_conversational(None)
41+
resolve_job_context.cache_clear()
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Tests for the default enforcement-mode resolution.
2+
3+
The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at
4+
runtime construction and the background policy fetch can run. If the
5+
backend later returns ``disabled``, ``set_enforcement_mode`` flips the
6+
cache and ``evaluate()`` short-circuits per-call.
7+
8+
Resolution order (per :func:`get_enforcement_mode`):
9+
1. Previously-cached programmatic value (set via ``set_enforcement_mode``).
10+
2. ``UIPATH_GOVERNANCE_MODE`` env var.
11+
3. Default ``AUDIT``.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import pytest
17+
18+
from uipath.runtime.governance.config import (
19+
EnforcementMode,
20+
get_enforcement_mode,
21+
reset_enforcement_mode,
22+
set_enforcement_mode,
23+
)
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def _isolate_mode(monkeypatch: pytest.MonkeyPatch):
28+
"""Each test starts from a clean module-state slate."""
29+
monkeypatch.delenv("UIPATH_GOVERNANCE_MODE", raising=False)
30+
reset_enforcement_mode()
31+
yield
32+
reset_enforcement_mode()
33+
34+
35+
def test_default_mode_is_audit() -> None:
36+
"""No programmatic mode + no env var → AUDIT.
37+
38+
AUDIT is the default so the wrapper attaches and the background
39+
policy fetch can run. The backend can flip the cache to DISABLED
40+
on fetch when the tenant has no policies.
41+
"""
42+
assert get_enforcement_mode() is EnforcementMode.AUDIT
43+
44+
45+
def test_env_var_disabled_wins_over_default(monkeypatch: pytest.MonkeyPatch) -> None:
46+
"""Developer override via env var still works."""
47+
monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "disabled")
48+
reset_enforcement_mode() # clear cached default
49+
assert get_enforcement_mode() is EnforcementMode.DISABLED
50+
51+
52+
def test_env_var_enforce_wins_over_default(monkeypatch: pytest.MonkeyPatch) -> None:
53+
monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "enforce")
54+
reset_enforcement_mode()
55+
assert get_enforcement_mode() is EnforcementMode.ENFORCE
56+
57+
58+
def test_invalid_env_var_falls_back_to_audit(
59+
monkeypatch: pytest.MonkeyPatch,
60+
) -> None:
61+
monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "garbage-value")
62+
reset_enforcement_mode()
63+
assert get_enforcement_mode() is EnforcementMode.AUDIT
64+
65+
66+
def test_programmatic_set_wins_over_env_and_default(
67+
monkeypatch: pytest.MonkeyPatch,
68+
) -> None:
69+
"""The policy loader's ``set_enforcement_mode`` call is canonical."""
70+
monkeypatch.setenv("UIPATH_GOVERNANCE_MODE", "audit")
71+
set_enforcement_mode(EnforcementMode.ENFORCE)
72+
assert get_enforcement_mode() is EnforcementMode.ENFORCE
73+
74+
75+
def test_reset_returns_to_default() -> None:
76+
"""``reset_enforcement_mode`` clears the cache so the default re-applies."""
77+
set_enforcement_mode(EnforcementMode.ENFORCE)
78+
assert get_enforcement_mode() is EnforcementMode.ENFORCE
79+
reset_enforcement_mode()
80+
assert get_enforcement_mode() is EnforcementMode.AUDIT
81+
82+
83+
def test_audit_mode_is_cached_after_first_read() -> None:
84+
"""First call computes; subsequent calls hit the cache."""
85+
assert get_enforcement_mode() is EnforcementMode.AUDIT
86+
# A second call returns the same instance — the cache survives.
87+
assert get_enforcement_mode() is EnforcementMode.AUDIT

0 commit comments

Comments
 (0)