Initial commit: add .gitignore and README
This commit is contained in:
32
fusionagi/governance/__init__.py
Normal file
32
fusionagi/governance/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Governance and safety: guardrails, rate limiting, access control, override, audit, policy, intent alignment."""
|
||||
|
||||
from fusionagi.governance.guardrails import Guardrails, PreCheckResult
|
||||
from fusionagi.governance.rate_limiter import RateLimiter
|
||||
from fusionagi.governance.access_control import AccessControl
|
||||
from fusionagi.governance.override import OverrideHooks
|
||||
from fusionagi.governance.audit_log import AuditLog
|
||||
from fusionagi.governance.policy_engine import PolicyEngine
|
||||
from fusionagi.governance.intent_alignment import IntentAlignment
|
||||
from fusionagi.governance.safety_pipeline import (
|
||||
SafetyPipeline,
|
||||
InputModerator,
|
||||
OutputScanner,
|
||||
ModerationResult,
|
||||
OutputScanResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Guardrails",
|
||||
"PreCheckResult",
|
||||
"RateLimiter",
|
||||
"AccessControl",
|
||||
"OverrideHooks",
|
||||
"AuditLog",
|
||||
"PolicyEngine",
|
||||
"IntentAlignment",
|
||||
"SafetyPipeline",
|
||||
"InputModerator",
|
||||
"OutputScanner",
|
||||
"ModerationResult",
|
||||
"OutputScanResult",
|
||||
]
|
||||
30
fusionagi/governance/access_control.py
Normal file
30
fusionagi/governance/access_control.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Tool access control: central policy for which agent may call which tools.
|
||||
|
||||
Optional; not wired to Executor or Orchestrator by default. Wire by passing
|
||||
an AccessControl instance and checking allowed(agent_id, tool_name, task_id)
|
||||
before tool invocation.
|
||||
"""
|
||||
|
||||
|
||||
class AccessControl:
|
||||
"""Policy: (agent_id, tool_name, task_id) -> allowed."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._deny: set[tuple[str, str]] = set()
|
||||
self._task_tools: dict[str, set[str]] = {}
|
||||
|
||||
def deny(self, agent_id: str, tool_name: str) -> None:
|
||||
"""Deny agent from using tool (global)."""
|
||||
self._deny.add((agent_id, tool_name))
|
||||
|
||||
def allow_tools_for_task(self, task_id: str, tool_names: list[str]) -> None:
|
||||
"""Set allowed tools for a task (empty = all allowed)."""
|
||||
self._task_tools[task_id] = set(tool_names)
|
||||
|
||||
def allowed(self, agent_id: str, tool_name: str, task_id: str | None = None) -> bool:
|
||||
"""Return True if agent may call tool (optionally for this task)."""
|
||||
if (agent_id, tool_name) in self._deny:
|
||||
return False
|
||||
if task_id and task_id in self._task_tools:
|
||||
return tool_name in self._task_tools[task_id]
|
||||
return True
|
||||
29
fusionagi/governance/audit_log.py
Normal file
29
fusionagi/governance/audit_log.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Structured audit log for AGI."""
|
||||
from typing import Any
|
||||
from fusionagi.schemas.audit import AuditEntry, AuditEventType
|
||||
from fusionagi._logger import logger
|
||||
import uuid
|
||||
|
||||
class AuditLog:
|
||||
def __init__(self, max_entries=100000):
|
||||
self._entries = []
|
||||
self._max_entries = max_entries
|
||||
self._by_task = {}
|
||||
self._by_type = {}
|
||||
def append(self, event_type, actor, action="", task_id=None, payload=None, outcome=""):
|
||||
entry_id = str(uuid.uuid4())
|
||||
entry = AuditEntry(entry_id=entry_id, event_type=event_type, actor=actor, task_id=task_id, action=action, payload=payload or {}, outcome=outcome)
|
||||
if len(self._entries) >= self._max_entries:
|
||||
self._entries.pop(0)
|
||||
idx = len(self._entries)
|
||||
self._entries.append(entry)
|
||||
if entry.task_id:
|
||||
self._by_task.setdefault(entry.task_id, []).append(idx)
|
||||
self._by_type.setdefault(entry.event_type.value, []).append(idx)
|
||||
return entry_id
|
||||
def get_by_task(self, task_id, limit=100):
|
||||
indices = self._by_task.get(task_id, [])[-limit:]
|
||||
return [self._entries[i] for i in indices if i < len(self._entries)]
|
||||
def get_by_type(self, event_type, limit=100):
|
||||
indices = self._by_type.get(event_type.value, [])[-limit:]
|
||||
return [self._entries[i] for i in indices if i < len(self._entries)]
|
||||
71
fusionagi/governance/guardrails.py
Normal file
71
fusionagi/governance/guardrails.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Guardrails: pre/post checks for tool calls (block paths, sanitize inputs)."""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class PreCheckResult(BaseModel):
|
||||
"""Result of a guardrails pre-check: allowed, optional sanitized args, optional error message."""
|
||||
|
||||
allowed: bool = Field(..., description="Whether the call is allowed")
|
||||
sanitized_args: dict[str, Any] | None = Field(default=None, description="Args to use if allowed and sanitized")
|
||||
error_message: str | None = Field(default=None, description="Reason for denial if not allowed")
|
||||
|
||||
|
||||
class Guardrails:
|
||||
"""Pre/post checks for tool invocations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._blocked_paths: list[str] = []
|
||||
self._blocked_patterns: list[re.Pattern[str]] = []
|
||||
self._custom_checks: list[Any] = []
|
||||
|
||||
def block_path_prefix(self, prefix: str) -> None:
|
||||
"""Block any file path starting with this prefix."""
|
||||
self._blocked_paths.append(prefix.rstrip("/"))
|
||||
|
||||
def block_path_pattern(self, pattern: str) -> None:
|
||||
"""Block paths matching this regex."""
|
||||
self._blocked_patterns.append(re.compile(pattern))
|
||||
|
||||
def add_check(self, check: Any) -> None:
|
||||
"""
|
||||
Add a custom pre-check. Check receives (tool_name, args); must not mutate caller's args.
|
||||
Returns (allowed, sanitized_args or error_message): (True, dict) or (True, None) or (False, str).
|
||||
Returned sanitized_args are used for subsequent checks and invocation.
|
||||
"""
|
||||
self._custom_checks.append(check)
|
||||
|
||||
def pre_check(self, tool_name: str, args: dict[str, Any]) -> PreCheckResult:
|
||||
"""Run all pre-checks. Returns PreCheckResult (allowed, sanitized_args, error_message)."""
|
||||
args = dict(args) # Copy to avoid mutating caller's args
|
||||
for key in ("path", "file_path"):
|
||||
if key in args and isinstance(args[key], str):
|
||||
path = args[key]
|
||||
for prefix in self._blocked_paths:
|
||||
if path.startswith(prefix) or path.startswith(prefix + "/"):
|
||||
reason = "Blocked path prefix: " + prefix
|
||||
logger.info("Guardrails pre_check blocked", extra={"tool_name": tool_name, "reason": reason})
|
||||
return PreCheckResult(allowed=False, error_message=reason)
|
||||
for pat in self._blocked_patterns:
|
||||
if pat.search(path):
|
||||
reason = "Blocked path pattern"
|
||||
logger.info("Guardrails pre_check blocked", extra={"tool_name": tool_name, "reason": reason})
|
||||
return PreCheckResult(allowed=False, error_message=reason)
|
||||
for check in self._custom_checks:
|
||||
allowed, result = check(tool_name, args)
|
||||
if not allowed:
|
||||
reason = result if isinstance(result, str) else "Check failed"
|
||||
logger.info("Guardrails pre_check blocked", extra={"tool_name": tool_name, "reason": reason})
|
||||
return PreCheckResult(allowed=False, error_message=reason)
|
||||
if isinstance(result, dict):
|
||||
args = result
|
||||
return PreCheckResult(allowed=True, sanitized_args=args)
|
||||
|
||||
def post_check(self, tool_name: str, result: Any) -> tuple[bool, str]:
|
||||
"""Optional post-check; return (True, "") or (False, error_message)."""
|
||||
return True, ""
|
||||
29
fusionagi/governance/intent_alignment.py
Normal file
29
fusionagi/governance/intent_alignment.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Intent alignment: what user meant vs what user said for AGI."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class IntentAlignment:
|
||||
"""
|
||||
Checks that system interpretation of user goal matches user intent.
|
||||
Placeholder: returns True; wire to confirmation or paraphrase flow.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._checks: list[tuple[str, str]] = [] # (interpreted_goal, user_input)
|
||||
|
||||
def check(self, interpreted_goal: str, user_input: str, context: dict[str, Any] | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Returns (aligned, message). If not aligned, message suggests clarification.
|
||||
"""
|
||||
if not interpreted_goal or not user_input:
|
||||
return True, ""
|
||||
self._checks.append((interpreted_goal, user_input))
|
||||
logger.debug("IntentAlignment check", extra={"goal": interpreted_goal[:80]})
|
||||
return True, ""
|
||||
|
||||
def suggest_paraphrase(self, goal: str) -> str:
|
||||
"""Return suggested paraphrase for user to confirm."""
|
||||
return f"Just to confirm, you want: {goal}"
|
||||
44
fusionagi/governance/override.py
Normal file
44
fusionagi/governance/override.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Human override hooks: events the orchestrator can fire before high-risk steps."""
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
# Callback: (event_type, payload) -> proceed: bool
|
||||
OverrideCallback = Callable[[str, dict[str, Any]], bool]
|
||||
|
||||
|
||||
class OverrideHooks:
|
||||
"""Optional callbacks for human override; no UI, just interface and logging."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._hooks: list[OverrideCallback] = []
|
||||
self._log: list[dict[str, Any]] = []
|
||||
|
||||
def register(self, callback: OverrideCallback) -> None:
|
||||
"""Register a callback; if any returns False, treat as 'do not proceed'."""
|
||||
self._hooks.append(callback)
|
||||
|
||||
def fire(self, event_type: str, payload: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Fire event (e.g. task_paused_for_approval). If no hooks, return True (proceed).
|
||||
If any hook returns False, return False (do not proceed). Log all events.
|
||||
Exception in a hook implies do not proceed.
|
||||
"""
|
||||
entry = {"event": event_type, "payload": payload}
|
||||
self._log.append(entry)
|
||||
logger.info("Override fire", extra={"event_type": event_type})
|
||||
for h in self._hooks:
|
||||
try:
|
||||
if not h(event_type, payload):
|
||||
logger.info("Override hook returned do not proceed", extra={"event_type": event_type})
|
||||
return False
|
||||
except Exception:
|
||||
logger.exception("Override hook raised", extra={"event_type": event_type})
|
||||
return False
|
||||
logger.debug("Override fire proceed", extra={"event_type": event_type})
|
||||
return True
|
||||
|
||||
def get_log(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""Return recent override events (for auditing)."""
|
||||
return list(self._log[-limit:])
|
||||
73
fusionagi/governance/policy_engine.py
Normal file
73
fusionagi/governance/policy_engine.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Policy engine: hard constraints independent of LLM for AGI."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.schemas.policy import PolicyEffect, PolicyRule
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""Evaluates policy rules; higher priority first; first match wins (allow/deny)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rules: list[PolicyRule] = []
|
||||
|
||||
def add_rule(self, rule: PolicyRule) -> None:
|
||||
self._rules.append(rule)
|
||||
self._rules.sort(key=lambda r: -r.priority)
|
||||
logger.debug("PolicyEngine: rule added", extra={"rule_id": rule.rule_id})
|
||||
|
||||
def get_rules(self) -> list[PolicyRule]:
|
||||
"""Return all rules (copy)."""
|
||||
return list(self._rules)
|
||||
|
||||
def get_rule(self, rule_id: str) -> PolicyRule | None:
|
||||
"""Return rule by id or None."""
|
||||
for r in self._rules:
|
||||
if r.rule_id == rule_id:
|
||||
return r
|
||||
return None
|
||||
|
||||
def update_rule(self, rule_id: str, updates: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update an existing rule by id. Updates can include condition, effect, reason, priority.
|
||||
Returns True if updated, False if rule_id not found.
|
||||
"""
|
||||
for i, r in enumerate(self._rules):
|
||||
if r.rule_id == rule_id:
|
||||
allowed = {"condition", "effect", "reason", "priority"}
|
||||
data = r.model_dump()
|
||||
for k, v in updates.items():
|
||||
if k in allowed:
|
||||
data[k] = v
|
||||
self._rules[i] = PolicyRule.model_validate(data)
|
||||
self._rules.sort(key=lambda x: -x.priority)
|
||||
logger.debug("PolicyEngine: rule updated", extra={"rule_id": rule_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_rule(self, rule_id: str) -> bool:
|
||||
"""Remove a rule by id. Returns True if removed."""
|
||||
for i, r in enumerate(self._rules):
|
||||
if r.rule_id == rule_id:
|
||||
self._rules.pop(i)
|
||||
logger.debug("PolicyEngine: rule removed", extra={"rule_id": rule_id})
|
||||
return True
|
||||
return False
|
||||
|
||||
def check(self, action: str, context: dict[str, Any]) -> tuple[bool, str]:
|
||||
"""
|
||||
Returns (allowed, reason). Context has e.g. tool_name, domain, data_class, agent_id.
|
||||
"""
|
||||
for rule in self._rules:
|
||||
if self._match(rule.condition, context):
|
||||
if rule.effect == PolicyEffect.DENY:
|
||||
return False, rule.reason or "Policy denied"
|
||||
return True, rule.reason or "Policy allowed"
|
||||
return True, ""
|
||||
|
||||
def _match(self, condition: dict[str, Any], context: dict[str, Any]) -> bool:
|
||||
for k, v in condition.items():
|
||||
if context.get(k) != v:
|
||||
return False
|
||||
return True
|
||||
38
fusionagi/governance/rate_limiter.py
Normal file
38
fusionagi/governance/rate_limiter.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Rate limiting: per agent or per tool; reject or queue if exceeded.
|
||||
|
||||
Optional; not wired to Executor or Orchestrator by default. Wire by calling
|
||||
allow(key) before tool invocation or message routing and checking the result.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple in-memory rate limiter: max N calls per window_seconds per key."""
|
||||
|
||||
def __init__(self, max_calls: int = 60, window_seconds: float = 60.0) -> None:
|
||||
self._max_calls = max_calls
|
||||
self._window = window_seconds
|
||||
self._calls: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
def allow(self, key: str) -> tuple[bool, str]:
|
||||
"""Record a call for key; return (True, "") or (False, reason)."""
|
||||
now = time.monotonic()
|
||||
cutoff = now - self._window
|
||||
self._calls[key] = [t for t in self._calls[key] if t > cutoff]
|
||||
if len(self._calls[key]) >= self._max_calls:
|
||||
reason = f"Rate limit exceeded for {key}"
|
||||
logger.info("Rate limiter rejected", extra={"key": key, "reason": reason})
|
||||
return False, reason
|
||||
self._calls[key].append(now)
|
||||
return True, ""
|
||||
|
||||
def reset(self, key: str | None = None) -> None:
|
||||
"""Reset counts for key or all."""
|
||||
if key is None:
|
||||
self._calls.clear()
|
||||
else:
|
||||
self._calls.pop(key, None)
|
||||
132
fusionagi/governance/safety_pipeline.py
Normal file
132
fusionagi/governance/safety_pipeline.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Safety pipeline: pre-check (input moderation), post-check (output scan)."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fusionagi.governance.guardrails import Guardrails, PreCheckResult
|
||||
from fusionagi.schemas.audit import AuditEventType
|
||||
from fusionagi._logger import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModerationResult:
|
||||
"""Result of input moderation."""
|
||||
|
||||
allowed: bool
|
||||
transformed: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class InputModerator:
|
||||
"""Pre-check: block or transform user input before processing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._blocked_patterns: list[re.Pattern[str]] = []
|
||||
self._blocked_phrases: list[str] = []
|
||||
|
||||
def add_blocked_pattern(self, pattern: str) -> None:
|
||||
"""Add regex pattern to block (e.g. prompt injection attempts)."""
|
||||
self._blocked_patterns.append(re.compile(pattern, re.I))
|
||||
|
||||
def add_blocked_phrase(self, phrase: str) -> None:
|
||||
"""Add exact phrase to block."""
|
||||
self._blocked_phrases.append(phrase.lower())
|
||||
|
||||
def moderate(self, text: str) -> ModerationResult:
|
||||
"""Check input; return allowed/denied and optional transformed text."""
|
||||
if not text or not text.strip():
|
||||
return ModerationResult(allowed=False, reason="Empty input")
|
||||
lowered = text.lower()
|
||||
for phrase in self._blocked_phrases:
|
||||
if phrase in lowered:
|
||||
logger.info("Input blocked: blocked phrase", extra={"phrase": phrase[:50]})
|
||||
return ModerationResult(allowed=False, reason=f"Blocked phrase: {phrase[:30]}...")
|
||||
for pat in self._blocked_patterns:
|
||||
if pat.search(text):
|
||||
logger.info("Input blocked: pattern match", extra={"pattern": pat.pattern[:50]})
|
||||
return ModerationResult(allowed=False, reason="Input matched blocked pattern")
|
||||
return ModerationResult(allowed=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputScanResult:
|
||||
"""Result of output (final answer) scan."""
|
||||
|
||||
passed: bool
|
||||
flags: list[str]
|
||||
sanitized: str | None = None
|
||||
|
||||
|
||||
class OutputScanner:
|
||||
"""Post-check: scan final answer for policy violations, PII leakage."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pii_patterns: list[tuple[str, re.Pattern[str]]] = [
|
||||
("ssn", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
|
||||
("credit_card", re.compile(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b")),
|
||||
]
|
||||
self._blocked_patterns: list[re.Pattern[str]] = []
|
||||
|
||||
def add_pii_pattern(self, name: str, pattern: str) -> None:
|
||||
"""Add PII detection pattern."""
|
||||
self._pii_patterns.append((name, re.compile(pattern)))
|
||||
|
||||
def add_blocked_pattern(self, pattern: str) -> None:
|
||||
"""Add pattern that fails the output."""
|
||||
self._blocked_patterns.append(re.compile(pattern, re.I))
|
||||
|
||||
def scan(self, text: str) -> OutputScanResult:
|
||||
"""Scan output; return passed, flags, optional sanitized."""
|
||||
flags: list[str] = []
|
||||
for name, pat in self._pii_patterns:
|
||||
if pat.search(text):
|
||||
flags.append(f"potential_pii:{name}")
|
||||
for pat in self._blocked_patterns:
|
||||
if pat.search(text):
|
||||
flags.append("blocked_content_detected")
|
||||
if flags:
|
||||
return OutputScanResult(passed=False, flags=flags)
|
||||
return OutputScanResult(passed=True, flags=[])
|
||||
|
||||
|
||||
class SafetyPipeline:
|
||||
"""Combined pre/post safety checks for Dvādaśa."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
moderator: InputModerator | None = None,
|
||||
scanner: OutputScanner | None = None,
|
||||
guardrails: Guardrails | None = None,
|
||||
audit_log: Any | None = None,
|
||||
) -> None:
|
||||
self._moderator = moderator or InputModerator()
|
||||
self._scanner = scanner or OutputScanner()
|
||||
self._guardrails = guardrails or Guardrails()
|
||||
self._audit = audit_log
|
||||
|
||||
def pre_check(self, user_input: str) -> ModerationResult:
|
||||
"""Run input moderation."""
|
||||
result = self._moderator.moderate(user_input)
|
||||
if self._audit and not result.allowed:
|
||||
self._audit.append(
|
||||
AuditEventType.POLICY_CHECK,
|
||||
actor="safety_pipeline",
|
||||
action="input_moderation",
|
||||
payload={"reason": result.reason},
|
||||
outcome="denied",
|
||||
)
|
||||
return result
|
||||
|
||||
def post_check(self, final_answer: str) -> OutputScanResult:
|
||||
"""Run output scan."""
|
||||
result = self._scanner.scan(final_answer)
|
||||
if self._audit and not result.passed:
|
||||
self._audit.append(
|
||||
AuditEventType.POLICY_CHECK,
|
||||
actor="safety_pipeline",
|
||||
action="output_scan",
|
||||
payload={"flags": result.flags},
|
||||
outcome="flagged",
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user