Skip to content

Commit 91309f8

Browse files
aditik0303claude
andcommitted
feat(governance): policy backend client, YAML compiler, loader
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5b3582f commit 91309f8

8 files changed

Lines changed: 2940 additions & 0 deletions

File tree

src/uipath/runtime/governance/native/_yaml_to_index.py

Lines changed: 459 additions & 0 deletions
Large diffs are not rendered by default.

src/uipath/runtime/governance/native/backend_client.py

Lines changed: 383 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
"""Policy pack loader.
2+
3+
Resolves the active PolicyIndex at startup. Policies are fetched
4+
exclusively from the governance backend (``api/v1/policy``); there is
5+
no local compiled fallback. When the backend is unavailable, the
6+
access token is unset, or the fetch times out, the loader returns an
7+
empty PolicyIndex and the agent runs without any rules.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
import os
14+
import threading
15+
import time
16+
from collections import Counter
17+
18+
import yaml
19+
from uipath.core.governance.config import is_governance_enabled
20+
21+
from uipath.runtime.governance.config import EnforcementMode, set_enforcement_mode
22+
from uipath.runtime.governance.native._yaml_to_index import build_policy_index_from_yaml
23+
from uipath.runtime.governance.native.backend_client import ENV_ACCESS_TOKEN
24+
from uipath.runtime.governance.native.models import PolicyIndex
25+
from uipath.runtime.governance.native.policy_api_client import (
26+
ENV_ORGANIZATION_ID,
27+
ENV_TENANT_ID,
28+
POLICY_API_TIMEOUT_SECONDS,
29+
fetch_policy_response,
30+
resolve_organization_id,
31+
resolve_tenant_id,
32+
)
33+
34+
logger = logging.getLogger(__name__)
35+
36+
# Pack name aliases for backward compatibility
37+
PACK_ALIASES: dict[str, str] = {
38+
"owasp": "owasp_agentic",
39+
"hipaa": "hipaa_runtime",
40+
"soc2": "soc2_runtime",
41+
"nist": "nist_ai_rmf_runtime",
42+
"eu_ai": "eu_ai_act_runtime",
43+
"iso": "iso42001_runtime",
44+
}
45+
46+
47+
# Module-level cache
48+
_policy_index: PolicyIndex | None = None
49+
50+
# Background-prefetch coordination. ``_prefetch_event`` is set once the
51+
# background load_policy_index() call finishes (success OR failure);
52+
# callers of ``get_policy_index()`` wait on it. ``_prefetch_lock``
53+
# protects the start-once semantics so concurrent ``prefetch`` calls
54+
# don't kick off duplicate threads.
55+
_prefetch_event: threading.Event | None = None
56+
_prefetch_lock = threading.Lock()
57+
58+
# Default wait when ``get_policy_index()`` blocks on an in-flight
59+
# prefetch. Matched to the policy-API HTTP timeout so a stuck backend
60+
# bounds the total time spent waiting at first hook fire to
61+
# ~POLICY_API_TIMEOUT_SECONDS. If the wait expires we return an empty
62+
# PolicyIndex — the agent runs without any policies rather than
63+
# blocking further or retrying.
64+
_PREFETCH_WAIT_SECONDS = POLICY_API_TIMEOUT_SECONDS
65+
66+
67+
def prefetch_policy_index() -> None:
68+
"""Kick off a background load of the policy index.
69+
70+
Non-blocking. Designed to be called as early as possible (at
71+
``GovernanceRuntime.__init__``) so the HTTP call to the governance
72+
backend overlaps with the rest of agent setup. The result lands in
73+
the same module cache that ``get_policy_index()`` reads from;
74+
``get_policy_index()`` waits on this prefetch when it's in flight.
75+
76+
Idempotent: subsequent calls while the first is running are no-ops,
77+
and calls after completion are no-ops. Skipped entirely when the
78+
governance feature flag is OFF so no network call is made.
79+
"""
80+
global _prefetch_event
81+
82+
if not is_governance_enabled():
83+
return
84+
85+
with _prefetch_lock:
86+
if _policy_index is not None:
87+
return # already loaded
88+
if _prefetch_event is not None:
89+
return # already in flight
90+
event = threading.Event()
91+
_prefetch_event = event
92+
93+
def _worker() -> None:
94+
global _policy_index
95+
try:
96+
loaded = load_policy_index()
97+
except Exception as exc: # noqa: BLE001 - logged; first hook will retry sync
98+
logger.warning("Policy prefetch failed: %s", exc)
99+
else:
100+
with _prefetch_lock:
101+
_policy_index = loaded
102+
finally:
103+
event.set()
104+
105+
threading.Thread(
106+
target=_worker,
107+
name="governance-policy-prefetch",
108+
daemon=True,
109+
).start()
110+
111+
112+
def get_policy_index() -> PolicyIndex:
113+
"""Get the cached policy index, loading if necessary.
114+
115+
Resolution order on first call:
116+
1. If the governance feature flag is OFF, return an empty
117+
PolicyIndex (cached). No network call.
118+
2. If a prefetch (see :func:`prefetch_policy_index`) is in flight,
119+
wait for it to complete (bounded by ``_PREFETCH_WAIT_SECONDS``).
120+
3. Governance backend at ``api/v1/policy`` (one HTTP GET, cached).
121+
4. Empty PolicyIndex when the backend is unavailable or times out.
122+
123+
Result is cached for the process lifetime; per-hook evaluation never
124+
touches the network. Call :func:`clear_policy_cache` to force a
125+
refetch (mainly for tests).
126+
"""
127+
global _policy_index
128+
129+
if _policy_index is not None:
130+
return _policy_index
131+
132+
if not is_governance_enabled():
133+
logger.info(
134+
"Governance feature flag is OFF; returning empty PolicyIndex. "
135+
"No rules will fire. Set EnablePythonGovernanceChecker=True to enable."
136+
)
137+
_policy_index = PolicyIndex()
138+
return _policy_index
139+
140+
event = _prefetch_event
141+
if event is not None:
142+
completed = event.wait(timeout=_PREFETCH_WAIT_SECONDS)
143+
if completed and _policy_index is not None:
144+
return _policy_index
145+
if not completed:
146+
logger.warning(
147+
"Policy prefetch did not complete in %.1fs; "
148+
"agent will run without any policies",
149+
_PREFETCH_WAIT_SECONDS,
150+
)
151+
else:
152+
# Distinguish from the timeout path so production triage
153+
# can tell "prefetch hung" from "prefetch returned empty"
154+
# (auth failure, server error, parse failure).
155+
logger.warning(
156+
"Policy prefetch completed but produced no PolicyIndex "
157+
"(see prior WARN for the root cause); agent will run "
158+
"without any policies"
159+
)
160+
_policy_index = PolicyIndex()
161+
return _policy_index
162+
163+
# No prefetch was started (direct callers / tests). Sync load — bounded
164+
# by the HTTP timeout in the API client.
165+
_policy_index = load_policy_index()
166+
return _policy_index
167+
168+
169+
def load_policy_index(pack_name: str | None = None) -> PolicyIndex:
170+
"""Load the active PolicyIndex from the governance backend.
171+
172+
Args:
173+
pack_name: Ignored. Pack selection is controlled entirely by the
174+
backend.
175+
176+
Returns:
177+
PolicyIndex parsed from the backend response. Empty PolicyIndex
178+
when the backend is unavailable, the token is unset, the YAML
179+
is malformed, or the response yields zero rules.
180+
"""
181+
start = time.perf_counter()
182+
183+
api_index = _load_from_api()
184+
if api_index is not None:
185+
_log_index_summary(api_index)
186+
logger.info(
187+
"Policy index ready: source=backend, total_ms=%.1f",
188+
(time.perf_counter() - start) * 1000,
189+
)
190+
return api_index
191+
192+
reason = _empty_index_reason()
193+
logger.info(
194+
"Policy index ready: source=empty (%s), total_ms=%.1f",
195+
reason,
196+
(time.perf_counter() - start) * 1000,
197+
)
198+
return PolicyIndex()
199+
200+
201+
def _empty_index_reason() -> str:
202+
"""Diagnose why the policy fetch produced nothing."""
203+
if not resolve_organization_id():
204+
return (
205+
f"UiPathConfig.organization_id unavailable — set {ENV_ORGANIZATION_ID} "
206+
"or install uipath-platform; backend API not contacted"
207+
)
208+
if not resolve_tenant_id():
209+
return (
210+
f"UiPathConfig.tenant_id unavailable — set {ENV_TENANT_ID} "
211+
"or install uipath-platform; backend API not contacted"
212+
)
213+
if not os.environ.get(ENV_ACCESS_TOKEN):
214+
return f"{ENV_ACCESS_TOKEN} unset — backend API not contacted"
215+
return "backend returned no policies (timeout / error / empty body)"
216+
217+
218+
def _apply_enforcement_mode(mode_str: str | None) -> None:
219+
"""Map a backend-supplied mode string onto :class:`EnforcementMode`.
220+
221+
Unknown values log a warning and leave the existing mode untouched.
222+
"""
223+
if not mode_str:
224+
return
225+
try:
226+
mode = EnforcementMode(mode_str.lower())
227+
except ValueError:
228+
logger.warning(
229+
"Backend returned unknown enforcement mode %r; keeping current mode",
230+
mode_str,
231+
)
232+
return
233+
set_enforcement_mode(mode)
234+
logger.info("Enforcement mode set from backend: %s", mode.value)
235+
236+
237+
def _load_from_api() -> PolicyIndex | None:
238+
"""Fetch and parse the policy index from the governance backend.
239+
240+
Applies the backend-supplied enforcement mode as a side effect.
241+
Returns ``None`` when the backend skips/errors, when the YAML is
242+
malformed, or when the resulting index has no rules — caller returns
243+
an empty PolicyIndex in those cases.
244+
"""
245+
start = time.perf_counter()
246+
response = fetch_policy_response()
247+
if response is None:
248+
return None
249+
250+
# Apply the platform-controlled enforcement mode before building the
251+
# index, so anything that reads ``get_enforcement_mode()`` during
252+
# index compilation already sees the right value.
253+
_apply_enforcement_mode(response.mode)
254+
255+
if not response.policy:
256+
logger.warning(
257+
"Policy fetch returned empty policy field; "
258+
"agent will run without any policies"
259+
)
260+
return None
261+
262+
try:
263+
index = build_policy_index_from_yaml(response.policy)
264+
except yaml.YAMLError as exc:
265+
logger.warning("Policy YAML from backend was malformed: %s", exc)
266+
return None
267+
except Exception as exc: # noqa: BLE001 - never let load break agent startup
268+
logger.warning("Failed to build PolicyIndex from backend YAML: %s", exc)
269+
return None
270+
271+
if index.total_rules == 0:
272+
logger.warning(
273+
"Policy YAML from backend yielded zero rules; "
274+
"agent will run without any policies"
275+
)
276+
return None
277+
278+
elapsed_ms = (time.perf_counter() - start) * 1000
279+
logger.info(
280+
"Loaded policy index from backend: packs=%s, rules=%d, elapsed_ms=%.1f",
281+
index.pack_names,
282+
index.total_rules,
283+
elapsed_ms,
284+
)
285+
return index
286+
287+
288+
def _backend_base_url() -> str:
289+
"""Return the backend base URL for logging; imported lazily to avoid cycles."""
290+
try:
291+
from uipath.runtime.governance.native.backend_client import (
292+
get_backend_base_url,
293+
)
294+
295+
return get_backend_base_url()
296+
except Exception: # noqa: BLE001
297+
return "backend"
298+
299+
300+
def _log_index_summary(index: PolicyIndex) -> None:
301+
"""Log summary of loaded policy index."""
302+
# Count rules by hook
303+
hook_counts: Counter[str] = Counter()
304+
for rule in index.all_rules:
305+
hook_counts[rule.hook.value] += 1
306+
307+
logger.debug(
308+
"Policy packs: %s, total rules: %d, by hook: %s",
309+
index.pack_names,
310+
index.total_rules,
311+
dict(hook_counts),
312+
)
313+
314+
315+
def get_available_packs() -> list[str]:
316+
"""Get list of pack names from the currently loaded policy index.
317+
318+
Returns whatever the backend supplied on the most recent load.
319+
Empty list if no index has been loaded yet or the backend yielded
320+
no packs.
321+
"""
322+
if _policy_index is None:
323+
return []
324+
return _policy_index.pack_names
325+
326+
327+
def clear_policy_cache() -> None:
328+
"""Clear the cached policy index and any in-flight prefetch state.
329+
330+
Next call to ``get_policy_index()`` will refetch from the backend.
331+
"""
332+
global _policy_index, _prefetch_event
333+
with _prefetch_lock:
334+
_policy_index = None
335+
_prefetch_event = None
336+
logger.debug("Policy index cache cleared")
337+
338+
339+
# Backward compatibility alias
340+
reset_policy_index = clear_policy_cache

0 commit comments

Comments
 (0)