Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user