462 lines
15 KiB
Python
462 lines
15 KiB
Python
"""Kontextbrücke für kontextübergreifenden Wissenstransfer.
|
|
|
|
Ermöglicht das sichere Teilen von Wissensartefakten zwischen Arbeitskontexten
|
|
unter Wahrung der Sicherheitsgrenzen. Implementiert:
|
|
- Sensitive-Content-Filterung (Secrets, Endpoints, PII) via Regex
|
|
- Explizite Nutzerbestätigung vor Freigabe
|
|
- Read-only-Referenzen in Zielkontexten
|
|
- Update-Propagierung beim nächsten Lesezugriff
|
|
- Widerruf von Freigaben
|
|
|
|
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from monorepo.knowledge.artifact import Artifact, parse_frontmatter
|
|
from monorepo.knowledge.index import IndexEntry, YAMLIndex
|
|
from monorepo.models import Context
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Datenmodelle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class SensitiveMatch:
|
|
"""Ein gefundener sensitiver Inhalt in einem Artefakt.
|
|
|
|
Attributes:
|
|
pattern_name: Name des Musters, das getroffen hat (z.B. "credentials", "endpoint", "pii").
|
|
matched_text: Der gefundene Text-Ausschnitt.
|
|
line_number: Zeilennummer im Inhalt (1-basiert).
|
|
reason: Menschenlesbare Begründung, warum der Inhalt als sensitiv gilt.
|
|
"""
|
|
|
|
pattern_name: str
|
|
matched_text: str
|
|
line_number: int
|
|
reason: str
|
|
|
|
|
|
@dataclass
|
|
class ShareResult:
|
|
"""Ergebnis einer Freigabe-Operation.
|
|
|
|
Attributes:
|
|
success: True wenn die Freigabe erfolgreich war.
|
|
artifact_id: ID des betroffenen Artefakts.
|
|
shared_to: Liste der Kontexte, in denen das Artefakt nun verfügbar ist.
|
|
errors: Liste von Fehlermeldungen bei gescheiterter Freigabe.
|
|
"""
|
|
|
|
success: bool
|
|
artifact_id: str
|
|
shared_to: list[str] = field(default_factory=list)
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared-Artifacts-Registry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class SharedArtifactEntry:
|
|
"""Eintrag in der Shared-Artifacts-Registry.
|
|
|
|
Attributes:
|
|
artifact_id: ID des geteilten Artefakts.
|
|
source_context: Ursprungskontext des Artefakts.
|
|
shared_to: Zielkontexte, in denen das Artefakt verfügbar ist.
|
|
content_hash: Hash des Inhalts zum Zeitpunkt der Freigabe (für Update-Erkennung).
|
|
"""
|
|
|
|
artifact_id: str
|
|
source_context: str
|
|
shared_to: list[str] = field(default_factory=list)
|
|
content_hash: str = ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ContextBridge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ContextBridge:
|
|
"""Ermöglicht kontextübergreifenden Wissenstransfer.
|
|
|
|
Die ContextBridge prüft Artefakte auf sensitive Inhalte, erfordert
|
|
explizite Nutzerbestätigung und stellt geteilte Artefakte als
|
|
schreibgeschützte Lesereferenzen in Zielkontexten bereit.
|
|
|
|
Update-Propagierung: Wird ein Artefakt im Quellkontext aktualisiert,
|
|
sind die Änderungen beim nächsten Lesezugriff über get_shared_content()
|
|
sichtbar, da immer vom Quell-Artefakt gelesen wird.
|
|
|
|
Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6
|
|
"""
|
|
|
|
SENSITIVE_PATTERNS: list[tuple[str, str, str]] = [
|
|
(
|
|
r"(?i)(api[_-]?key|token|password|secret)\s*[:=]",
|
|
"credentials",
|
|
"Enthält kontextspezifische Zugangsdaten (API-Key, Token, Passwort oder Secret)",
|
|
),
|
|
(
|
|
r"(?i)(endpoint|url)\s*[:=]\s*https?://",
|
|
"endpoint",
|
|
"Enthält kontextspezifische Endpoint-URL",
|
|
),
|
|
(
|
|
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
|
|
"pii",
|
|
"Enthält personenbezogene Daten (E-Mail-Adresse)",
|
|
),
|
|
]
|
|
|
|
def __init__(
|
|
self,
|
|
knowledge_base_path: Path,
|
|
index: YAMLIndex,
|
|
registry_path: Path | None = None,
|
|
) -> None:
|
|
"""Initialisiert die ContextBridge.
|
|
|
|
Args:
|
|
knowledge_base_path: Basispfad des Wissensspeichers.
|
|
index: Der YAML-Index des Wissensspeichers.
|
|
registry_path: Pfad zur Shared-Artifacts-Registry (YAML).
|
|
Falls None, wird in-memory gearbeitet.
|
|
"""
|
|
self.knowledge_base_path = knowledge_base_path
|
|
self.index = index
|
|
self.registry_path = registry_path
|
|
self._registry: dict[str, SharedArtifactEntry] = {}
|
|
|
|
if registry_path and registry_path.exists():
|
|
self._load_registry()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Öffentliche API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def share_artifact(
|
|
self, artifact_id: str, user_confirmed: bool = False
|
|
) -> ShareResult:
|
|
"""Gibt ein Artefakt kontextübergreifend frei.
|
|
|
|
Prüft:
|
|
1. Existenz des Artefakts im Index
|
|
2. Explizite Nutzerbestätigung (Req 5.4)
|
|
3. Sensitive-Content-Filter (Req 5.1, 5.3)
|
|
|
|
Bei Erfolg wird das Artefakt als Lesereferenz in allen anderen
|
|
Kontexten bereitgestellt (Req 5.2).
|
|
|
|
Args:
|
|
artifact_id: ID des freizugebenden Artefakts.
|
|
user_confirmed: Muss True sein für explizite Nutzerbestätigung.
|
|
|
|
Returns:
|
|
ShareResult mit Erfolgs-/Fehlerstatus.
|
|
"""
|
|
# Req 5.4: Explizite Nutzerbestätigung erforderlich
|
|
if not user_confirmed:
|
|
return ShareResult(
|
|
success=False,
|
|
artifact_id=artifact_id,
|
|
errors=[
|
|
"Explizite Nutzerbestätigung erforderlich. "
|
|
"Setze user_confirmed=True um die Freigabe zu bestätigen."
|
|
],
|
|
)
|
|
|
|
# Artefakt im Index finden
|
|
entry = self.index.get_entry(artifact_id)
|
|
if entry is None:
|
|
return ShareResult(
|
|
success=False,
|
|
artifact_id=artifact_id,
|
|
errors=[f"Artefakt nicht im Index gefunden: {artifact_id}"],
|
|
)
|
|
|
|
# Inhalt laden und auf sensitive Inhalte prüfen (Req 5.1, 5.3)
|
|
content = self._load_artifact_content(entry)
|
|
if content is None:
|
|
return ShareResult(
|
|
success=False,
|
|
artifact_id=artifact_id,
|
|
errors=[
|
|
f"Artefakt-Datei nicht gefunden oder nicht lesbar: {entry.path}"
|
|
],
|
|
)
|
|
|
|
sensitive_matches = self.check_sensitive_content(content)
|
|
if sensitive_matches:
|
|
reasons = [
|
|
f"Zeile {m.line_number}: {m.reason} ('{m.matched_text}')"
|
|
for m in sensitive_matches
|
|
]
|
|
return ShareResult(
|
|
success=False,
|
|
artifact_id=artifact_id,
|
|
errors=[
|
|
"Freigabe verweigert: Artefakt enthält sensible Inhalte.",
|
|
*reasons,
|
|
],
|
|
)
|
|
|
|
# Zielkontexte ermitteln (alle Kontexte außer dem Quellkontext)
|
|
source_context = entry.scope
|
|
target_contexts = [
|
|
ctx.value for ctx in Context if ctx.value != source_context
|
|
]
|
|
|
|
# In Registry eintragen
|
|
self._registry[artifact_id] = SharedArtifactEntry(
|
|
artifact_id=artifact_id,
|
|
source_context=source_context,
|
|
shared_to=target_contexts,
|
|
content_hash=entry.content_hash,
|
|
)
|
|
|
|
# Registry persistieren
|
|
self._save_registry()
|
|
|
|
logger.info(
|
|
"Artefakt '%s' aus Kontext '%s' für Kontexte %s freigegeben.",
|
|
artifact_id,
|
|
source_context,
|
|
target_contexts,
|
|
)
|
|
|
|
return ShareResult(
|
|
success=True,
|
|
artifact_id=artifact_id,
|
|
shared_to=target_contexts,
|
|
)
|
|
|
|
def check_sensitive_content(self, content: str) -> list[SensitiveMatch]:
|
|
"""Prüft Inhalt auf kontextspezifische Geheimnisse und PII.
|
|
|
|
Verwendet Regex-Muster zur Erkennung von:
|
|
- Credentials (API-Keys, Tokens, Passwörter, Secrets)
|
|
- Endpoint-URLs
|
|
- Personenbezogene Daten (E-Mail-Adressen)
|
|
|
|
Args:
|
|
content: Der zu prüfende Textinhalt.
|
|
|
|
Returns:
|
|
Liste der gefundenen sensitiven Stellen. Leere Liste wenn
|
|
kein sensitiver Inhalt gefunden wurde.
|
|
|
|
Requirements: 5.1, 5.3
|
|
"""
|
|
matches: list[SensitiveMatch] = []
|
|
lines = content.splitlines()
|
|
|
|
for line_num, line in enumerate(lines, start=1):
|
|
for pattern_str, pattern_name, reason in self.SENSITIVE_PATTERNS:
|
|
pattern = re.compile(pattern_str)
|
|
for match in pattern.finditer(line):
|
|
matched_text = match.group(0)
|
|
# Text auf sinnvolle Länge kürzen
|
|
if len(matched_text) > 60:
|
|
matched_text = matched_text[:57] + "..."
|
|
matches.append(
|
|
SensitiveMatch(
|
|
pattern_name=pattern_name,
|
|
matched_text=matched_text,
|
|
line_number=line_num,
|
|
reason=reason,
|
|
)
|
|
)
|
|
|
|
return matches
|
|
|
|
def revoke_share(self, artifact_id: str) -> None:
|
|
"""Widerruft die Freigabe eines geteilten Artefakts.
|
|
|
|
Entfernt das Artefakt aus allen Zielkontexten als Lesereferenz.
|
|
Nach dem Widerruf ist das Artefakt nur noch im Quellkontext sichtbar.
|
|
|
|
Args:
|
|
artifact_id: ID des Artefakts, dessen Freigabe widerrufen wird.
|
|
|
|
Raises:
|
|
ValueError: Wenn das Artefakt nicht in der Freigabe-Registry ist.
|
|
|
|
Requirements: 5.6
|
|
"""
|
|
if artifact_id not in self._registry:
|
|
raise ValueError(
|
|
f"Artefakt '{artifact_id}' ist nicht freigegeben "
|
|
f"und kann daher nicht widerrufen werden."
|
|
)
|
|
|
|
entry = self._registry.pop(artifact_id)
|
|
self._save_registry()
|
|
|
|
logger.info(
|
|
"Freigabe von Artefakt '%s' widerrufen. "
|
|
"Entfernt aus Kontexten: %s",
|
|
artifact_id,
|
|
entry.shared_to,
|
|
)
|
|
|
|
def get_shared_content(self, artifact_id: str, requesting_context: str) -> str | None:
|
|
"""Liest den aktuellen Inhalt eines geteilten Artefakts.
|
|
|
|
Liefert immer den aktuellen Stand aus dem Quellkontext,
|
|
sodass Updates automatisch beim nächsten Lesezugriff sichtbar sind.
|
|
|
|
Args:
|
|
artifact_id: ID des geteilten Artefakts.
|
|
requesting_context: Der Kontext, aus dem der Zugriff erfolgt.
|
|
|
|
Returns:
|
|
Den aktuellen Inhalt des Artefakts als String, oder None
|
|
wenn der Zugriff nicht erlaubt ist.
|
|
|
|
Requirements: 5.2, 5.5
|
|
"""
|
|
# Prüfe ob Artefakt freigegeben ist
|
|
registry_entry = self._registry.get(artifact_id)
|
|
if registry_entry is None:
|
|
return None
|
|
|
|
# Prüfe ob der anfragende Kontext berechtigt ist
|
|
if requesting_context not in registry_entry.shared_to:
|
|
return None
|
|
|
|
# Lade den aktuellen Inhalt aus dem Quellkontext (Req 5.5: Update-Propagierung)
|
|
index_entry = self.index.get_entry(artifact_id)
|
|
if index_entry is None:
|
|
return None
|
|
|
|
return self._load_artifact_content(index_entry)
|
|
|
|
def is_shared(self, artifact_id: str) -> bool:
|
|
"""Prüft ob ein Artefakt aktuell freigegeben ist.
|
|
|
|
Args:
|
|
artifact_id: ID des zu prüfenden Artefakts.
|
|
|
|
Returns:
|
|
True wenn das Artefakt freigegeben ist.
|
|
"""
|
|
return artifact_id in self._registry
|
|
|
|
def get_shared_artifacts(self, context: str) -> list[str]:
|
|
"""Liefert alle Artefakt-IDs, die in einem bestimmten Kontext sichtbar sind.
|
|
|
|
Args:
|
|
context: Der Kontext, für den die sichtbaren Artefakte ermittelt werden.
|
|
|
|
Returns:
|
|
Liste der Artefakt-IDs, die im angegebenen Kontext als
|
|
Lesereferenz verfügbar sind.
|
|
"""
|
|
return [
|
|
entry.artifact_id
|
|
for entry in self._registry.values()
|
|
if context in entry.shared_to
|
|
]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interne Methoden
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_artifact_content(self, entry: IndexEntry) -> str | None:
|
|
"""Lädt den Textinhalt eines Artefakts aus dem Dateisystem.
|
|
|
|
Args:
|
|
entry: Der IndexEntry des Artefakts.
|
|
|
|
Returns:
|
|
Den Inhalt als String oder None bei Fehler.
|
|
"""
|
|
if not entry.path:
|
|
return None
|
|
|
|
artifact_path = self.knowledge_base_path / entry.path
|
|
if not artifact_path.exists():
|
|
logger.warning("Artefakt-Datei nicht gefunden: %s", artifact_path)
|
|
return None
|
|
|
|
try:
|
|
artifact = parse_frontmatter(artifact_path)
|
|
return artifact.content
|
|
except (ValueError, FileNotFoundError, OSError) as e:
|
|
logger.warning(
|
|
"Fehler beim Laden von Artefakt '%s': %s", entry.id, e
|
|
)
|
|
return None
|
|
|
|
def _load_registry(self) -> None:
|
|
"""Lädt die Shared-Artifacts-Registry von der Festplatte."""
|
|
if self.registry_path is None or not self.registry_path.exists():
|
|
return
|
|
|
|
try:
|
|
with open(self.registry_path, encoding="utf-8") as f:
|
|
data = yaml.safe_load(f)
|
|
except (yaml.YAMLError, OSError) as e:
|
|
logger.warning("Fehler beim Laden der Registry: %s", e)
|
|
return
|
|
|
|
if not isinstance(data, dict):
|
|
return
|
|
|
|
for artifact_data in data.get("shared_artifacts", []):
|
|
if isinstance(artifact_data, dict):
|
|
entry = SharedArtifactEntry(
|
|
artifact_id=str(artifact_data.get("artifact_id", "")),
|
|
source_context=str(artifact_data.get("source_context", "")),
|
|
shared_to=list(artifact_data.get("shared_to", [])),
|
|
content_hash=str(artifact_data.get("content_hash", "")),
|
|
)
|
|
if entry.artifact_id:
|
|
self._registry[entry.artifact_id] = entry
|
|
|
|
def _save_registry(self) -> None:
|
|
"""Persistiert die Shared-Artifacts-Registry auf die Festplatte."""
|
|
if self.registry_path is None:
|
|
return
|
|
|
|
data: dict[str, Any] = {
|
|
"shared_artifacts": [
|
|
{
|
|
"artifact_id": entry.artifact_id,
|
|
"source_context": entry.source_context,
|
|
"shared_to": entry.shared_to,
|
|
"content_hash": entry.content_hash,
|
|
}
|
|
for entry in self._registry.values()
|
|
]
|
|
}
|
|
|
|
self.registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(self.registry_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(
|
|
data,
|
|
f,
|
|
default_flow_style=False,
|
|
allow_unicode=True,
|
|
sort_keys=False,
|
|
)
|