Skip to content
Draft
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-runtime"
version = "0.12.2"
version = "0.12.3"
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
118 changes: 118 additions & 0 deletions src/uipath/runtime/governance/_audit/console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Console audit sink for human-readable governance output.

Writes audit events via Python logging (not stderr print), useful for
debugging and development.
"""
from __future__ import annotations

import json
import logging

from .base import AuditEvent, AuditSink, EventType

_logger = logging.getLogger("uipath.governance.audit.console")


class ConsoleAuditSink(AuditSink):
"""Audit sink that writes governance events via logging.

Args:
verbose: If True, show all events. If False, only show matches.
"""

def __init__(self, verbose: bool = False) -> None:
"""Configure the sink's verbosity (verbose shows every event)."""
self._verbose = verbose

@property
def name(self) -> str:
"""Constant sink identifier."""
return "console"

def accepts(self, event: AuditEvent) -> bool:
"""Filter to matched rules and lifecycle events unless verbose."""
if self._verbose:
return True
if event.event_type == EventType.RULE_EVALUATION:
return event.data.get("matched", False)
return event.event_type in (
EventType.SESSION_START,
EventType.SESSION_END,
EventType.HOOK_END,
EventType.POLICY_VIOLATION,
)

def emit(self, event: AuditEvent) -> None:
"""Write the event to the log using the appropriate formatter."""
if event.event_type == EventType.RULE_EVALUATION:
self._emit_rule_evaluation(event)
elif event.event_type == EventType.HOOK_END:
self._emit_hook_summary(event)
elif event.event_type == EventType.SESSION_START:
self._emit_session_start(event)
elif event.event_type == EventType.SESSION_END:
self._emit_session_end(event)
else:
self._emit_generic(event)

def _emit_rule_evaluation(self, event: AuditEvent) -> None:
data = event.data
matched = data.get("matched", False)
status = "MATCHED" if matched else "PASS"
policy_id = data.get("policy_id", "?")
rule_name = data.get("rule_name", "?")
action = data.get("action", "?").upper()
detail = data.get("detail", "")

if matched:
_logger.warning(
"[GOVERNANCE] [%s] %s | %s | action=%s | %s",
status, policy_id, rule_name, action, detail,
)
elif self._verbose:
_logger.info("[GOVERNANCE] [%s] %s | %s", status, policy_id, rule_name)

def _emit_hook_summary(self, event: AuditEvent) -> None:
data = event.data
hook = event.hook
total = data.get("total_rules", 0)
matched = data.get("matched_rules", 0)
action = data.get("final_action", "allow").upper()
mode = data.get("enforcement_mode")
mode_str = mode.value if hasattr(mode, "value") else str(mode or "audit")

if mode_str == "audit" and action == "DENY":
action = "AUDIT (would deny)"

level = logging.WARNING if matched else logging.INFO
_logger.log(
level,
"[GOVERNANCE] HOOK: %s | rules=%d | matched=%d | action=%s",
hook, total, matched, action,
)

def _emit_session_start(self, event: AuditEvent) -> None:
data = event.data
packs = data.get("packs", [])
mode = data.get("enforcement_mode")
mode_str = mode.value if hasattr(mode, "value") else str(mode or "audit")
_logger.info(
"[GOVERNANCE] Session started | agent=%s | packs=%s | mode=%s",
event.agent_name, ",".join(packs), mode_str,
)

def _emit_session_end(self, event: AuditEvent) -> None:
data = event.data
total = data.get("total_evaluations", 0)
matched = data.get("rules_matched", 0)
denied = data.get("rules_denied", 0)
_logger.info(
"[GOVERNANCE] Session ended | evaluations=%d | matched=%d | denied=%d",
total, matched, denied,
)

def _emit_generic(self, event: AuditEvent) -> None:
_logger.info(
"[GOVERNANCE] %s | %s | %s",
event.event_type, event.agent_name, json.dumps(event.data, default=str),
)
5 changes: 5 additions & 0 deletions src/uipath/runtime/governance/_audit/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,10 @@ def create_sink(name: str) -> AuditSink | None:

return TracesAuditSink()

if name == "console":
from .console import ConsoleAuditSink

return ConsoleAuditSink()

logger.warning("Unknown audit sink: %s", name)
return None
69 changes: 69 additions & 0 deletions src/uipath/runtime/governance/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Runtime-level governance enforcement-mode state.

``EnforcementMode`` is defined in :mod:`uipath.core.governance` and
re-exported here for backward compatibility. The get/set/reset helpers
below are *per-process* state — the native policy loader sets the mode
on each successful backend fetch.
"""

from __future__ import annotations

import logging
import os

from uipath.core.governance import EnforcementMode

logger = logging.getLogger(__name__)

ENV_ENFORCEMENT_MODE = "UIPATH_GOVERNANCE_MODE"

__all__ = [
"EnforcementMode",
"ENV_ENFORCEMENT_MODE",
"get_enforcement_mode",
"set_enforcement_mode",
"reset_enforcement_mode",
]

_enforcement_mode: EnforcementMode | None = None


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

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 fetch — that's the canonical source).
2. ``UIPATH_GOVERNANCE_MODE`` env var (developer override).
3. Default :attr:`EnforcementMode.AUDIT` — evaluate and log without
blocking.
"""
global _enforcement_mode
if _enforcement_mode is not None:
return _enforcement_mode

mode_str = os.getenv(ENV_ENFORCEMENT_MODE, "audit").lower()
try:
_enforcement_mode = EnforcementMode(mode_str)
except ValueError:
_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)."""
global _enforcement_mode
_enforcement_mode = None
Loading
Loading