feat(guardrails): añade GuardrailsAIEngine + 8 validadores (PII, injection, schema, telco)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Engine que orquesta los validadores definidos en una PolicyDefinition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from agentforge_core.domain.guardrail import GuardrailViolation
|
||||
from agentforge_core.domain.policy import PolicyDefinition, PolicyValidator
|
||||
from agentforge_core.guardrails.validators import (
|
||||
detect_pii,
|
||||
forbidden_action_keywords,
|
||||
forbidden_topics,
|
||||
pii_leakage,
|
||||
prompt_injection,
|
||||
schema_match,
|
||||
telco_safety_rules,
|
||||
toxic_language,
|
||||
)
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Registries: type -> función validadora (entrada/salida)
|
||||
INPUT_VALIDATORS: dict[str, Callable[..., list[GuardrailViolation]]] = {
|
||||
"detect_pii": detect_pii,
|
||||
"prompt_injection": prompt_injection,
|
||||
"toxic_language": toxic_language,
|
||||
"forbidden_topics": forbidden_topics,
|
||||
}
|
||||
|
||||
OUTPUT_VALIDATORS: dict[str, Callable[..., list[GuardrailViolation]]] = {
|
||||
"schema_match": schema_match,
|
||||
"pii_leakage": pii_leakage,
|
||||
"forbidden_action_keywords": forbidden_action_keywords,
|
||||
"telco_safety_rules": telco_safety_rules,
|
||||
}
|
||||
|
||||
|
||||
class GuardrailsAIEngine:
|
||||
"""Engine basado en validadores Python; integra Presidio para PII."""
|
||||
|
||||
name = "guardrails_ai"
|
||||
|
||||
async def validate_input(
|
||||
self, payload: str, policy: PolicyDefinition, trace_id: UUID
|
||||
) -> list[GuardrailViolation]:
|
||||
return self._run(
|
||||
kind="input",
|
||||
registry=INPUT_VALIDATORS,
|
||||
validators=policy.input_validators,
|
||||
payload=payload,
|
||||
policy=policy,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
async def validate_output(
|
||||
self, payload: dict[str, Any], policy: PolicyDefinition, trace_id: UUID
|
||||
) -> list[GuardrailViolation]:
|
||||
return self._run(
|
||||
kind="output",
|
||||
registry=OUTPUT_VALIDATORS,
|
||||
validators=policy.output_validators,
|
||||
payload=payload,
|
||||
policy=policy,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
def _run(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
registry: dict[str, Callable[..., list[GuardrailViolation]]],
|
||||
validators: list[PolicyValidator],
|
||||
payload: Any,
|
||||
policy: PolicyDefinition,
|
||||
trace_id: UUID,
|
||||
) -> list[GuardrailViolation]:
|
||||
violations: list[GuardrailViolation] = []
|
||||
for v in validators:
|
||||
fn = registry.get(v.type)
|
||||
if fn is None:
|
||||
log.warning("validator_unknown", type=v.type, stage=kind)
|
||||
continue
|
||||
try:
|
||||
violations.extend(fn(payload, v.config, trace_id, kind))
|
||||
except Exception as exc:
|
||||
log.error("validator_error", type=v.type, error=str(exc))
|
||||
if policy.on_validator_error == "fail_closed":
|
||||
violations.append(
|
||||
GuardrailViolation(
|
||||
trace_id=trace_id,
|
||||
timestamp=datetime.now(UTC),
|
||||
stage=kind,
|
||||
validator=v.type,
|
||||
severity="block",
|
||||
message=f"validator failed: {exc}",
|
||||
blocked=True,
|
||||
)
|
||||
)
|
||||
return violations
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Validadores individuales. Cada uno devuelve list[GuardrailViolation]."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import jsonschema
|
||||
|
||||
from agentforge_core.domain.guardrail import GuardrailViolation
|
||||
|
||||
_INJECTION_PATTERNS = [
|
||||
r"ignore (the )?(previous|prior|above) (instruction|prompt|message)",
|
||||
r"you are now",
|
||||
r"system: ",
|
||||
r"forget your (rules|instructions)",
|
||||
r"\\n\\nsystem:",
|
||||
r"reveal (the )?(system|hidden) (prompt|instruction)",
|
||||
]
|
||||
|
||||
|
||||
def _violation(
|
||||
*,
|
||||
trace_id: UUID,
|
||||
stage: str,
|
||||
validator: str,
|
||||
severity: str,
|
||||
message: str,
|
||||
blocked: bool,
|
||||
) -> GuardrailViolation:
|
||||
return GuardrailViolation(
|
||||
trace_id=trace_id,
|
||||
timestamp=datetime.now(UTC),
|
||||
stage=stage,
|
||||
validator=validator,
|
||||
severity=severity,
|
||||
message=message,
|
||||
blocked=blocked,
|
||||
)
|
||||
|
||||
|
||||
def detect_pii(
|
||||
text: str, config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Detección PII vía Presidio Analyzer (con fallback a regex si no está instalado)."""
|
||||
try:
|
||||
from presidio_analyzer import AnalyzerEngine
|
||||
except Exception:
|
||||
# Si Presidio no está, fallback a regex básica
|
||||
return _pii_regex_fallback(text, config, trace_id, stage)
|
||||
|
||||
entities = config.get(
|
||||
"entities",
|
||||
["EMAIL_ADDRESS", "PHONE_NUMBER", "PERSON", "IP_ADDRESS", "IBAN_CODE", "ES_NIF"],
|
||||
)
|
||||
sev = config.get("severity_on_match", "block")
|
||||
blocked = sev == "block"
|
||||
|
||||
analyzer = AnalyzerEngine()
|
||||
results = analyzer.analyze(text=text, entities=entities, language="en")
|
||||
if not results:
|
||||
return []
|
||||
matched = ", ".join({r.entity_type for r in results})
|
||||
return [
|
||||
_violation(
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
validator="DetectPII",
|
||||
severity=sev,
|
||||
message=f"PII detectada: {matched}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _pii_regex_fallback(
|
||||
text: str, config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Detección PII básica vía regex cuando Presidio no está disponible."""
|
||||
sev = config.get("severity_on_match", "block")
|
||||
blocked = sev == "block"
|
||||
patterns = {
|
||||
"EMAIL": r"[\w.+-]+@[\w-]+\.[\w.-]+",
|
||||
"PHONE": r"\b\d{3}[\s-]?\d{3}[\s-]?\d{3}\b",
|
||||
"ES_NIF": r"\b\d{8}[A-HJ-NP-TV-Z]\b",
|
||||
"IP": r"\b\d{1,3}(\.\d{1,3}){3}\b",
|
||||
}
|
||||
matched = [k for k, p in patterns.items() if re.search(p, text)]
|
||||
if not matched:
|
||||
return []
|
||||
return [
|
||||
_violation(
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
validator="DetectPII",
|
||||
severity=sev,
|
||||
message=f"PII detectada (regex fallback): {', '.join(matched)}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def prompt_injection(
|
||||
text: str, config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Heurísticas de patrones de prompt-injection conocidos."""
|
||||
sev = config.get("severity_on_match", "block")
|
||||
blocked = sev == "block"
|
||||
lower = text.lower()
|
||||
for pattern in _INJECTION_PATTERNS:
|
||||
if re.search(pattern, lower):
|
||||
return [
|
||||
_violation(
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
validator="PromptInjection",
|
||||
severity=sev,
|
||||
message=f"Patrón de inyección detectado: {pattern}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def toxic_language(
|
||||
text: str, config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Heurística simple de toxicidad por lista negra (suficiente para MVP)."""
|
||||
threshold = config.get("threshold", 0.7)
|
||||
sev = config.get("severity_on_match", "warning")
|
||||
blocked = sev == "block"
|
||||
blacklist = config.get(
|
||||
"blacklist",
|
||||
["idiota", "imbécil", "estúpido", "fuck", "shit"],
|
||||
)
|
||||
lower = text.lower()
|
||||
hits = sum(1 for w in blacklist if w in lower)
|
||||
if hits == 0:
|
||||
return []
|
||||
score = min(1.0, hits * 0.5)
|
||||
if score < threshold:
|
||||
return []
|
||||
return [
|
||||
_violation(
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
validator="ToxicLanguage",
|
||||
severity=sev,
|
||||
message=f"Lenguaje tóxico (score={score:.2f}, hits={hits})",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def forbidden_topics(
|
||||
text: str, config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Coincidencia substring con la lista de temas prohibidos."""
|
||||
topics: list[str] = config.get("topics", [])
|
||||
sev = config.get("severity_on_match", "block")
|
||||
blocked = sev == "block"
|
||||
lower = text.lower()
|
||||
matched = [t for t in topics if t.lower() in lower]
|
||||
if not matched:
|
||||
return []
|
||||
return [
|
||||
_violation(
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
validator="ForbiddenTopics",
|
||||
severity=sev,
|
||||
message=f"Temas prohibidos: {', '.join(matched)}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def schema_match(
|
||||
payload: dict[str, Any], config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Valida payload contra JSON Schema."""
|
||||
schema = config.get("schema", {})
|
||||
sev = config.get("severity_on_mismatch", "block")
|
||||
blocked = sev == "block"
|
||||
try:
|
||||
jsonschema.validate(instance=payload, schema=schema)
|
||||
return []
|
||||
except jsonschema.ValidationError as exc:
|
||||
return [
|
||||
_violation(
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
validator="SchemaMatch",
|
||||
severity=sev,
|
||||
message=f"Schema mismatch: {exc.message}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def pii_leakage(
|
||||
payload: dict[str, Any], config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Re-aplica detect_pii sobre la salida serializada como string."""
|
||||
return detect_pii(json.dumps(payload, ensure_ascii=False), config, trace_id, stage)
|
||||
|
||||
|
||||
def forbidden_action_keywords(
|
||||
payload: dict[str, Any], config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Comprueba que las acciones propuestas no contienen comandos peligrosos."""
|
||||
keywords: list[str] = config.get("keywords", [])
|
||||
sev = config.get("severity_on_match", "block")
|
||||
blocked = sev == "block"
|
||||
actions = payload.get("proposed_actions", [])
|
||||
flat = " ".join(
|
||||
f"{a.get('action', '')} {a.get('rollback_plan', '')}"
|
||||
for a in actions
|
||||
if isinstance(a, dict)
|
||||
).lower()
|
||||
matched = [k for k in keywords if k.lower() in flat]
|
||||
if not matched:
|
||||
return []
|
||||
return [
|
||||
_violation(
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
validator="ForbiddenActionKeywords",
|
||||
severity=sev,
|
||||
message=f"Palabras prohibidas en acciones: {', '.join(matched)}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def telco_safety_rules(
|
||||
payload: dict[str, Any], config: dict[str, Any], trace_id: UUID, stage: str
|
||||
) -> list[GuardrailViolation]:
|
||||
"""Reglas declarativas de seguridad sobre acciones propuestas."""
|
||||
rules: list[str] = config.get("rules", [])
|
||||
actions: list[dict[str, Any]] = payload.get("proposed_actions", [])
|
||||
issues: list[str] = []
|
||||
|
||||
if "never_propose_action_targeting_production_without_rollback" in rules:
|
||||
for a in actions:
|
||||
target = str(a.get("target", "")).lower()
|
||||
rollback = str(a.get("rollback_plan", "")).strip()
|
||||
if ("prod" in target or "production" in target) and (not rollback or rollback == "n/a"):
|
||||
issues.append(f"acción sobre prod sin rollback: {a.get('action')}")
|
||||
|
||||
if "never_propose_mass_action_without_canary" in rules:
|
||||
mass_keywords = {"all", "todos", "*", "mass"}
|
||||
for a in actions:
|
||||
target = str(a.get("target", "")).lower()
|
||||
plan = str(a.get("rollback_plan", "")).lower()
|
||||
if any(k in target for k in mass_keywords) and "canary" not in plan:
|
||||
issues.append(f"acción masiva sin canary: {a.get('action')}")
|
||||
|
||||
if not issues:
|
||||
return []
|
||||
sev = config.get("severity_on_violation", "block")
|
||||
return [
|
||||
_violation(
|
||||
trace_id=trace_id,
|
||||
stage=stage,
|
||||
validator="TelcoSafetyRules",
|
||||
severity=sev,
|
||||
message="; ".join(issues),
|
||||
blocked=sev == "block",
|
||||
)
|
||||
]
|
||||
Reference in New Issue
Block a user