Repository navigation
feat(governance): enforcement-mode config, policy models, deps #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
5b3582f
6da34f0
8e2c802
8140de4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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): | ||
|
radu-mocanu marked this conversation as resolved.
Outdated
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we need to either delete this class now and update the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
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() | ||
|
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).""" | ||
|
radu-mocanu marked this conversation as resolved.
Outdated
|
||
| global _enforcement_mode | ||
|
radu-mocanu marked this conversation as resolved.
Outdated
|
||
| _enforcement_mode = None | ||
| 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) | ||
|
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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we also have an action per check. what happens if ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. makes sense, thanks. |
||
| 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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 and create a union type for them. this will provide out-of-the-box model validation so one can t write stuff like:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = ( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #129 |
||
| "" # LLM model name (e.g., "gpt-4", "claude-3-opus") - available at agent start | ||
|
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()) | ||
Uh oh!
There was an error while loading. Please reload this page.