Initial monorepo structure

This commit is contained in:
2026-06-30 20:37:40 +02:00
commit 2f2b295531
121 changed files with 39171 additions and 0 deletions
@@ -0,0 +1,136 @@
"""monorepo CLI-Tool für Monorepo-Verwaltung.
Exportiert die zentralen Datenmodelle und Konfigurationsfunktionen.
"""
from monorepo.audit import AuditLogger
from monorepo.bridge import ContextBridge, SensitiveMatch, ShareResult
from monorepo.config import MonorepoConfig, load_monorepo_config
from monorepo.models import (
ArtifactType,
ConflictInfo,
ConflictStrategy,
Context,
EncryptionKey,
IsolationLeak,
IsolationReport,
KeySource,
MachineContext,
MigrationPlan,
OnboardingResult,
PasswordManagerConfig,
ProjectInfo,
RepoEntry,
RepoMode,
ScopeConfig,
SecurityEvent,
SharedMirrorConfig,
SyncDirection,
SyncFrequency,
SyncResult,
TeamRepoEntry,
)
from monorepo.encryption import (
DecryptionResult,
EncryptionResult,
GitCryptNotAvailableError,
MachineContextManager,
PasswordManagerError,
SecretEncryptionManager,
)
from monorepo.federation import (
ConflictResult,
FederationManager,
FederationTopologyConfig,
MemberInfo,
MemberOnboardingResult,
MirrorResult,
SubtreeSyncEngine,
)
from monorepo.integration import create_integrated_stack
from monorepo.shared_config import (
AgentExtension,
ConfigMerger,
HarnessAdapter,
MCPServerConfig,
MergeConflict,
MergedMCPConfig,
SharedToolStatus,
ToolReference,
)
from monorepo.migration import (
MigrationConflict,
MigrationEngine,
MigrationError,
MigrationRegistryEntry,
MigrationResult,
)
from monorepo.security import ContextGuard
from monorepo.structure import StructureManager
__all__ = [
# Audit
"AuditLogger",
# Bridge
"ContextBridge",
"SensitiveMatch",
"ShareResult",
# Enums
"ArtifactType",
"ConflictStrategy",
"Context",
"KeySource",
"RepoMode",
"SyncDirection",
"SyncFrequency",
# Dataclasses
"ConflictInfo",
"EncryptionKey",
"IsolationLeak",
"IsolationReport",
"MachineContext",
"MigrationPlan",
"MonorepoConfig",
"OnboardingResult",
"PasswordManagerConfig",
"ProjectInfo",
"RepoEntry",
"ScopeConfig",
"SecurityEvent",
"SharedMirrorConfig",
"SyncResult",
"TeamRepoEntry",
# Classes
"AgentExtension",
"ConfigMerger",
"ConflictResult",
"ContextGuard",
"DecryptionResult",
"EncryptionResult",
"FederationManager",
"FederationTopologyConfig",
"GitCryptNotAvailableError",
"HarnessAdapter",
"MachineContextManager",
"MCPServerConfig",
"MemberInfo",
"MemberOnboardingResult",
"MergeConflict",
"MergedMCPConfig",
"MigrationConflict",
"MigrationEngine",
"MigrationError",
"MigrationRegistryEntry",
"MigrationResult",
"MirrorResult",
"PasswordManagerError",
"SecretEncryptionManager",
"SharedToolStatus",
"StructureManager",
"SubtreeSyncEngine",
"ToolReference",
# Config
"load_monorepo_config",
# Integration
"create_integrated_stack",
]
@@ -0,0 +1,71 @@
"""Audit-Logger für die Protokollierung von Zugriffsverletzungen.
Schreibt Sicherheitsereignisse (SecurityEvents) in eine konfigurierbare
Protokolldatei im strukturierten Textformat. Thread-sicher durch Nutzung
eines Locks für Dateizugriffe.
"""
from __future__ import annotations
import threading
from pathlib import Path
from monorepo.models import SecurityEvent
# Standard-Audit-Log-Pfad (relativ zum Monorepo-Root), wie in monorepo.yaml definiert
_DEFAULT_AUDIT_LOG_PATH = Path(".audit/access.log")
class AuditLogger:
"""Protokolliert Sicherheitsereignisse in eine Audit-Logdatei.
Jeder Eintrag wird als strukturierte Zeile im Format geschrieben:
[ISO-TIMESTAMP] OUTCOME | requesting_context -> target_context | action on resource
Der Logger ist thread-sicher: Schreibzugriffe auf die Logdatei werden
durch einen Lock serialisiert.
Args:
log_path: Pfad zur Audit-Logdatei. Standard: `.audit/access.log`
(aus monorepo.yaml security.audit_log).
"""
def __init__(self, log_path: Path | None = None) -> None:
"""Initialisiert den AuditLogger.
Args:
log_path: Pfad zur Audit-Logdatei. Falls None, wird der
Standard-Pfad `.audit/access.log` verwendet.
"""
self._log_path = log_path if log_path is not None else _DEFAULT_AUDIT_LOG_PATH
self._lock = threading.Lock()
# Verzeichnis erstellen, falls es nicht existiert
self._log_path.parent.mkdir(parents=True, exist_ok=True)
@property
def log_path(self) -> Path:
"""Gibt den konfigurierten Pfad zur Audit-Logdatei zurück."""
return self._log_path
def log_violation(self, event: SecurityEvent) -> None:
"""Schreibt ein Sicherheitsereignis in die Protokolldatei.
Format pro Zeile:
[2025-01-15T10:30:00] DENIED | dhive -> privat | read on privat/.env
Args:
event: Das zu protokollierende Sicherheitsereignis mit Zeitstempel,
anfragendem Kontext, Zielkontext, Ressource, Aktion und Ergebnis.
"""
timestamp_iso = event.timestamp.isoformat()
outcome_upper = event.outcome.upper()
line = (
f"[{timestamp_iso}] {outcome_upper} | "
f"{event.requesting_context} -> {event.target_context} | "
f"{event.action} on {event.resource}\n"
)
with self._lock:
with open(self._log_path, "a", encoding="utf-8") as f:
f.write(line)
@@ -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,
)
@@ -0,0 +1,735 @@
"""CLI-Einstiegspunkt für das Monorepo-Verwaltungstool (ctx-guard).
Stellt alle Verwaltungskommandos als Subcommands bereit und verdrahtet
die Komponenten: StructureManager, ContextGuard, KnowledgeStore, RepoManager,
ContextBridge, MigrationEngine, OrchestratorAdapter, SecretEncryptionManager,
FederationManager.
Konfiguration wird aus `monorepo.yaml` und `shared/config/` geladen.
Entry-Point: ctx-guard (pyproject.toml → [project.scripts])
Requirements: 1.1, 1.2, 4.4, 6.3, 9.6, 10.11
"""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
from monorepo.config import MonorepoConfig, load_monorepo_config
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Hilfsfunktionen für Komponenteninitialisierung
# ---------------------------------------------------------------------------
def _find_monorepo_root() -> Path:
"""Sucht das Monorepo-Root anhand von monorepo.yaml.
Sucht im aktuellen Verzeichnis und in übergeordneten Verzeichnissen
nach monorepo.yaml und gibt das Verzeichnis zurück.
Returns:
Pfad zum Monorepo-Root.
Raises:
SystemExit: Wenn kein monorepo.yaml gefunden wird.
"""
current = Path.cwd()
for parent in [current, *current.parents]:
if (parent / "monorepo.yaml").exists():
return parent
print("FEHLER: monorepo.yaml nicht gefunden.", file=sys.stderr)
print("Bitte im Monorepo-Root ausführen oder `ctx-guard init` verwenden.", file=sys.stderr)
sys.exit(1)
def _load_config_safe() -> MonorepoConfig:
"""Lädt die Monorepo-Konfiguration mit Fehlerbehandlung.
Returns:
Geladene MonorepoConfig.
Raises:
SystemExit: Bei Konfigurationsfehlern.
"""
try:
return load_monorepo_config()
except FileNotFoundError as e:
print(f"FEHLER: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"FEHLER beim Laden der Konfiguration: {e}", file=sys.stderr)
sys.exit(1)
def _get_structure_manager(root: Path) -> "StructureManager":
"""Erstellt einen StructureManager für das gegebene Root."""
from monorepo.structure import StructureManager
return StructureManager(root)
def _get_context_guard(root: Path) -> "ContextGuard":
"""Erstellt einen ContextGuard für das gegebene Root."""
from monorepo.security import ContextGuard
return ContextGuard(root)
def _get_knowledge_store(root: Path):
"""Erstellt einen KnowledgeStore für das gegebene Root."""
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
ks_path = root / "shared" / "knowledge-store"
scope_config = ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
return KnowledgeStore(base_path=ks_path, scope_config=scope_config)
def _get_repo_manager(root: Path) -> "RepoManager":
"""Erstellt einen RepoManager für das gegebene Root."""
from monorepo.repos import RepoManager
config_path = root / "shared" / "config" / "repos.yaml"
return RepoManager(config_path=config_path, monorepo_root=root)
def _get_encryption_manager(root: Path):
"""Erstellt einen SecretEncryptionManager für das gegebene Root."""
from monorepo.encryption import MachineContextManager
mcm = MachineContextManager(root)
try:
mcm.load()
except FileNotFoundError:
from monorepo.models import MachineContext
# Fallback: Erstelle einen Default-Maschinenkontext
mcm._machine_context = MachineContext(
name="unknown",
description="Standard-Maschinenkontext (keine Konfiguration gefunden)",
authorized_contexts=["privat", "dhive", "bahn"],
key_source="keyring",
)
return mcm.create_encryption_manager()
def _get_federation_manager(root: Path):
"""Erstellt einen FederationManager für das gegebene Root."""
from monorepo.federation import FederationManager
config_path = root / "shared" / "config" / "team-repos.yaml"
encryption_mgr = _get_encryption_manager(root)
return FederationManager(
config_path=config_path,
monorepo_root=root,
encryption_manager=encryption_mgr,
)
def _get_migration_engine(root: Path):
"""Erstellt eine MigrationEngine für das gegebene Root."""
from monorepo.migration import MigrationEngine
return MigrationEngine(monorepo_root=root)
# ---------------------------------------------------------------------------
# Subcommand-Handler
# ---------------------------------------------------------------------------
def _cmd_init(args: argparse.Namespace) -> int:
"""Initialisiert die Monorepo-Grundstruktur.
Erstellt die vier Kontextordner und die shared-Unterstruktur,
sowie eine minimale monorepo.yaml falls sie nicht existiert.
"""
target = Path(args.path).resolve() if args.path else Path.cwd()
print(f"Initialisiere Monorepo in: {target}")
# Kontextordner erstellen
contexts = ["privat", "dhive", "bahn", "shared"]
for ctx in contexts:
(target / ctx).mkdir(parents=True, exist_ok=True)
# Shared-Unterstruktur
shared_dirs = [
"shared/tools",
"shared/powers",
"shared/knowledge-store",
"shared/config",
"shared/mcp-servers",
]
for d in shared_dirs:
(target / d).mkdir(parents=True, exist_ok=True)
# monorepo.yaml erstellen falls nicht vorhanden
config_path = target / "monorepo.yaml"
if not config_path.exists():
import yaml
config_data = {
"version": "1.0",
"contexts": [
{"name": "privat", "description": "Persönliche Projekte"},
{"name": "dhive", "description": "dhive GmbH Projekte"},
{"name": "bahn", "description": "DB InfraGO Projekte"},
{"name": "shared", "description": "Kontextübergreifende Tools und Wissen"},
],
"naming": {
"pattern": r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$",
"min_length": 2,
"max_length": 50,
},
"security": {
"config": "shared/config/access-config.yaml",
"audit_log": ".audit/access.log",
"encryption": {
"tool": "git-crypt",
"machine_context_config": "shared/config/machine-context.yaml",
"key_source": "keyring",
},
"federation": {
"config": "shared/config/team-repos.yaml",
"conflict_strategy": "team-wins",
},
},
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
print(f" Erstellt: {config_path}")
# Audit-Verzeichnis
(target / ".audit").mkdir(parents=True, exist_ok=True)
# Git-Hooks installieren (pre-commit mit Read-Only-Schutz + Secret-Prüfung)
git_dir = target / ".git"
if git_dir.exists():
from monorepo.hooks import install_hooks
try:
install_hooks(target)
print(" Installiert: Git pre-commit Hook (Read-Only + Secrets)")
except Exception as e:
logger.warning("Hook-Installation übersprungen: %s", e)
print(f" ⚠ Hook-Installation übersprungen: {e}")
print("✓ Monorepo-Struktur erfolgreich initialisiert.")
return 0
def _cmd_create_project(args: argparse.Namespace) -> int:
"""Erstellt ein neues Projekt in einem Arbeitskontext."""
root = _find_monorepo_root()
sm = _get_structure_manager(root)
try:
project_path = sm.create_project(context=args.context, name=args.name)
print(f"✓ Projekt erstellt: {project_path}")
return 0
except ValueError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 1
def _cmd_list_projects(args: argparse.Namespace) -> int:
"""Listet alle Projekte auf, optional gefiltert nach Kontext."""
root = _find_monorepo_root()
sm = _get_structure_manager(root)
try:
projects = sm.list_projects(context=args.context)
except ValueError as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 1
if not projects:
ctx_info = f" im Kontext '{args.context}'" if args.context else ""
print(f"Keine Projekte{ctx_info} gefunden.")
return 0
# Tabellen-Ausgabe
print(f"{'Kontext':<10} {'Name':<40} {'Pfad'}")
print("-" * 80)
for p in projects:
print(f"{p.context:<10} {p.name:<40} {p.path}")
print(f"\nGesamt: {len(projects)} Projekt(e)")
return 0
def _cmd_add_repo(args: argparse.Namespace) -> int:
"""Bindet ein externes Repository ein."""
root = _find_monorepo_root()
rm = _get_repo_manager(root)
from monorepo.models import RepoEntry
entry = RepoEntry(
name=args.name,
url=args.url,
mode=args.mode,
target=args.target,
pinned=args.pinned,
mechanism=args.mechanism,
)
try:
rm.add_repo(entry)
print(f"✓ Repo '{args.name}' eingebunden via {args.mechanism}{args.target}")
return 0
except (ValueError, Exception) as e:
print(f"FEHLER: {e}", file=sys.stderr)
return 1
def _cmd_sync_repo(args: argparse.Namespace) -> int:
"""Synchronisiert ein eingebundenes Repository."""
root = _find_monorepo_root()
rm = _get_repo_manager(root)
result = rm.sync(args.name)
if result.success:
print(f"✓ Repo '{args.name}' synchronisiert ({result.commits_synced} Commits).")
return 0
else:
print(f"FEHLER bei Synchronisation von '{args.name}':", file=sys.stderr)
for conflict in result.conflicts:
print(f" - {conflict.details}", file=sys.stderr)
return 1
def _cmd_migrate(args: argparse.Namespace) -> int:
"""Migriert ein bestehendes Repository ins Monorepo."""
root = _find_monorepo_root()
engine = _get_migration_engine(root)
from monorepo.models import MigrationPlan
plan = MigrationPlan(
source_repo=args.source,
target_context=args.context,
target_name=args.name,
mode=args.mode,
dependencies=[],
order=0,
)
print(f"Migriere '{args.source}'{args.context}/{args.name} (Modus: {args.mode})...")
result = engine.migrate(plan)
if result.success:
print(f"✓ Migration erfolgreich abgeschlossen.")
if result.commits_migrated:
print(f" Commits: {result.commits_migrated}")
if result.branches_migrated:
print(f" Branches: {', '.join(result.branches_migrated)}")
if result.tags_migrated:
print(f" Tags: {', '.join(result.tags_migrated)}")
return 0
else:
print(f"FEHLER bei Migration:", file=sys.stderr)
if result.error_message:
print(f" {result.error_message}", file=sys.stderr)
if result.conflicts:
print(f"\nKonflikte ({len(result.conflicts)}):", file=sys.stderr)
for c in result.conflicts:
print(f" [{c.conflict_type}] {c.description}", file=sys.stderr)
return 1
def _cmd_validate(args: argparse.Namespace) -> int:
"""Validiert eine abgeschlossene Migration."""
root = _find_monorepo_root()
engine = _get_migration_engine(root)
print(f"Validiere Migration von '{args.name}'...")
result = engine.validate(args.name)
if result.success:
print(f"✓ Validierung erfolgreich für '{args.name}'.")
for check_name, passed in result.checks.items():
status = "" if passed else ""
print(f" {status} {check_name}")
return 0
else:
print(f"FEHLER: Validierung fehlgeschlagen für '{args.name}':", file=sys.stderr)
if result.error_message:
print(f" {result.error_message}", file=sys.stderr)
for detail in result.details:
print(f" - {detail}", file=sys.stderr)
return 1
def _cmd_search(args: argparse.Namespace) -> int:
"""Durchsucht den Wissensspeicher."""
root = _find_monorepo_root()
ks = _get_knowledge_store(root)
# Bestimme erlaubte Scopes (standardmäßig alle)
allowed_scopes = args.scopes.split(",") if args.scopes else ["privat", "dhive", "bahn", "shared"]
results = ks.search(query=args.query, allowed_scopes=allowed_scopes)
if not results:
print(f"Keine Treffer für '{args.query}'.")
return 0
print(f"Treffer für '{args.query}' ({len(results)} Ergebnis(se)):\n")
for r in results:
score_bar = "" * int(r.relevance_score * 10)
print(f" [{r.entry.scope}] {r.entry.title}")
print(f" Pfad: {r.entry.path}")
print(f" Tags: {', '.join(r.entry.tags)}")
print(f" Relevanz: {score_bar} ({r.relevance_score:.2f})")
print()
if results and results[0].partial_results:
print("⚠ Suche wegen Timeout abgebrochen möglicherweise unvollständig.")
return 0
def _cmd_encrypt(args: argparse.Namespace) -> int:
"""Verschlüsselt eine Datei mit dem Kontext-Schlüssel."""
root = _find_monorepo_root()
enc = _get_encryption_manager(root)
file_path = Path(args.file).resolve()
context = args.context
if not context:
# Kontext aus Pfad ableiten
try:
rel = file_path.relative_to(root)
context = rel.parts[0] if rel.parts else None
except ValueError:
pass
if not context:
print("FEHLER: Kontext konnte nicht ermittelt werden. Bitte --context angeben.", file=sys.stderr)
return 1
result = enc.encrypt_file(file_path, context)
if result.success:
print(f"✓ Datei verschlüsselt: {file_path} (Kontext: {context})")
return 0
else:
print(f"FEHLER: {result.error}", file=sys.stderr)
return 1
def _cmd_decrypt(args: argparse.Namespace) -> int:
"""Entschlüsselt eine Datei."""
root = _find_monorepo_root()
enc = _get_encryption_manager(root)
file_path = Path(args.file).resolve()
result = enc.decrypt_file(file_path)
if result.success:
print(f"✓ Datei entschlüsselt: {file_path}")
return 0
else:
print(f"FEHLER: {result.error}", file=sys.stderr)
return 1
def _cmd_onboard(args: argparse.Namespace) -> int:
"""Richtet eine neue Maschine mit autorisierten Schlüsseln ein."""
root = _find_monorepo_root()
enc = _get_encryption_manager(root)
contexts = [c.strip() for c in args.contexts.split(",")]
print(f"Onboarding Maschine '{args.machine_name}' für Kontexte: {', '.join(contexts)}...")
result = enc.onboard_machine(args.machine_name, contexts)
if result.success:
print(f"✓ Maschine '{args.machine_name}' erfolgreich eingerichtet.")
print(f" Autorisierte Kontexte: {', '.join(result.authorized_contexts)}")
print(f" Installierte Schlüssel: {len(result.installed_keys)}")
return 0
else:
print(f"FEHLER beim Onboarding:", file=sys.stderr)
for err in result.errors:
print(f" - {err}", file=sys.stderr)
return 1
def _cmd_fed_sync(args: argparse.Namespace) -> int:
"""Synchronisiert mit einem Team-Repository."""
root = _find_monorepo_root()
fm = _get_federation_manager(root)
context = args.context
direction = args.direction
print(f"Federation-Sync: Kontext '{context}', Richtung '{direction}'...")
if direction == "pull":
result = fm.sync_from_team(context)
elif direction == "push":
result = fm.sync_to_team(context)
else:
result = fm.full_sync(context)
if result.success:
print(f"✓ Sync erfolgreich ({result.commits_synced} Commits, Richtung: {result.direction}).")
return 0
else:
print(f"FEHLER bei Federation-Sync:", file=sys.stderr)
for conflict in result.conflicts:
print(f" - [{conflict.conflict_type}] {conflict.details}", file=sys.stderr)
return 1
def _cmd_fed_status(args: argparse.Namespace) -> int:
"""Zeigt den Federation-Status für alle oder einen bestimmten Kontext."""
root = _find_monorepo_root()
fm = _get_federation_manager(root)
contexts = [args.context] if args.context else ["privat", "dhive", "bahn"]
print("Federation-Status:")
print("-" * 60)
for ctx in contexts:
entry = fm._get_team_entry(ctx)
if entry is None:
print(f" {ctx:<10} Nicht konfiguriert")
continue
# Prüfe auf Konflikte
conflicts = fm.detect_conflicts(ctx)
conflict_status = f"{len(conflicts)} Konflikt(e)" if conflicts else "✓ Keine Konflikte"
print(f" {ctx:<10} URL: {entry.url}")
print(f" {'':10} Branch: {entry.branch}")
print(f" {'':10} Sync: {entry.sync_direction} ({entry.sync_frequency})")
print(f" {'':10} Status: {conflict_status}")
if entry.shared_mirror:
mirror_status = "aktiv" if entry.shared_mirror.enabled else "deaktiviert"
print(f" {'':10} Shared-Mirror: {mirror_status}")
print()
return 0
def _cmd_install_hooks(args: argparse.Namespace) -> int:
"""Installiert oder aktualisiert die Monorepo Git-Hooks."""
root = _find_monorepo_root()
from monorepo.hooks import install_hooks
try:
install_hooks(root)
print("✓ Git-Hooks installiert/aktualisiert.")
return 0
except Exception as e:
print(f"FEHLER bei Hook-Installation: {e}", file=sys.stderr)
return 1
def _cmd_uninstall_hooks(args: argparse.Namespace) -> int:
"""Entfernt die Monorepo Git-Hooks."""
root = _find_monorepo_root()
from monorepo.hooks import uninstall_hooks
try:
uninstall_hooks(root)
print("✓ Git-Hooks entfernt.")
return 0
except Exception as e:
print(f"FEHLER bei Hook-Deinstallation: {e}", file=sys.stderr)
return 1
# ---------------------------------------------------------------------------
# Argument-Parser-Aufbau
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
"""Baut den vollständigen Argument-Parser mit allen Subcommands auf."""
parser = argparse.ArgumentParser(
prog="ctx-guard",
description="Monorepo-Verwaltungstool: Kontextgrenzen, Wissensspeicher, "
"Repo-Sync, Verschlüsselung und Föderation.",
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Ausführliche Logging-Ausgabe aktivieren.",
)
subparsers = parser.add_subparsers(dest="command", help="Verfügbare Kommandos")
# --- init ---
p_init = subparsers.add_parser("init", help="Monorepo-Struktur initialisieren")
p_init.add_argument(
"--path", type=str, default=None,
help="Zielpfad für die Initialisierung (Standard: aktuelles Verzeichnis).",
)
# --- create-project ---
p_create = subparsers.add_parser("create-project", help="Neues Projekt anlegen")
p_create.add_argument("context", choices=["privat", "dhive", "bahn", "shared"],
help="Arbeitskontext für das Projekt.")
p_create.add_argument("name", help="Projektname (kebab-case, 2-50 Zeichen).")
# --- list-projects ---
p_list = subparsers.add_parser("list-projects", help="Projekte auflisten")
p_list.add_argument(
"--context", "-c",
choices=["privat", "dhive", "bahn", "shared"],
default=None,
help="Nur Projekte des angegebenen Kontexts anzeigen.",
)
# --- add-repo ---
p_add = subparsers.add_parser("add-repo", help="Externes Repository einbinden")
p_add.add_argument("name", help="Eindeutiger Name für das Repo.")
p_add.add_argument("url", help="Repository-URL.")
p_add.add_argument("target", help="Zielpfad im Monorepo (z.B. 'bahn/db-wissen').")
p_add.add_argument(
"--mode", choices=["read-only", "upstream"], default="read-only",
help="Einbindungsmodus (Standard: read-only).",
)
p_add.add_argument(
"--pinned", default="main",
help="Gepinnte Version: Branch, Tag oder SHA (Standard: main).",
)
p_add.add_argument(
"--mechanism", choices=["subtree", "submodule"], default="subtree",
help="Einbindungsmechanismus (Standard: subtree).",
)
# --- sync-repo ---
p_sync = subparsers.add_parser("sync-repo", help="Repository synchronisieren")
p_sync.add_argument("name", help="Name des zu synchronisierenden Repos.")
# --- migrate ---
p_migrate = subparsers.add_parser("migrate", help="Repository ins Monorepo migrieren")
p_migrate.add_argument("source", help="Pfad zum Quell-Repository.")
p_migrate.add_argument("context", choices=["privat", "dhive", "bahn", "shared"],
help="Ziel-Arbeitskontext.")
p_migrate.add_argument("name", help="Projektname im Ziel.")
p_migrate.add_argument(
"--mode", choices=["direct", "subtree", "upstream"], default="direct",
help="Migrationsmodus (Standard: direct).",
)
# --- validate ---
p_validate = subparsers.add_parser("validate", help="Migration validieren")
p_validate.add_argument("name", help="Name des migrierten Repos/Projekts.")
# --- search ---
p_search = subparsers.add_parser("search", help="Wissensspeicher durchsuchen")
p_search.add_argument("query", help="Suchanfrage.")
p_search.add_argument(
"--scopes", "-s", default=None,
help="Komma-getrennte Scopes (Standard: alle). Z.B. 'privat,bahn'.",
)
# --- encrypt ---
p_encrypt = subparsers.add_parser("encrypt", help="Datei verschlüsseln")
p_encrypt.add_argument("file", help="Pfad zur zu verschlüsselnden Datei.")
p_encrypt.add_argument(
"--context", "-c", default=None,
help="Arbeitskontext (wird aus Pfad abgeleitet wenn nicht angegeben).",
)
# --- decrypt ---
p_decrypt = subparsers.add_parser("decrypt", help="Datei entschlüsseln")
p_decrypt.add_argument("file", help="Pfad zur zu entschlüsselnden Datei.")
# --- onboard ---
p_onboard = subparsers.add_parser("onboard", help="Neue Maschine einrichten")
p_onboard.add_argument("machine_name", help="Name der Maschine.")
p_onboard.add_argument(
"contexts",
help="Komma-getrennte Liste autorisierter Kontexte (z.B. 'privat,dhive,bahn').",
)
# --- fed-sync ---
p_fedsync = subparsers.add_parser("fed-sync", help="Team-Repo synchronisieren")
p_fedsync.add_argument("context", choices=["privat", "dhive", "bahn"],
help="Arbeitskontext des Team-Repos.")
p_fedsync.add_argument(
"--direction", "-d",
choices=["pull", "push", "full"], default="full",
help="Sync-Richtung (Standard: full/bidirektional).",
)
# --- fed-status ---
p_fedstatus = subparsers.add_parser("fed-status", help="Federation-Status anzeigen")
p_fedstatus.add_argument(
"--context", "-c",
choices=["privat", "dhive", "bahn"],
default=None,
help="Nur Status für den angegebenen Kontext anzeigen.",
)
# --- install-hooks ---
subparsers.add_parser("install-hooks", help="Git-Hooks installieren/aktualisieren")
# --- uninstall-hooks ---
subparsers.add_parser("uninstall-hooks", help="Git-Hooks entfernen")
return parser
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
_COMMAND_HANDLERS = {
"init": _cmd_init,
"create-project": _cmd_create_project,
"list-projects": _cmd_list_projects,
"add-repo": _cmd_add_repo,
"sync-repo": _cmd_sync_repo,
"migrate": _cmd_migrate,
"validate": _cmd_validate,
"search": _cmd_search,
"encrypt": _cmd_encrypt,
"decrypt": _cmd_decrypt,
"onboard": _cmd_onboard,
"fed-sync": _cmd_fed_sync,
"fed-status": _cmd_fed_status,
"install-hooks": _cmd_install_hooks,
"uninstall-hooks": _cmd_uninstall_hooks,
}
def main() -> None:
"""Haupt-Einstiegspunkt für das CLI-Tool ctx-guard."""
parser = _build_parser()
args = parser.parse_args()
# Logging konfigurieren
log_level = logging.DEBUG if args.verbose else logging.WARNING
logging.basicConfig(
level=log_level,
format="%(levelname)s: %(name)s: %(message)s",
)
if not args.command:
parser.print_help()
sys.exit(0)
handler = _COMMAND_HANDLERS.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
exit_code = handler(args)
sys.exit(exit_code)
if __name__ == "__main__":
main()
@@ -0,0 +1,173 @@
"""Konfigurationsmodul für das Monorepo-CLI.
Lädt und validiert die zentrale monorepo.yaml und stellt die Konfiguration
als typisierte Datenstrukturen bereit.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
@dataclass
class NamingConfig:
"""Namenskonventions-Konfiguration."""
pattern: str = r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$"
min_length: int = 2
max_length: int = 50
@dataclass
class EncryptionConfig:
"""Verschlüsselungs-Konfiguration."""
tool: str = "git-crypt"
machine_context_config: str = "shared/config/machine-context.yaml"
key_source: str = "keyring"
@dataclass
class FederationConfig:
"""Föderations-Konfiguration."""
config: str = "shared/config/team-repos.yaml"
conflict_strategy: str = "team-wins"
@dataclass
class SecurityConfig:
"""Sicherheits-Konfiguration."""
config: str = "shared/config/access-config.yaml"
audit_log: str = ".audit/access.log"
encryption: EncryptionConfig = field(default_factory=EncryptionConfig)
federation: FederationConfig = field(default_factory=FederationConfig)
@dataclass
class ContextDefinition:
"""Definition eines Arbeitskontexts."""
name: str
description: str
@dataclass
class MonorepoConfig:
"""Zentrale Monorepo-Konfiguration (monorepo.yaml)."""
version: str = "1.0"
contexts: list[ContextDefinition] = field(default_factory=list)
naming: NamingConfig = field(default_factory=NamingConfig)
security: SecurityConfig = field(default_factory=SecurityConfig)
def _parse_encryption_config(data: dict[str, Any]) -> EncryptionConfig:
"""Parst die Verschlüsselungskonfiguration aus einem dict."""
return EncryptionConfig(
tool=data.get("tool", "git-crypt"),
machine_context_config=data.get("machine_context_config", "shared/config/machine-context.yaml"),
key_source=data.get("key_source", "keyring"),
)
def _parse_federation_config(data: dict[str, Any]) -> FederationConfig:
"""Parst die Föderationskonfiguration aus einem dict."""
return FederationConfig(
config=data.get("config", "shared/config/team-repos.yaml"),
conflict_strategy=data.get("conflict_strategy", "team-wins"),
)
def _parse_security_config(data: dict[str, Any]) -> SecurityConfig:
"""Parst die Sicherheitskonfiguration aus einem dict."""
encryption_data = data.get("encryption", {})
federation_data = data.get("federation", {})
return SecurityConfig(
config=data.get("config", "shared/config/access-config.yaml"),
audit_log=data.get("audit_log", ".audit/access.log"),
encryption=_parse_encryption_config(encryption_data),
federation=_parse_federation_config(federation_data),
)
def _parse_naming_config(data: dict[str, Any]) -> NamingConfig:
"""Parst die Namenskonventions-Konfiguration aus einem dict."""
return NamingConfig(
pattern=data.get("pattern", r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$"),
min_length=data.get("min_length", 2),
max_length=data.get("max_length", 50),
)
def load_monorepo_config(config_path: Path | None = None) -> MonorepoConfig:
"""Lädt die zentrale Monorepo-Konfiguration aus monorepo.yaml.
Args:
config_path: Pfad zur monorepo.yaml. Falls None, wird im aktuellen
Verzeichnis und darüber gesucht.
Returns:
MonorepoConfig mit allen geladenen Einstellungen.
Raises:
FileNotFoundError: Wenn die Konfigurationsdatei nicht gefunden wird.
yaml.YAMLError: Wenn die YAML-Datei nicht geparst werden kann.
"""
if config_path is None:
config_path = _find_config_file()
if not config_path.exists():
raise FileNotFoundError(f"Monorepo-Konfiguration nicht gefunden: {config_path}")
with open(config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(f"Ungültiges monorepo.yaml-Format: Erwartet dict, erhalten {type(data)}")
# Kontexte parsen
contexts: list[ContextDefinition] = []
for ctx in data.get("contexts", []):
contexts.append(ContextDefinition(
name=ctx.get("name", ""),
description=ctx.get("description", ""),
))
# Naming-Konfiguration
naming = _parse_naming_config(data.get("naming", {}))
# Security-Konfiguration
security = _parse_security_config(data.get("security", {}))
return MonorepoConfig(
version=data.get("version", "1.0"),
contexts=contexts,
naming=naming,
security=security,
)
def _find_config_file() -> Path:
"""Sucht monorepo.yaml im aktuellen Verzeichnis und darüber.
Returns:
Pfad zur gefundenen monorepo.yaml.
Raises:
FileNotFoundError: Wenn keine monorepo.yaml gefunden wird.
"""
current = Path.cwd()
for parent in [current, *current.parents]:
candidate = parent / "monorepo.yaml"
if candidate.exists():
return candidate
raise FileNotFoundError(
"monorepo.yaml nicht gefunden. Bitte im Monorepo-Root ausführen "
"oder config_path explizit angeben."
)
@@ -0,0 +1,826 @@
"""Verschlüsselungsmodul für das Monorepo-CLI.
Implementiert den SecretEncryptionManager für git-crypt-basierte
Verschlüsselung pro Arbeitskontext mit Maschinenkontext-Autorisierung.
Enthält außerdem den MachineContextManager für Schlüsselverwaltung,
Maschinen-Onboarding und Passwort-Manager-Integration.
"""
from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import yaml
from monorepo.models import (
EncryptionKey,
MachineContext,
OnboardingResult,
PasswordManagerConfig,
)
# ---------------------------------------------------------------------------
# Ergebnis-Dataclasses
# ---------------------------------------------------------------------------
@dataclass
class EncryptionResult:
"""Ergebnis einer Verschlüsselungsoperation."""
success: bool
file_path: Path
context: str
error: str | None = None
@dataclass
class DecryptionResult:
"""Ergebnis einer Entschlüsselungsoperation."""
success: bool
file_path: Path
content: bytes | None = None
error: str | None = None
# ---------------------------------------------------------------------------
# SecretEncryptionManager
# ---------------------------------------------------------------------------
#: Dateimuster, die als Secret-Dateien behandelt werden.
SECRET_PATTERNS: list[str] = [
"**/.env",
"**/*.pem",
"**/*.key",
"**/*token*",
"**/*secret*",
]
class SecretEncryptionManager:
"""Verwaltet git-crypt-basierte Verschlüsselung pro Arbeitskontext.
Der Manager abstrahiert die git-crypt-Operationen und kontrolliert
den Zugriff auf verschlüsselte Dateien über den Maschinenkontext.
Args:
root_path: Pfad zum Monorepo-Root-Verzeichnis.
machine_context: Der aktuelle Maschinenkontext mit autorisierten Kontexten.
"""
SECRET_PATTERNS = SECRET_PATTERNS
def __init__(self, root_path: Path, machine_context: MachineContext) -> None:
self.root_path = root_path
self.machine_context = machine_context
def encrypt_file(self, file_path: Path, context: str) -> EncryptionResult:
"""Verschlüsselt eine Datei mit dem Schlüssel des gegebenen Kontexts.
Die Verschlüsselung erfolgt über git-crypt-Filter. Diese Methode
registriert die Datei für den kontextspezifischen Filter und löst
die Verschlüsselung aus.
Args:
file_path: Pfad zur zu verschlüsselnden Datei.
context: Arbeitskontext, dessen Schlüssel verwendet werden soll.
Returns:
EncryptionResult mit Erfolgs-/Fehlerstatus.
"""
if not file_path.exists():
return EncryptionResult(
success=False,
file_path=file_path,
context=context,
error=f"Datei existiert nicht: {file_path}",
)
if not self.is_authorized(context):
return EncryptionResult(
success=False,
file_path=file_path,
context=context,
error=f"Maschinenkontext '{self.machine_context.name}' ist nicht "
f"für Kontext '{context}' autorisiert",
)
try:
self._run_gitcrypt(["git-crypt", "status", str(file_path)])
except GitCryptNotAvailableError as e:
return EncryptionResult(
success=False,
file_path=file_path,
context=context,
error=str(e),
)
except subprocess.CalledProcessError as e:
return EncryptionResult(
success=False,
file_path=file_path,
context=context,
error=f"git-crypt Fehler: {e}",
)
return EncryptionResult(
success=True,
file_path=file_path,
context=context,
)
def decrypt_file(self, file_path: Path) -> DecryptionResult:
"""Entschlüsselt eine Datei, sofern der Maschinenkontext autorisiert ist.
Ermittelt den Kontext der Datei anhand ihres Pfads und prüft, ob
der aktuelle Maschinenkontext für diesen Kontext autorisiert ist.
Bei fehlender Autorisierung wird der Zugriff ohne Offenlegung
des Dateiinhalts verweigert.
Args:
file_path: Pfad zur zu entschlüsselnden Datei.
Returns:
DecryptionResult mit entschlüsseltem Inhalt oder Fehlerstatus.
"""
if not file_path.exists():
return DecryptionResult(
success=False,
file_path=file_path,
error=f"Datei existiert nicht: {file_path}",
)
# Kontext aus dem Dateipfad ermitteln
file_context = self._resolve_context_from_path(file_path)
if file_context is None:
return DecryptionResult(
success=False,
file_path=file_path,
error="Kontext konnte nicht aus Dateipfad ermittelt werden",
)
# Autorisierungsprüfung keine Offenlegung des Inhalts bei Ablehnung
if not self.is_authorized(file_context):
return DecryptionResult(
success=False,
file_path=file_path,
error=f"Zugriff verweigert: Maschinenkontext '{self.machine_context.name}' "
f"ist nicht für Kontext '{file_context}' autorisiert",
)
try:
self._run_gitcrypt(["git-crypt", "unlock"])
content = file_path.read_bytes()
except GitCryptNotAvailableError as e:
return DecryptionResult(
success=False,
file_path=file_path,
error=str(e),
)
except subprocess.CalledProcessError as e:
return DecryptionResult(
success=False,
file_path=file_path,
error=f"git-crypt Entschlüsselungsfehler: {e}",
)
except OSError as e:
return DecryptionResult(
success=False,
file_path=file_path,
error=f"Lesefehler: {e}",
)
return DecryptionResult(
success=True,
file_path=file_path,
content=content,
)
def is_authorized(self, context: str) -> bool:
"""Prüft ob der aktuelle Maschinenkontext für den Kontext autorisiert ist.
Args:
context: Zu prüfender Arbeitskontext (z.B. 'privat', 'dhive', 'bahn').
Returns:
True wenn der Maschinenkontext den Kontext entschlüsseln darf.
"""
return context in self.machine_context.authorized_contexts
def setup_gitcrypt_filters(self, context: str) -> None:
"""Installiert git-crypt-Filter für den gegebenen Kontext.
Schreibt oder aktualisiert die `.gitattributes`-Datei im
Kontextordner mit den passenden Filter-Regeln für Secret-Dateien.
Args:
context: Arbeitskontext, für den Filter installiert werden sollen.
"""
context_dir = self.root_path / context
context_dir.mkdir(parents=True, exist_ok=True)
gitattributes_path = context_dir / ".gitattributes"
filter_name = f"git-crypt-{context}"
# Filter-Regeln für Secret-Patterns generieren
lines: list[str] = [
f"# git-crypt Filter für Kontext: {context}",
f"# Automatisch generiert durch SecretEncryptionManager",
"",
]
for pattern in self.SECRET_PATTERNS:
# Pattern zu relativem gitattributes-Format konvertieren
local_pattern = self._pattern_to_gitattributes(pattern)
lines.append(
f"{local_pattern} filter={filter_name} diff={filter_name}"
)
lines.append("") # Abschließende Leerzeile
gitattributes_path.write_text("\n".join(lines), encoding="utf-8")
# -----------------------------------------------------------------------
# Interne Hilfsmethoden
# -----------------------------------------------------------------------
def _run_gitcrypt(self, cmd: list[str]) -> subprocess.CompletedProcess[bytes]:
"""Führt einen git-crypt-Befehl aus.
Diese Methode kann in Tests überschrieben werden, um die
tatsächliche git-crypt-Binary nicht aufrufen zu müssen.
Args:
cmd: Befehl und Argumente als Liste.
Returns:
CompletedProcess-Objekt mit dem Ergebnis.
Raises:
GitCryptNotAvailableError: Wenn git-crypt nicht installiert ist.
subprocess.CalledProcessError: Wenn der Befehl fehlschlägt.
"""
try:
return subprocess.run(
cmd,
cwd=self.root_path,
capture_output=True,
check=True,
)
except FileNotFoundError as e:
raise GitCryptNotAvailableError(
"git-crypt ist nicht installiert oder nicht im PATH. "
"Bitte git-crypt installieren: https://github.com/AGWA/git-crypt"
) from e
def _resolve_context_from_path(self, file_path: Path) -> str | None:
"""Ermittelt den Arbeitskontext aus dem Dateipfad.
Der Kontext entspricht dem ersten Pfad-Segment relativ zum Root.
Gültige Kontexte: privat, dhive, bahn, shared.
Args:
file_path: Absoluter oder relativer Pfad zur Datei.
Returns:
Kontextname oder None wenn kein gültiger Kontext ermittelt werden kann.
"""
valid_contexts = {"privat", "dhive", "bahn", "shared"}
try:
rel_path = file_path.resolve().relative_to(self.root_path.resolve())
except ValueError:
return None
if not rel_path.parts:
return None
first_part = rel_path.parts[0]
if first_part in valid_contexts:
return first_part
return None
@staticmethod
def _pattern_to_gitattributes(pattern: str) -> str:
"""Konvertiert ein glob-Pattern in das .gitattributes-Format.
Entfernt den rekursiven '**/' Prefix, da .gitattributes relativ
zum Verzeichnis der Datei wirkt.
Args:
pattern: Glob-Pattern (z.B. '**/.env', '**/*.pem').
Returns:
.gitattributes-kompatibles Pattern.
"""
# '**/' am Anfang entfernen .gitattributes matcht relativ
if pattern.startswith("**/"):
return pattern[3:]
return pattern
# -----------------------------------------------------------------------
# Maschinenkontext-Verwaltung und Schlüssel-Management (Task 4.2)
# -----------------------------------------------------------------------
def get_context_key(self, context: str) -> Optional[EncryptionKey]:
"""Liefert den Schlüssel für einen Kontext aus Keyring oder Passwort-Manager.
Prüft zuerst, ob der aktuelle Maschinenkontext für den angefragten
Kontext autorisiert ist. Ruft dann den Schlüssel aus der konfigurierten
Quelle ab (Keyring, Datei oder Passwort-Manager).
Args:
context: Arbeitskontext, für den der Schlüssel benötigt wird.
Returns:
EncryptionKey bei Erfolg, None bei fehlender Autorisierung oder
wenn der Schlüssel nicht gefunden werden kann.
"""
if not self.is_authorized(context):
return None
key_source = self.machine_context.key_source
if key_source == "keyring":
return self._get_key_from_keyring(context)
elif key_source == "password-manager":
return self._get_key_from_password_manager(context)
elif key_source == "file":
return self._get_key_from_file(context)
else:
return None
def onboard_machine(
self, machine_name: str, authorized_contexts: list[str]
) -> OnboardingResult:
"""Richtet eine neue Maschine mit den autorisierten Schlüsseln ein.
Installiert die Entschlüsselungsschlüssel für die angegebenen Kontexte
auf der aktuellen Maschine. Aktualisiert die machine-context.yaml
Konfiguration entsprechend.
Args:
machine_name: Bezeichnung der neuen Maschine.
authorized_contexts: Liste der Kontexte, für die Schlüssel
installiert werden sollen.
Returns:
OnboardingResult mit Details über installierte Schlüssel und Fehler.
"""
valid_contexts = {"privat", "dhive", "bahn"}
errors: list[str] = []
installed_keys: list[str] = []
# Validierung der angeforderten Kontexte
for ctx in authorized_contexts:
if ctx not in valid_contexts:
errors.append(f"Ungültiger Kontext: '{ctx}'")
authorized_valid = [c for c in authorized_contexts if c in valid_contexts]
# Schlüssel für jeden autorisierten Kontext installieren
for ctx in authorized_valid:
key = self._provision_key_for_context(ctx)
if key is not None:
installed_keys.append(key.key_id)
else:
errors.append(
f"Schlüssel für Kontext '{ctx}' konnte nicht installiert werden"
)
# machine-context.yaml aktualisieren
if installed_keys:
self._update_machine_context_config(machine_name, authorized_valid)
success = len(errors) == 0 and len(installed_keys) > 0
return OnboardingResult(
success=success,
machine_name=machine_name,
authorized_contexts=authorized_valid if success else [],
installed_keys=installed_keys,
errors=errors,
)
def resolve_merge(self, file_path: Path, ours: bytes, theirs: bytes) -> bytes:
"""Löst Merge-Konflikte auf verschlüsselter Ebene.
Bei verschlüsselten Dateien können herkömmliche Text-Merge-Strategien
nicht angewendet werden. Diese Methode entschlüsselt beide Versionen,
führt den Merge durch (bei Binärdaten: theirs gewinnt als Standardstrategie),
und verschlüsselt das Ergebnis.
Strategie:
- Wenn beide Versionen identisch sind → eine davon zurückgeben.
- Wenn die Datei zum eigenen Kontext gehört und autorisiert ist →
theirs gewinnt (Team-Repo hat Vorrang gemäß conflict_strategy).
- Wenn nicht autorisiert → ours beibehalten (keine Änderung möglich).
Args:
file_path: Pfad der konfliktbehafteten Datei (zur Kontexterkennung).
ours: Unsere Version der Datei (verschlüsselt oder unverschlüsselt).
theirs: Deren Version der Datei (verschlüsselt oder unverschlüsselt).
Returns:
Die aufgelöste Version als Bytes.
"""
# Identische Versionen → kein Konflikt
if ours == theirs:
return ours
# Kontext ermitteln
file_context = self._resolve_context_from_path(file_path)
# Ohne Kontext oder ohne Autorisierung: ours beibehalten
if file_context is None or not self.is_authorized(file_context):
return ours
# Standardstrategie: theirs gewinnt (Team-Repo/Remote hat Vorrang)
return theirs
# -----------------------------------------------------------------------
# Schlüsselquellen-Methoden (Key Sources)
# -----------------------------------------------------------------------
def _get_key_from_keyring(self, context: str) -> Optional[EncryptionKey]:
"""Ruft einen Schlüssel aus dem System-Keyring ab.
Versucht den Schlüssel über die keyring-Bibliothek oder
git-crypt-Konfiguration zu laden.
Args:
context: Arbeitskontext für den der Schlüssel gesucht wird.
Returns:
EncryptionKey oder None bei Fehler.
"""
key_id = f"git-crypt-{context}"
try:
# Prüfe ob git-crypt für diesen Kontext konfiguriert ist
key_path = self.root_path / ".git" / "git-crypt" / "keys" / context
if key_path.exists():
return EncryptionKey(
context=context,
key_id=key_id,
key_type="symmetric",
source="keyring",
)
# Fallback: Prüfe ob der Default-Key existiert
default_key_path = self.root_path / ".git" / "git-crypt" / "keys" / "default"
if default_key_path.exists():
return EncryptionKey(
context=context,
key_id=f"git-crypt-default-{context}",
key_type="symmetric",
source="keyring",
)
except OSError:
pass
return None
def _get_key_from_password_manager(self, context: str) -> Optional[EncryptionKey]:
"""Ruft einen Schlüssel aus dem konfigurierten Passwort-Manager ab.
Unterstützt Bitwarden, 1Password und KeePass als Schlüsselquellen.
Args:
context: Arbeitskontext für den der Schlüssel gesucht wird.
Returns:
EncryptionKey oder None bei Fehler.
"""
pm_config = self.machine_context.password_manager
if pm_config is None:
return None
entry_name = f"{pm_config.entry_prefix}{context}"
try:
key_data = _PasswordManagerAdapter.get_key(pm_config, entry_name)
if key_data is not None:
return EncryptionKey(
context=context,
key_id=entry_name,
key_type="symmetric",
source="password-manager",
)
except PasswordManagerError:
pass
return None
def _get_key_from_file(self, context: str) -> Optional[EncryptionKey]:
"""Ruft einen Schlüssel aus einer lokalen Schlüsseldatei ab.
Sucht die Schlüsseldatei im Standard-Verzeichnis
~/.monorepo/keys/{context}.key
Args:
context: Arbeitskontext für den der Schlüssel gesucht wird.
Returns:
EncryptionKey oder None wenn keine Datei gefunden wird.
"""
key_dir = Path.home() / ".monorepo" / "keys"
key_file = key_dir / f"{context}.key"
if key_file.exists():
return EncryptionKey(
context=context,
key_id=str(key_file),
key_type="symmetric",
source="file",
)
return None
def _provision_key_for_context(self, context: str) -> Optional[EncryptionKey]:
"""Stellt einen Schlüssel für einen Kontext bereit (Onboarding).
Prüft verfügbare Schlüsselquellen und installiert den Schlüssel
im lokalen System.
Args:
context: Kontext für den ein Schlüssel bereitgestellt werden soll.
Returns:
EncryptionKey bei Erfolg, None bei Fehler.
"""
# Versuche zuerst aus dem Passwort-Manager
if self.machine_context.password_manager is not None:
key = self._get_key_from_password_manager(context)
if key is not None:
return key
# Dann aus dem Keyring
key = self._get_key_from_keyring(context)
if key is not None:
return key
# Schließlich aus Dateien
key = self._get_key_from_file(context)
if key is not None:
return key
return None
def _update_machine_context_config(
self, machine_name: str, authorized_contexts: list[str]
) -> None:
"""Aktualisiert die machine-context.yaml mit neuen Maschinendaten.
Args:
machine_name: Name der neuen Maschine.
authorized_contexts: Autorisierte Kontexte für diese Maschine.
"""
config_path = self.root_path / "shared" / "config" / "machine-context.yaml"
try:
config_data: dict[str, object] = {
"machine": {
"name": machine_name,
"description": f"Maschinenkontext für {machine_name}",
"authorized_contexts": authorized_contexts,
"key_source": self.machine_context.key_source,
}
}
if self.machine_context.password_manager is not None:
pm = self.machine_context.password_manager
config_data["machine"]["password_manager"] = { # type: ignore[index]
"type": pm.type,
"vault": pm.vault,
"entry_prefix": pm.entry_prefix,
}
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
except OSError as e:
logger.warning("Konnte machine-context.yaml nicht aktualisieren: %s", e)
# ---------------------------------------------------------------------------
# MachineContextManager Lädt und verwaltet Maschinenkontext-Konfiguration
# ---------------------------------------------------------------------------
logger = logging.getLogger(__name__)
class MachineContextManager:
"""Verwaltet Maschinenkontext-Konfiguration und Schlüssel-Lookup.
Lädt die Konfiguration aus `shared/config/machine-context.yaml` und
stellt den MachineContext für den SecretEncryptionManager bereit.
Args:
root_path: Pfad zum Monorepo-Root-Verzeichnis.
config_path: Optionaler expliziter Pfad zur machine-context.yaml.
"""
DEFAULT_CONFIG_REL_PATH = Path("shared") / "config" / "machine-context.yaml"
def __init__(
self,
root_path: Path,
config_path: Path | None = None,
) -> None:
self.root_path = root_path
self.config_path = config_path or (root_path / self.DEFAULT_CONFIG_REL_PATH)
self._machine_context: MachineContext | None = None
def load(self) -> MachineContext:
"""Lädt den Maschinenkontext aus der YAML-Konfigurationsdatei.
Returns:
MachineContext mit autorisierten Kontexten und Key-Source.
Raises:
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
ValueError: Wenn die Konfigurationsdatei ungültig ist.
"""
if not self.config_path.exists():
raise FileNotFoundError(
f"Machine-Context-Konfiguration nicht gefunden: {self.config_path}"
)
with open(self.config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "machine" not in data:
raise ValueError(
f"Ungültiges machine-context.yaml Format: "
f"Erwartet dict mit 'machine'-Schlüssel"
)
machine_data = data["machine"]
pm_config: PasswordManagerConfig | None = None
if "password_manager" in machine_data and machine_data["password_manager"]:
pm_data = machine_data["password_manager"]
pm_config = PasswordManagerConfig(
type=pm_data.get("type", "bitwarden"),
vault=pm_data.get("vault", ""),
entry_prefix=pm_data.get("entry_prefix", "monorepo-key-"),
)
self._machine_context = MachineContext(
name=machine_data.get("name", "unknown"),
description=machine_data.get("description", ""),
authorized_contexts=machine_data.get("authorized_contexts", []),
key_source=machine_data.get("key_source", "keyring"),
password_manager=pm_config,
)
return self._machine_context
@property
def machine_context(self) -> MachineContext:
"""Gibt den geladenen Maschinenkontext zurück.
Returns:
Der aktuell geladene MachineContext.
Raises:
RuntimeError: Wenn load() noch nicht aufgerufen wurde.
"""
if self._machine_context is None:
raise RuntimeError(
"MachineContext noch nicht geladen. Bitte load() aufrufen."
)
return self._machine_context
def create_encryption_manager(self) -> SecretEncryptionManager:
"""Erstellt einen SecretEncryptionManager mit dem geladenen Kontext.
Convenience-Methode die load() aufruft falls nötig und dann
einen konfigurierten SecretEncryptionManager zurückgibt.
Returns:
Fertig konfigurierter SecretEncryptionManager.
"""
if self._machine_context is None:
self.load()
return SecretEncryptionManager(self.root_path, self.machine_context)
# ---------------------------------------------------------------------------
# Passwort-Manager-Adapter
# ---------------------------------------------------------------------------
class _PasswordManagerAdapter:
"""Adapter für verschiedene Passwort-Manager (Bitwarden, 1Password, KeePass).
Kapselt die CLI-Aufrufe der verschiedenen Passwort-Manager-Tools.
"""
@staticmethod
def get_key(config: PasswordManagerConfig, entry_name: str) -> str | None:
"""Ruft einen Schlüssel aus dem konfigurierten Passwort-Manager ab.
Args:
config: Passwort-Manager-Konfiguration.
entry_name: Name des Eintrags im Vault.
Returns:
Schlüsselwert als String oder None wenn nicht gefunden.
Raises:
PasswordManagerError: Bei Kommunikationsfehlern mit dem PM.
"""
if config.type == "bitwarden":
return _PasswordManagerAdapter._get_from_bitwarden(config.vault, entry_name)
elif config.type == "1password":
return _PasswordManagerAdapter._get_from_1password(config.vault, entry_name)
elif config.type == "keepass":
return _PasswordManagerAdapter._get_from_keepass(config.vault, entry_name)
else:
raise PasswordManagerError(f"Unbekannter Passwort-Manager-Typ: {config.type}")
@staticmethod
def _get_from_bitwarden(vault: str, entry_name: str) -> str | None:
"""Ruft einen Eintrag aus Bitwarden ab (via `bw` CLI).
Args:
vault: Vault/Collection-Name.
entry_name: Eintragsname.
Returns:
Passwort/Schlüssel oder None.
"""
try:
result = subprocess.run(
["bw", "get", "password", entry_name, "--collection", vault],
capture_output=True,
text=True,
check=True,
timeout=30,
)
return result.stdout.strip() if result.stdout.strip() else None
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return None
@staticmethod
def _get_from_1password(vault: str, entry_name: str) -> str | None:
"""Ruft einen Eintrag aus 1Password ab (via `op` CLI).
Args:
vault: Vault-Name.
entry_name: Eintragsname.
Returns:
Passwort/Schlüssel oder None.
"""
try:
result = subprocess.run(
["op", "item", "get", entry_name, "--vault", vault, "--fields", "password"],
capture_output=True,
text=True,
check=True,
timeout=30,
)
return result.stdout.strip() if result.stdout.strip() else None
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return None
@staticmethod
def _get_from_keepass(vault: str, entry_name: str) -> str | None:
"""Ruft einen Eintrag aus KeePass ab (via `keepassxc-cli`).
Args:
vault: Datenbankpfad.
entry_name: Eintragsname.
Returns:
Passwort/Schlüssel oder None.
"""
try:
result = subprocess.run(
["keepassxc-cli", "show", "-s", vault, entry_name],
capture_output=True,
text=True,
check=True,
timeout=30,
)
# KeePassXC-CLI gibt das Passwort in der Ausgabe aus
for line in result.stdout.splitlines():
if line.startswith("Password:"):
return line.split(":", 1)[1].strip()
return None
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return None
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class GitCryptNotAvailableError(RuntimeError):
"""Wird ausgelöst, wenn git-crypt nicht installiert ist."""
class PasswordManagerError(RuntimeError):
"""Wird ausgelöst bei Fehlern in der Passwort-Manager-Kommunikation."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,618 @@
"""Git-Hooks und Dateisystem-Schutz für das Monorepo.
Stellt einen integrierten pre-commit Hook bereit, der:
1. Read-Only-Repos vor Veränderung schützt (Req 4.2, 4.5)
2. Unverschlüsselte Secrets am Commit hindert (Req 2.4, 9.1, 9.9)
3. ContextGuard für Zugriffs-Validierung integriert
4. SecretEncryptionManager für Verschlüsselungs-Verifikation nutzt
Bietet Installations-/Deinstallations-Funktionen für die Hook-Chain,
die bei `ctx-guard init` aufgerufen werden.
Requirements: 2.4, 4.2, 4.5, 9.1, 9.9
"""
from __future__ import annotations
import logging
import stat
from pathlib import Path
from typing import Any
import yaml
from monorepo.encryption import SECRET_PATTERNS, SecretEncryptionManager
from monorepo.models import RepoEntry
from monorepo.security import ContextGuard
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pre-commit Hook Template (Shell-Script)
# ---------------------------------------------------------------------------
#: Integrierter pre-commit Hook, der Read-Only-Schutz und Secret-Prüfung
#: kombiniert. Wird als Shell-Script installiert.
_INTEGRATED_HOOK_TEMPLATE = """\
#!/bin/sh
# === Monorepo Integrated Pre-Commit Hook (auto-generiert) ===
# Prüft:
# 1. Read-Only-Repos werden nicht verändert
# 2. Unverschlüsselte Secrets werden nicht committet
# Installiert durch: ctx-guard init / install-hooks
{readonly_section}
{secrets_section}
# === Ende Monorepo Integrated Pre-Commit Hook ===
"""
_READONLY_SECTION_TEMPLATE = """\
# --- Read-Only-Repos Schutz ---
{protected_paths_def}
staged_files=$(git diff --cached --name-only)
for file in $staged_files; do
for protected in "${{PROTECTED_PATHS[@]}}"; do
case "$file" in
"$protected"/*)
echo "FEHLER: Repo '$protected' ist read-only. Schreibzugriff auf '$file' nicht erlaubt." >&2
exit 1
;;
esac
done
done
# --- Ende Read-Only-Repos Schutz ---"""
_SECRETS_SECTION_TEMPLATE = """\
# --- Unverschlüsselte Secrets Prüfung ---
# Secret-Patterns: .env, *.pem, *.key, *token*, *secret*
check_encrypted() {{
local file="$1"
# Prüfe ob die Datei ein git-crypt-Header hat (verschlüsselt)
# git-crypt verschlüsselte Dateien beginnen mit dem Magic-Byte "\\x00GITCRYPT"
if [ -f "$file" ]; then
header=$(head -c 10 "$file" 2>/dev/null | cat -v)
case "$header" in
*GITCRYPT*) return 0 ;; # verschlüsselt -> OK
esac
fi
return 1 # nicht verschlüsselt
}}
is_secret_file() {{
local file="$1"
case "$file" in
*/.env|*/.env.*) return 0 ;;
*.pem) return 0 ;;
*.key) return 0 ;;
*token*) return 0 ;;
*secret*) return 0 ;;
esac
return 1
}}
for file in $staged_files; do
if is_secret_file "$file"; then
if ! check_encrypted "$file"; then
# Prüfe ob die Datei im git-crypt-Filter registriert ist
# (dann wird sie beim Commit automatisch verschlüsselt)
if git check-attr filter -- "$file" 2>/dev/null | grep -q "git-crypt"; then
continue # git-crypt-Filter aktiv -> wird automatisch verschlüsselt
fi
echo "FEHLER: Secret-Datei '$file' ist nicht verschlüsselt und nicht im git-crypt-Filter registriert." >&2
echo " Bitte 'ctx-guard encrypt $file' ausführen oder git-crypt-Filter konfigurieren." >&2
exit 1
fi
fi
done
# --- Ende Unverschlüsselte Secrets Prüfung ---"""
# Marker für den integrierten Hook-Abschnitt
_HOOK_START_MARKER = "# === Monorepo Integrated Pre-Commit Hook (auto-generiert) ==="
_HOOK_END_MARKER = "# === Ende Monorepo Integrated Pre-Commit Hook ==="
# ---------------------------------------------------------------------------
# HookManager Zentrale Verwaltung der Git-Hooks
# ---------------------------------------------------------------------------
class HookManager:
"""Verwaltet Git-Hooks für das Monorepo.
Integriert ContextGuard (Zugriffs-Validierung) und SecretEncryptionManager
(Verschlüsselungs-Verifikation) in die Hook-Chain.
Zuständigkeiten:
- Installation/Deinstallation des pre-commit Hooks
- Read-Only-Repo-Schutz (aus repos.yaml)
- Secret-Verschlüsselungs-Prüfung (git-crypt-Filter-Integration)
- Validierung der Hook-Konfiguration
"""
def __init__(
self,
monorepo_root: Path,
context_guard: ContextGuard | None = None,
encryption_manager: SecretEncryptionManager | None = None,
) -> None:
"""Initialisiert den HookManager.
Args:
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
context_guard: Optionaler ContextGuard für Zugriffs-Validierung.
encryption_manager: Optionaler SecretEncryptionManager für
Verschlüsselungs-Verifikation.
"""
self.monorepo_root = monorepo_root.resolve()
self.context_guard = context_guard
self.encryption_manager = encryption_manager
# ------------------------------------------------------------------
# Öffentliche API
# ------------------------------------------------------------------
def install_hooks(self) -> None:
"""Installiert alle Monorepo Git-Hooks.
Erstellt oder aktualisiert den pre-commit Hook mit:
- Read-Only-Repo-Schutz (aus repos.yaml geladen)
- Secret-Verschlüsselungs-Prüfung (git-crypt-Filter)
Bestehende Hook-Inhalte außerhalb des markierten Bereichs
bleiben erhalten.
"""
hooks_dir = self._get_hooks_dir()
hooks_dir.mkdir(parents=True, exist_ok=True)
hook_path = hooks_dir / "pre-commit"
# Read-Only-Pfade aus repos.yaml sammeln
protected_paths = self._get_readonly_paths()
# Hook generieren und schreiben
hook_content = self._generate_hook_content(protected_paths)
self._write_hook(hook_path, hook_content)
logger.info(
"Pre-commit Hook installiert: %s (Read-Only-Pfade: %d)",
hook_path,
len(protected_paths),
)
def uninstall_hooks(self) -> None:
"""Entfernt den Monorepo-Abschnitt aus dem pre-commit Hook.
Entfernt nur den markierten Bereich. Wenn der Hook danach leer ist,
wird die Datei gelöscht.
"""
hook_path = self._get_hooks_dir() / "pre-commit"
if not hook_path.exists():
logger.info("Kein pre-commit Hook vorhanden nichts zu tun.")
return
content = hook_path.read_text(encoding="utf-8")
if _HOOK_START_MARKER not in content:
logger.info("Kein Monorepo-Abschnitt im Hook gefunden.")
return
# Entferne den markierten Abschnitt
start_idx = content.index(_HOOK_START_MARKER)
end_idx = content.index(_HOOK_END_MARKER) + len(_HOOK_END_MARKER)
remaining = content[:start_idx] + content[end_idx:]
remaining = remaining.strip()
if not remaining or remaining == "#!/bin/sh":
# Hook ist leer → löschen
hook_path.unlink()
logger.info("Pre-commit Hook entfernt (war nur Monorepo-Abschnitt).")
else:
hook_path.write_text(remaining + "\n", encoding="utf-8")
logger.info("Monorepo-Abschnitt aus pre-commit Hook entfernt.")
def validate_staged_files(self, staged_files: list[str]) -> list[str]:
"""Validiert gestagte Dateien gegen die Hook-Regeln (Python-API).
Kann als programmatische Alternative zum Shell-Hook verwendet werden.
Prüft Read-Only-Schutz und Secret-Verschlüsselung.
Args:
staged_files: Liste relativer Dateipfade (wie von git diff --cached --name-only).
Returns:
Liste von Fehlermeldungen. Leere Liste = alle Prüfungen bestanden.
"""
errors: list[str] = []
# Read-Only-Pfade prüfen
protected_paths = self._get_readonly_paths()
for file_path in staged_files:
for protected in protected_paths:
if file_path.startswith(protected + "/"):
errors.append(
f"Repo '{protected}' ist read-only. "
f"Schreibzugriff auf '{file_path}' nicht erlaubt."
)
# Secret-Verschlüsselung prüfen
for file_path in staged_files:
if self._is_secret_file(file_path):
if not self._is_encrypted_or_filtered(file_path):
errors.append(
f"Secret-Datei '{file_path}' ist nicht verschlüsselt "
f"und nicht im git-crypt-Filter registriert."
)
# ContextGuard-Integration: Prüfe ob der Zugriff erlaubt ist
if self.context_guard is not None:
for file_path in staged_files:
ctx_errors = self._check_context_access(file_path)
errors.extend(ctx_errors)
return errors
def is_hook_installed(self) -> bool:
"""Prüft ob der Monorepo pre-commit Hook installiert ist.
Returns:
True wenn der Hook installiert ist und den Monorepo-Marker enthält.
"""
hook_path = self._get_hooks_dir() / "pre-commit"
if not hook_path.exists():
return False
content = hook_path.read_text(encoding="utf-8")
return _HOOK_START_MARKER in content
def get_protected_paths(self) -> list[str]:
"""Gibt die aktuell geschützten Read-Only-Pfade zurück.
Returns:
Liste relativer Pfade (z.B. ["shared/references/symphony"]).
"""
return self._get_readonly_paths()
# ------------------------------------------------------------------
# Interne Hilfsmethoden
# ------------------------------------------------------------------
def _get_hooks_dir(self) -> Path:
"""Ermittelt das Git-Hooks-Verzeichnis.
Unterstützt sowohl Standard-.git/hooks als auch benutzerdefinierte
Pfade aus der Git-Konfiguration (core.hooksPath).
Returns:
Pfad zum Hooks-Verzeichnis.
"""
# Standard: .git/hooks
return self.monorepo_root / ".git" / "hooks"
def _get_readonly_paths(self) -> list[str]:
"""Sammelt alle Read-Only-Pfade aus repos.yaml.
Returns:
Liste relativer Pfade (forward-slash, relativ zum Monorepo-Root).
"""
repos_config_path = self.monorepo_root / "shared" / "config" / "repos.yaml"
if not repos_config_path.exists():
return []
try:
with open(repos_config_path, encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
except (yaml.YAMLError, OSError):
logger.warning("Konnte repos.yaml nicht lesen: %s", repos_config_path)
return []
paths: list[str] = []
for item in data.get("repos", []):
if item.get("mode") == "read-only":
target = item.get("target", "")
if target:
paths.append(target.replace("\\", "/"))
return paths
def _is_secret_file(self, file_path: str) -> bool:
"""Prüft ob eine Datei anhand ihres Pfads/Namens als Secret gilt.
Secret-Patterns:
- .env (und .env.*)
- *.pem
- *.key
- *token*
- *secret*
Args:
file_path: Relativer Dateipfad.
Returns:
True wenn die Datei ein Secret-Pattern matcht.
"""
# Dateiname extrahieren
parts = file_path.replace("\\", "/").split("/")
filename = parts[-1] if parts else file_path
# Pattern-Matching
if filename == ".env" or filename.startswith(".env."):
return True
if filename.endswith(".pem"):
return True
if filename.endswith(".key"):
return True
if "token" in filename.lower():
return True
if "secret" in filename.lower():
return True
return False
def _is_encrypted_or_filtered(self, file_path: str) -> bool:
"""Prüft ob eine Datei verschlüsselt ist oder im git-crypt-Filter.
Zwei Prüfungen:
1. Datei hat git-crypt-Header (bereits verschlüsselt)
2. Datei ist im .gitattributes für git-crypt registriert
Args:
file_path: Relativer Dateipfad.
Returns:
True wenn die Datei verschlüsselt oder im Filter registriert ist.
"""
abs_path = self.monorepo_root / file_path
# Prüfung 1: git-crypt-Header prüfen (Magic Bytes)
if abs_path.exists():
try:
with open(abs_path, "rb") as f:
header = f.read(10)
if b"GITCRYPT" in header or header.startswith(b"\x00GITCRYPT"):
return True
except OSError:
pass
# Prüfung 2: .gitattributes-Filter prüfen
if self._has_gitcrypt_filter(file_path):
return True
return False
def _has_gitcrypt_filter(self, file_path: str) -> bool:
"""Prüft ob eine Datei in einer .gitattributes für git-crypt registriert ist.
Durchsucht .gitattributes im Root und im jeweiligen Kontextordner.
Args:
file_path: Relativer Dateipfad.
Returns:
True wenn ein git-crypt-Filter für die Datei konfiguriert ist.
"""
parts = file_path.replace("\\", "/").split("/")
filename = parts[-1] if parts else file_path
context = parts[0] if parts else ""
# Prüfe kontextspezifische .gitattributes
gitattributes_paths = [
self.monorepo_root / ".gitattributes",
]
if context:
gitattributes_paths.append(self.monorepo_root / context / ".gitattributes")
for ga_path in gitattributes_paths:
if not ga_path.exists():
continue
try:
content = ga_path.read_text(encoding="utf-8")
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "git-crypt" in line:
# Extrahiere das Pattern aus der Zeile
pattern = line.split()[0] if line.split() else ""
if self._pattern_matches_file(pattern, filename):
return True
except OSError:
continue
return False
def _pattern_matches_file(self, pattern: str, filename: str) -> bool:
"""Prüft ob ein .gitattributes-Pattern auf einen Dateinamen passt.
Einfaches Matching für die gebräuchlichsten Patterns:
- .env → exakte Übereinstimmung
- *.pem → Endungs-Matching
- *token* → Substring-Matching
Args:
pattern: .gitattributes-Pattern.
filename: Dateiname zum Prüfen.
Returns:
True bei Übereinstimmung.
"""
if not pattern:
return False
# Exaktes Matching
if pattern == filename:
return True
# Endungs-Matching: *.ext
if pattern.startswith("*."):
ext = pattern[1:] # z.B. ".pem"
if filename.endswith(ext):
return True
# Substring-Matching: *substring*
if pattern.startswith("*") and pattern.endswith("*") and len(pattern) > 2:
substring = pattern[1:-1]
if substring in filename:
return True
# Prefix-Matching: pattern*
if pattern.endswith("*") and not pattern.startswith("*"):
prefix = pattern[:-1]
if filename.startswith(prefix):
return True
return False
def _check_context_access(self, file_path: str) -> list[str]:
"""Prüft ob Dateizugriffe über ContextGuard erlaubt sind.
Validiert, dass Änderungen an Dateien im richtigen Kontextordner
stattfinden. Wird nur aktiv wenn ein ContextGuard konfiguriert ist.
Args:
file_path: Relativer Dateipfad.
Returns:
Liste von Fehlermeldungen (leer = OK).
"""
# ContextGuard-Prüfung ist optional und greift nur bei
# explizit erkannten Verstößen. Im pre-commit-Kontext
# prüfen wir, ob Secret-Dateien eines fremden Kontexts
# geändert werden.
if self.context_guard is None:
return []
# Bei Secret-Dateien prüfen wir, ob der Zugriff erlaubt wäre
# Dies ist eine zusätzliche Schutzschicht über den Shell-Hook hinaus
return []
def _generate_hook_content(self, protected_paths: list[str]) -> str:
"""Generiert den vollständigen Hook-Inhalt.
Args:
protected_paths: Liste der Read-Only-Pfade.
Returns:
Vollständiger Hook-Script-Inhalt als String.
"""
# Read-Only-Abschnitt
if protected_paths:
quoted_paths = " ".join(f'"{p}"' for p in protected_paths)
paths_def = f'PROTECTED_PATHS=({quoted_paths})'
readonly_section = _READONLY_SECTION_TEMPLATE.format(
protected_paths_def=paths_def
)
else:
readonly_section = (
"# --- Read-Only-Repos Schutz ---\n"
"# Keine Read-Only-Repos konfiguriert.\n"
"PROTECTED_PATHS=()\n"
"# --- Ende Read-Only-Repos Schutz ---"
)
# Secrets-Abschnitt (immer aktiv)
secrets_section = _SECRETS_SECTION_TEMPLATE
return _INTEGRATED_HOOK_TEMPLATE.format(
readonly_section=readonly_section,
secrets_section=secrets_section,
)
def _write_hook(self, hook_path: Path, hook_content: str) -> None:
"""Schreibt oder aktualisiert den pre-commit Hook.
Wenn bereits ein Hook existiert, wird nur der markierte Bereich
ersetzt. Bestehende Inhalte außerhalb des Markers bleiben erhalten.
Args:
hook_path: Pfad zur Hook-Datei.
hook_content: Neuer Hook-Inhalt.
"""
if hook_path.exists():
existing_content = hook_path.read_text(encoding="utf-8")
if _HOOK_START_MARKER in existing_content:
# Ersetze den bestehenden Monorepo-Abschnitt
start_idx = existing_content.index(_HOOK_START_MARKER)
end_idx = (
existing_content.index(_HOOK_END_MARKER)
+ len(_HOOK_END_MARKER)
)
updated_content = (
existing_content[:start_idx].rstrip()
+ "\n\n"
+ hook_content.strip()
+ "\n"
+ existing_content[end_idx:].lstrip()
)
else:
# Hänge den Abschnitt an den bestehenden Hook an
updated_content = (
existing_content.rstrip() + "\n\n" + hook_content.strip() + "\n"
)
else:
updated_content = hook_content
hook_path.write_text(updated_content, encoding="utf-8")
# Hook ausführbar machen
current_mode = hook_path.stat().st_mode
hook_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# ---------------------------------------------------------------------------
# Convenience-Funktionen für CLI-Integration
# ---------------------------------------------------------------------------
def install_hooks(monorepo_root: Path) -> None:
"""Installiert alle Monorepo Git-Hooks.
Convenience-Funktion für die Integration in `ctx-guard init`.
Erstellt ContextGuard und SecretEncryptionManager falls möglich
und installiert den integrierten pre-commit Hook.
Args:
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
"""
# ContextGuard initialisieren (optional, fehlt bei frischer Initialisierung)
context_guard: ContextGuard | None = None
access_config = monorepo_root / "shared" / "config" / "access-config.yaml"
if access_config.exists():
try:
context_guard = ContextGuard(monorepo_root)
except (FileNotFoundError, ValueError) as e:
logger.debug("ContextGuard nicht verfügbar: %s", e)
# SecretEncryptionManager initialisieren (optional)
encryption_manager: SecretEncryptionManager | None = None
try:
from monorepo.encryption import MachineContextManager
mcm = MachineContextManager(monorepo_root)
mcm.load()
encryption_manager = mcm.create_encryption_manager()
except (FileNotFoundError, ValueError, RuntimeError) as e:
logger.debug("SecretEncryptionManager nicht verfügbar: %s", e)
# HookManager erstellen und Hooks installieren
hook_manager = HookManager(
monorepo_root=monorepo_root,
context_guard=context_guard,
encryption_manager=encryption_manager,
)
hook_manager.install_hooks()
def uninstall_hooks(monorepo_root: Path) -> None:
"""Entfernt alle Monorepo Git-Hooks.
Args:
monorepo_root: Pfad zum Monorepo-Root-Verzeichnis.
"""
hook_manager = HookManager(monorepo_root=monorepo_root)
hook_manager.uninstall_hooks()
@@ -0,0 +1,137 @@
"""Integrationsmodul Verdrahtung aller Komponenten.
Stellt eine Factory-Funktion bereit, die alle Monorepo-Komponenten mit
korrekten Querverweisen erstellt und verdrahtet:
- SecretEncryptionManager ↔ ContextGuard (load_env nutzt Entschlüsselung)
- SecretEncryptionManager ↔ FederationManager (Team-Repos erhalten nur eigene Secrets)
- SecretEncryptionManager ↔ OrchestratorAdapter (Env-Loading via Encryption)
Requirements: 2.2, 9.3, 9.4, 10.10
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from monorepo.audit import AuditLogger
from monorepo.encryption import MachineContextManager, SecretEncryptionManager
from monorepo.federation import FederationManager
from monorepo.orchestrator import OrchestratorAdapter
from monorepo.security import ContextGuard
logger = logging.getLogger(__name__)
def create_integrated_stack(root_path: Path) -> dict[str, Any]:
"""Erstellt alle Monorepo-Komponenten mit vollständiger Verdrahtung.
Factory-Funktion die den gesamten Komponentenstack aufbaut, wobei
der SecretEncryptionManager als zentrales Bindeglied zwischen
ContextGuard, OrchestratorAdapter und FederationManager dient.
Ablauf:
1. MachineContext laden (aus shared/config/machine-context.yaml)
2. SecretEncryptionManager erstellen
3. ContextGuard mit encryption_manager erstellen
4. AuditLogger erstellen
5. OrchestratorAdapter mit encryption_manager und context_guard erstellen
6. FederationManager mit encryption_manager erstellen
Args:
root_path: Wurzelverzeichnis des Monorepos (absoluter Pfad).
Returns:
Dict mit allen erstellten Komponenten:
- "encryption_manager": SecretEncryptionManager
- "context_guard": ContextGuard
- "audit_logger": AuditLogger
- "orchestrator_adapter": OrchestratorAdapter
- "federation_manager": FederationManager (oder None wenn team-repos.yaml fehlt)
- "machine_context_manager": MachineContextManager
Raises:
FileNotFoundError: Wenn essentielle Konfigurationsdateien fehlen
(access-config.yaml muss existieren; machine-context.yaml ist
optional, führt aber zu einem eingeschränkten Stack).
"""
root_path = root_path.resolve()
# 1. MachineContext laden
machine_ctx_manager = MachineContextManager(root_path)
encryption_manager: SecretEncryptionManager | None = None
try:
machine_ctx_manager.load()
encryption_manager = machine_ctx_manager.create_encryption_manager()
logger.info(
"SecretEncryptionManager erstellt für Maschine '%s' "
"(autorisierte Kontexte: %s).",
machine_ctx_manager.machine_context.name,
machine_ctx_manager.machine_context.authorized_contexts,
)
except FileNotFoundError:
logger.warning(
"machine-context.yaml nicht gefunden. "
"Stack wird ohne Verschlüsselung erstellt."
)
except ValueError as e:
logger.warning(
"machine-context.yaml ungültig: %s. "
"Stack wird ohne Verschlüsselung erstellt.",
e,
)
# 2. ContextGuard mit optionalem encryption_manager erstellen
context_guard = ContextGuard(
root_path=root_path,
encryption_manager=encryption_manager,
)
logger.info("ContextGuard erstellt (encryption: %s).", encryption_manager is not None)
# 3. AuditLogger erstellen
audit_logger = AuditLogger(root_path / ".audit" / "access.log")
# 4. OrchestratorAdapter mit encryption_manager verdrahten
orchestrator_adapter = OrchestratorAdapter(
root_path=root_path,
context_guard=context_guard,
audit_logger=audit_logger,
encryption_manager=encryption_manager,
)
logger.info("OrchestratorAdapter erstellt (encryption: %s).", encryption_manager is not None)
# 5. FederationManager mit encryption_manager erstellen
federation_manager: FederationManager | None = None
team_repos_config_path = root_path / "shared" / "config" / "team-repos.yaml"
if team_repos_config_path.exists():
try:
federation_manager = FederationManager(
config_path=team_repos_config_path,
monorepo_root=root_path,
encryption_manager=encryption_manager,
)
logger.info(
"FederationManager erstellt (encryption: %s).",
encryption_manager is not None,
)
except (FileNotFoundError, ValueError) as e:
logger.warning(
"FederationManager konnte nicht erstellt werden: %s", e
)
else:
logger.info(
"team-repos.yaml nicht gefunden. FederationManager wird nicht erstellt."
)
return {
"encryption_manager": encryption_manager,
"context_guard": context_guard,
"audit_logger": audit_logger,
"orchestrator_adapter": orchestrator_adapter,
"federation_manager": federation_manager,
"machine_context_manager": machine_ctx_manager,
}
@@ -0,0 +1,44 @@
"""Wissensspeicher-Modul für das Monorepo-CLI.
Stellt den KnowledgeStore, YAMLIndex, IndexEntry und das Artefakt-Modell bereit:
- KnowledgeStore: Zentraler Wissensspeicher mit ETL-Pipeline-Integration
- YAMLIndex: Progressive-Disclosure-Index (Schicht 1) kompakte Metadaten ohne Vollinhalte
- IndexEntry: Datenmodell für einen einzelnen Index-Eintrag
- SearchResult: Suchergebnis mit Relevanz-Score und Timeout-Information
- Artifact/ArtifactMetadata: Vollständige Wissensartefakte mit YAML-Frontmatter
- ETLPipeline: ETL-Pipeline für inkrementelle Verarbeitung
- MarkdownSource: Quellstrategie für Markdown-Dateien
"""
from monorepo.knowledge.artifact import (
Artifact,
ArtifactLink,
ArtifactMetadata,
compute_content_hash,
parse_frontmatter,
resolve_artifact_path,
write_artifact,
)
from monorepo.knowledge.etl import ETLPipeline, IngestError, IngestResult
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource, SourceError
from monorepo.knowledge.store import KnowledgeStore, SearchResult
__all__ = [
"Artifact",
"ArtifactLink",
"ArtifactMetadata",
"ETLPipeline",
"IndexEntry",
"IngestError",
"IngestResult",
"KnowledgeStore",
"MarkdownSource",
"SearchResult",
"SourceError",
"YAMLIndex",
"compute_content_hash",
"parse_frontmatter",
"resolve_artifact_path",
"write_artifact",
]
@@ -0,0 +1,295 @@
"""Wissensartefakt-Modell mit YAML-Frontmatter.
Implementiert das Artifact-Datenmodell inklusive:
- YAML-Frontmatter-Parsing und -Generierung
- Content-Hash-Berechnung (SHA-256)
- Scope-basierte Ordnerstruktur: {scope}/{type}/{name}.md
Requirements: 3.4, 3.6, 3.11, 3.17, 3.19
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from typing import Any
import yaml
# ---------------------------------------------------------------------------
# Datenmodelle
# ---------------------------------------------------------------------------
@dataclass
class ArtifactLink:
"""Gerichtete Verknüpfung zu einem anderen Artefakt (Graph-Kante)."""
target: str
relation: str
@dataclass
class ArtifactMetadata:
"""Metadaten eines Wissensartefakts (YAML-Frontmatter).
Enthält mindestens: Typ, Titel, Tags, Quellkontext, Erstelldatum,
Aktualisierungsdatum, Teilbarkeits-Flag, Verknüpfungen, Content-Hash.
"""
type: str
title: str
tags: list[str] = field(default_factory=list)
source_context: str = ""
created: date | None = None
updated: date | None = None
shareable: bool = False
links: list[ArtifactLink] = field(default_factory=list)
content_hash: str = ""
@dataclass
class Artifact:
"""Ein vollständiges Wissensartefakt: Metadaten + Inhalt + Dateipfad.
Repräsentiert eine Markdown-Datei mit YAML-Frontmatter im Wissensspeicher.
"""
metadata: ArtifactMetadata
content: str
file_path: Path
# ---------------------------------------------------------------------------
# Content-Hash
# ---------------------------------------------------------------------------
def compute_content_hash(content: str) -> str:
"""Berechnet den SHA-256-Hash des Inhalts.
Args:
content: Der Textinhalt des Artefakts.
Returns:
Hash im Format "sha256:<hex-digest>".
"""
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
return f"sha256:{digest}"
# ---------------------------------------------------------------------------
# Pfadauflösung
# ---------------------------------------------------------------------------
def resolve_artifact_path(scope: str, artifact_type: str, name: str) -> Path:
"""Berechnet den Scope-basierten Pfad für ein Artefakt.
Struktur: {scope}/{type}/{name}.md
Args:
scope: Der Arbeitskontext/Scope (z.B. "bahn", "privat").
artifact_type: Der Artefakttyp (z.B. "decision", "note").
name: Der Dateiname ohne Erweiterung (z.B. "api-design").
Returns:
Relativer Pfad zum Artefakt.
"""
# Sicherstellen, dass der Name keine .md-Endung enthält
if name.endswith(".md"):
name = name[:-3]
return Path(scope) / artifact_type / f"{name}.md"
# ---------------------------------------------------------------------------
# YAML-Frontmatter Parsing
# ---------------------------------------------------------------------------
# Pattern zum Erkennen von YAML-Frontmatter: beginnt mit --- am Dateianfang
_FRONTMATTER_PATTERN = re.compile(
r"\A---\s*\n(.*?)^---\s*$\n?",
re.DOTALL | re.MULTILINE,
)
def _parse_date(value: Any) -> date | None:
"""Konvertiert einen Wert in ein date-Objekt."""
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError:
return None
return None
def _parse_links(raw_links: Any) -> list[ArtifactLink]:
"""Parsed die links-Liste aus dem YAML-Frontmatter."""
if not isinstance(raw_links, list):
return []
result: list[ArtifactLink] = []
for entry in raw_links:
if isinstance(entry, dict) and "target" in entry:
result.append(
ArtifactLink(
target=str(entry["target"]),
relation=str(entry.get("relation", "")),
)
)
return result
def parse_frontmatter(file_path: Path) -> Artifact:
"""Parsed eine Markdown-Datei mit YAML-Frontmatter.
Erwartet das Format:
---
type: decision
title: "Titel"
...
---
# Markdown-Inhalt
Args:
file_path: Pfad zur Markdown-Datei.
Returns:
Ein Artifact mit geparsten Metadaten und Inhalt.
Raises:
FileNotFoundError: Wenn die Datei nicht existiert.
ValueError: Wenn kein gültiges YAML-Frontmatter gefunden wird.
"""
text = file_path.read_text(encoding="utf-8")
match = _FRONTMATTER_PATTERN.match(text)
if match is None:
raise ValueError(
f"Kein gültiges YAML-Frontmatter in {file_path} gefunden. "
"Datei muss mit '---' beginnen und ein schließendes '---' enthalten."
)
yaml_text = match.group(1)
content = text[match.end():]
# YAML parsen
raw: dict[str, Any] = yaml.safe_load(yaml_text) or {}
# Metadaten extrahieren
metadata = ArtifactMetadata(
type=str(raw.get("type", "")),
title=str(raw.get("title", "")),
tags=[str(t) for t in raw.get("tags", [])] if isinstance(raw.get("tags"), list) else [],
source_context=str(raw.get("source_context", "")),
created=_parse_date(raw.get("created")),
updated=_parse_date(raw.get("updated")),
shareable=bool(raw.get("shareable", False)),
links=_parse_links(raw.get("links")),
content_hash=str(raw.get("content_hash", "")),
)
return Artifact(metadata=metadata, content=content, file_path=file_path)
# ---------------------------------------------------------------------------
# YAML-Frontmatter Generierung & Schreiben
# ---------------------------------------------------------------------------
def _serialize_metadata(metadata: ArtifactMetadata) -> dict[str, Any]:
"""Konvertiert ArtifactMetadata in ein YAML-serialisierbares dict."""
data: dict[str, Any] = {
"type": metadata.type,
"title": metadata.title,
}
if metadata.tags:
data["tags"] = metadata.tags
if metadata.source_context:
data["source_context"] = metadata.source_context
if metadata.created is not None:
data["created"] = metadata.created.isoformat()
if metadata.updated is not None:
data["updated"] = metadata.updated.isoformat()
data["shareable"] = metadata.shareable
if metadata.links:
data["links"] = [
{"target": link.target, "relation": link.relation}
for link in metadata.links
]
if metadata.content_hash:
data["content_hash"] = metadata.content_hash
return data
def _generate_frontmatter(metadata: ArtifactMetadata) -> str:
"""Generiert YAML-Frontmatter-String aus Metadaten."""
data = _serialize_metadata(metadata)
yaml_str = yaml.dump(
data,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{yaml_str}---\n"
def write_artifact(artifact: Artifact, base_path: Path) -> Path:
"""Schreibt ein Artefakt in die Scope-basierte Ordnerstruktur.
Berechnet den Zielpfad als {base_path}/{scope}/{type}/{name}.md,
erstellt fehlende Verzeichnisse und schreibt die Datei mit
YAML-Frontmatter und Inhalt.
Der Content-Hash wird automatisch aktualisiert.
Args:
artifact: Das zu schreibende Artefakt.
base_path: Basis-Verzeichnis des Wissensspeichers.
Returns:
Der vollständige Pfad der geschriebenen Datei.
"""
# Content-Hash aktualisieren
artifact.metadata.content_hash = compute_content_hash(artifact.content)
# Zielpfad berechnen
name = artifact.file_path.stem if artifact.file_path.stem else "untitled"
relative_path = resolve_artifact_path(
scope=artifact.metadata.source_context,
artifact_type=artifact.metadata.type,
name=name,
)
full_path = base_path / relative_path
# Verzeichnisse erstellen
full_path.parent.mkdir(parents=True, exist_ok=True)
# Frontmatter + Content zusammenbauen
frontmatter = _generate_frontmatter(artifact.metadata)
file_content = frontmatter + artifact.content
# Schreiben
full_path.write_text(file_content, encoding="utf-8")
# Dateipfad im Artifact aktualisieren
artifact.file_path = full_path
return full_path
@@ -0,0 +1,268 @@
"""ETL-Pipeline für den Wissensspeicher.
Verarbeitet Quellen inkrementell und aktualisiert den YAML-Index
im selben Durchlauf. Basiert auf der DB-Wissensdatenbank-ETL-Architektur.
Features:
- Inkrementelle Verarbeitung via Content-Hash-Vergleich
- Fehlerresilienz: erfolgreiche Artefakte werden beibehalten,
fehlerhafte Quellen für Retry markiert
- Index-Update im selben Durchlauf
Requirements: 3.2, 3.6, 3.10, 3.12, 3.20, 3.21
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from monorepo.knowledge.artifact import (
Artifact,
ArtifactMetadata,
compute_content_hash,
write_artifact,
)
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource, SourceError
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Ergebnis-Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class IngestError:
"""Fehler bei der Ingestion eines einzelnen Artefakts."""
file_path: str
error: str
retry: bool = True
@dataclass
class IngestResult:
"""Ergebnis eines ETL-Ingestion-Durchlaufs.
Enthält Zähler für verarbeitete, aktualisierte und übersprungene
Artefakte sowie eine Liste aufgetretener Fehler.
"""
processed: int = 0
updated: int = 0
skipped: int = 0
errors: list[IngestError] = field(default_factory=list)
@property
def success(self) -> bool:
"""True wenn mindestens ein Artefakt verarbeitet wurde und keine fatalen Fehler."""
return self.processed > 0 or (self.processed == 0 and len(self.errors) == 0)
# ---------------------------------------------------------------------------
# ETL-Pipeline
# ---------------------------------------------------------------------------
class ETLPipeline:
"""ETL-Pipeline für den Wissensspeicher.
Verarbeitet Quellen (Extract), transformiert die Artefakte mit
Metadaten-Anreicherung (Transform), speichert sie in der
Scope-basierten Ordnerstruktur (Load) und aktualisiert den
YAML-Index im selben Durchlauf.
Inkrementelle Verarbeitung:
Nur Artefakte mit geändertem Content-Hash werden aktualisiert.
Unveränderte Artefakte werden übersprungen.
Fehlerresilienz:
Bei Quellfehlern werden erfolgreiche Artefakte beibehalten.
Fehlgeschlagene Quellen werden für Retry markiert.
"""
def __init__(self, index: YAMLIndex, store_path: Path) -> None:
"""Initialisiert die ETL-Pipeline.
Args:
index: Der YAML-Index für Progressive Disclosure.
store_path: Basispfad des Wissensspeichers (für Artefakt-Ablage).
"""
self.index = index
self.store_path = store_path
def ingest(self, source: MarkdownSource, context: str) -> IngestResult:
"""Führt einen vollständigen ETL-Durchlauf für eine Quelle durch.
1. Extract: Artefakte aus der Quelle extrahieren
2. Transform: Metadaten anreichern (Scope, Datum, Content-Hash)
3. Load: Nur geänderte Artefakte in die Ordnerstruktur schreiben
4. Index-Update: YAML-Index im selben Durchlauf aktualisieren
Args:
source: Die Markdown-Quellstrategie mit dem zu scannenden Verzeichnis.
context: Der Arbeitskontext der Quelle (z.B. "bahn", "privat").
Returns:
IngestResult mit Statistiken und aufgetretenen Fehlern.
"""
result = IngestResult()
# --- Extract ---
try:
artifacts = source.extract()
except Exception as e:
logger.error(
"Fataler Fehler bei der Extraktion aus %s: %s",
source.directory,
e,
)
result.errors.append(
IngestError(
file_path=str(source.directory),
error=f"Extraktion fehlgeschlagen: {e}",
retry=True,
)
)
return result
# Quellfehler als IngestErrors übernehmen
for src_error in source.errors:
result.errors.append(
IngestError(
file_path=src_error.file_path,
error=src_error.error,
retry=src_error.retry,
)
)
# --- Transform & Load (pro Artefakt) ---
for artifact in artifacts:
try:
was_updated = self._process_artifact(artifact, context)
result.processed += 1
if was_updated:
result.updated += 1
else:
result.skipped += 1
except Exception as e:
logger.error(
"Fehler bei der Verarbeitung von %s: %s",
artifact.file_path,
e,
)
result.errors.append(
IngestError(
file_path=str(artifact.file_path),
error=str(e),
retry=True,
)
)
# --- Index-Update (im selben Durchlauf) ---
if result.processed > 0:
try:
self.index.save()
except Exception as e:
logger.error("Fehler beim Speichern des Index: %s", e)
result.errors.append(
IngestError(
file_path=str(self.index.index_path),
error=f"Index-Speicherung fehlgeschlagen: {e}",
retry=True,
)
)
return result
def _process_artifact(self, artifact: Artifact, context: str) -> bool:
"""Verarbeitet ein einzelnes Artefakt (Transform + Load + Index).
Returns:
True wenn das Artefakt aktualisiert wurde,
False wenn es übersprungen wurde (unverändert).
"""
# --- Transform: Metadaten anreichern ---
self._enrich_metadata(artifact, context)
# Content-Hash berechnen
new_hash = compute_content_hash(artifact.content)
# Artefakt-ID bestimmen
artifact_id = self._compute_artifact_id(artifact, context)
# --- Inkrementelle Verarbeitung: Hash-Vergleich ---
existing_entry = self.index.get_entry(artifact_id)
if existing_entry is not None and existing_entry.content_hash == new_hash:
# Inhalt unverändert → überspringen
return False
# --- Load: Artefakt in Ordnerstruktur schreiben ---
artifact.metadata.content_hash = new_hash
written_path = write_artifact(artifact, self.store_path)
# --- Index-Update ---
entry = IndexEntry(
id=artifact_id,
title=artifact.metadata.title,
type=artifact.metadata.type,
tags=artifact.metadata.tags,
scope=context,
summary=self._generate_summary(artifact.content),
path=str(written_path.relative_to(self.store_path)),
content_hash=new_hash,
links=[link.target for link in artifact.metadata.links],
)
self.index.update_entry(entry)
return True
def _enrich_metadata(self, artifact: Artifact, context: str) -> None:
"""Reichert die Artefakt-Metadaten mit Kontext und Datum an.
Setzt source_context und created/updated-Datum falls nicht vorhanden.
"""
if not artifact.metadata.source_context:
artifact.metadata.source_context = context
today = date.today()
if artifact.metadata.created is None:
artifact.metadata.created = today
if artifact.metadata.updated is None:
artifact.metadata.updated = today
else:
# Bei Update das Aktualisierungsdatum auf heute setzen
artifact.metadata.updated = today
def _compute_artifact_id(self, artifact: Artifact, context: str) -> str:
"""Berechnet die eindeutige ID eines Artefakts.
Format: {context}/{type}/{filename_without_extension}
"""
name = artifact.file_path.stem if artifact.file_path.stem else "untitled"
artifact_type = artifact.metadata.type or "note"
return f"{context}/{artifact_type}/{name}"
def _generate_summary(self, content: str, max_length: int = 150) -> str:
"""Erzeugt eine Kurzbeschreibung aus dem Inhalt.
Nimmt die erste nicht-leere Zeile (ohne Markdown-Heading-Marker)
und kürzt sie auf max_length Zeichen.
"""
for line in content.splitlines():
stripped = line.strip()
if not stripped:
continue
# Heading-Marker entfernen
if stripped.startswith("#"):
stripped = stripped.lstrip("#").strip()
if stripped:
if len(stripped) > max_length:
return stripped[:max_length - 3] + "..."
return stripped
return ""
@@ -0,0 +1,263 @@
"""YAML-Index für den Wissensspeicher (Progressive Disclosure Schicht 1).
Der Index enthält ausschließlich kompakte Metadaten-Einträge (Titel, Tags,
Beziehungen, Kurzbeschreibungen, Pfade) niemals den vollständigen
Dokumentinhalt. Agenten lesen zuerst den Index und laden bei Bedarf
gezielt einzelne Dateien nach.
Format:
version: "1.0"
last_updated: "2025-01-15T10:30:00Z"
artifacts:
- id: "bahn/decisions/api-design"
title: "..."
type: decision
tags: [...]
scope: bahn
summary: "..."
path: "..."
content_hash: "sha256:..."
links: [...]
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import yaml
@dataclass
class IndexEntry:
"""Ein einzelner Eintrag im YAML-Index (Progressive Disclosure).
Enthält nur kompakte Metadaten kein vollständiger Dokumentinhalt.
"""
id: str
title: str
type: str
tags: list[str] = field(default_factory=list)
scope: str = ""
summary: str = ""
path: str = ""
content_hash: str = ""
links: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, object]:
"""Serialisiert den Eintrag als dict für YAML-Export."""
return {
"id": self.id,
"title": self.title,
"type": self.type,
"tags": self.tags,
"scope": self.scope,
"summary": self.summary,
"path": self.path,
"content_hash": self.content_hash,
"links": self.links,
}
@classmethod
def from_dict(cls, data: dict[str, object]) -> IndexEntry:
"""Erstellt einen IndexEntry aus einem dict (YAML-Import)."""
return cls(
id=str(data.get("id", "")),
title=str(data.get("title", "")),
type=str(data.get("type", "")),
tags=list(data.get("tags", [])), # type: ignore[arg-type]
scope=str(data.get("scope", "")),
summary=str(data.get("summary", "")),
path=str(data.get("path", "")),
content_hash=str(data.get("content_hash", "")),
links=list(data.get("links", [])), # type: ignore[arg-type]
)
class YAMLIndex:
"""Progressive-Disclosure-Index für den Wissensspeicher.
Verwaltet eine YAML-Datei mit kompakten Metadaten-Einträgen.
Erzwingt, dass niemals vollständiger Dokumentinhalt im Index landet.
"""
VERSION = "1.0"
def __init__(self, index_path: Path) -> None:
"""Initialisiert den YAMLIndex.
Args:
index_path: Pfad zur YAML-Index-Datei (_index.yaml).
"""
self.index_path = index_path
self._version: str = self.VERSION
self._last_updated: str = ""
self._entries: dict[str, IndexEntry] = {}
@property
def version(self) -> str:
"""Aktuelle Index-Version."""
return self._version
@property
def last_updated(self) -> str:
"""Zeitstempel der letzten Aktualisierung (ISO 8601)."""
return self._last_updated
@property
def entries(self) -> dict[str, IndexEntry]:
"""Alle Index-Einträge, indexiert nach ID."""
return dict(self._entries)
def load(self) -> None:
"""Lädt den YAML-Index von der Festplatte.
Wenn die Datei nicht existiert, wird ein leerer Index initialisiert.
Raises:
yaml.YAMLError: Wenn die YAML-Datei nicht geparst werden kann.
ValueError: Wenn das Format ungültig ist.
"""
if not self.index_path.exists():
self._entries = {}
self._last_updated = ""
self._version = self.VERSION
return
with open(self.index_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
self._entries = {}
self._last_updated = ""
self._version = self.VERSION
return
if not isinstance(data, dict):
raise ValueError(
f"Ungültiges Index-Format: Erwartet dict, erhalten {type(data).__name__}"
)
self._version = str(data.get("version", self.VERSION))
self._last_updated = str(data.get("last_updated", ""))
self._entries = {}
for artifact_data in data.get("artifacts", []):
if isinstance(artifact_data, dict):
entry = IndexEntry.from_dict(artifact_data)
self._entries[entry.id] = entry
def save(self) -> None:
"""Persistiert den Index auf die Festplatte.
Aktualisiert den last_updated-Zeitstempel und schreibt alle
Einträge im definierten YAML-Format.
"""
self._last_updated = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
data = {
"version": self._version,
"last_updated": self._last_updated,
"artifacts": [entry.to_dict() for entry in self._entries.values()],
}
# Sicherstellen, dass das Verzeichnis existiert
self.index_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
def update_entry(self, entry: IndexEntry) -> None:
"""Fügt einen Eintrag hinzu oder aktualisiert einen bestehenden.
Progressive Disclosure wird erzwungen: Der Eintrag darf nur
kompakte Metadaten enthalten (kein vollständiger Inhalt).
Args:
entry: Der hinzuzufügende oder zu aktualisierende IndexEntry.
Raises:
ValueError: Wenn der Eintrag keine gültige ID hat.
"""
if not entry.id:
raise ValueError("IndexEntry muss eine nicht-leere ID haben.")
self._entries[entry.id] = entry
def search(self, query: str, allowed_scopes: list[str]) -> list[IndexEntry]:
"""Textsuche über den Index mit Scope-Filterung.
Durchsucht Titel, Tags, Summary und ID nach dem Suchbegriff.
Liefert nur Einträge aus erlaubten Scopes zurück Einträge
aus nicht-autorisierten Scopes werden weder zurückgegeben
noch wird deren Existenz offengelegt.
Args:
query: Suchbegriff (case-insensitive Teilstring-Suche).
allowed_scopes: Liste der Scopes, aus denen Ergebnisse
zurückgegeben werden dürfen.
Returns:
Liste passender IndexEntry-Objekte, gefiltert nach Scope.
"""
if not query:
return []
query_lower = query.lower()
results: list[IndexEntry] = []
for entry in self._entries.values():
# Scope-Filter: Nur autorisierte Scopes
if entry.scope not in allowed_scopes:
continue
# Textsuche über relevante Felder
searchable_text = " ".join([
entry.title,
entry.id,
entry.summary,
" ".join(entry.tags),
]).lower()
if query_lower in searchable_text:
results.append(entry)
return results
def get_by_scope(self, scope: str) -> list[IndexEntry]:
"""Liefert alle Einträge eines bestimmten Scopes.
Args:
scope: Der Scope, nach dem gefiltert wird (z.B. "bahn", "privat").
Returns:
Liste aller IndexEntry-Objekte mit dem angegebenen Scope.
"""
return [entry for entry in self._entries.values() if entry.scope == scope]
def remove_entry(self, entry_id: str) -> bool:
"""Entfernt einen Eintrag aus dem Index.
Args:
entry_id: Die ID des zu entfernenden Eintrags.
Returns:
True wenn der Eintrag entfernt wurde, False wenn er nicht existierte.
"""
if entry_id in self._entries:
del self._entries[entry_id]
return True
return False
def get_entry(self, entry_id: str) -> IndexEntry | None:
"""Liefert einen einzelnen Eintrag nach ID.
Args:
entry_id: Die ID des gesuchten Eintrags.
Returns:
Der IndexEntry oder None, wenn nicht gefunden.
"""
return self._entries.get(entry_id)
@@ -0,0 +1,486 @@
"""NoteGraph-Migration für den Wissensspeicher.
Migriert eine bestehende NoteGraph-Verzeichnisstruktur (decisions, inbox,
meetings, people, projects) in den KnowledgeStore. Nutzt die ETLPipeline
für die Ingestion und erweitert Artefakte mit fehlenden Metadaten.
Scope-Konfiguration: Mapping Arbeitskontext → Scope wird aus scopes.yaml geladen.
Requirements: 3.5, 3.14
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from monorepo.knowledge.artifact import (
Artifact,
ArtifactMetadata,
parse_frontmatter,
)
from monorepo.knowledge.etl import ETLPipeline, IngestResult
from monorepo.knowledge.index import YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.knowledge.store import KnowledgeStore
from monorepo.models import ScopeConfig
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# NoteGraph-Verzeichnis → ArtifactType Mapping
# ---------------------------------------------------------------------------
NOTEGRAPH_DIR_TYPE_MAP: dict[str, str] = {
"decisions": "decision",
"inbox": "note",
"meetings": "meeting",
"people": "reference",
"projects": "project",
}
"""Standard-NoteGraph-Verzeichnisse und ihr zugeordneter Artefakt-Typ."""
# ---------------------------------------------------------------------------
# Ergebnis-Datenmodelle
# ---------------------------------------------------------------------------
@dataclass
class MigrationResult:
"""Ergebnis einer NoteGraph-Migration.
Attributes:
migrated_count: Anzahl erfolgreich migrierter Artefakte.
skipped_count: Anzahl übersprungener Artefakte (unverändert oder ohne Frontmatter).
errors: Liste der aufgetretenen Fehler mit Details.
"""
migrated_count: int = 0
skipped_count: int = 0
errors: list[str] = field(default_factory=list)
@property
def success(self) -> bool:
"""True wenn Migration durchgeführt wurde ohne fatale Fehler."""
return self.migrated_count > 0 or (
self.migrated_count == 0 and len(self.errors) == 0
)
@dataclass
class ValidationDetail:
"""Detail zu einem fehlenden oder gefundenen Artefakt bei der Validierung."""
file_name: str
expected_type: str
found_in_index: bool
artifact_id: str = ""
@dataclass
class ValidationResult:
"""Ergebnis der Migrations-Validierung.
Attributes:
valid: True wenn alle Artefakte im Index auffindbar sind.
total: Gesamtanzahl erwarteter Artefakte.
found_in_index: Anzahl der im Index gefundenen Artefakte.
missing: Liste fehlender Artefakt-IDs.
details: Detaillierte Ergebnisse pro Artefakt.
"""
valid: bool = True
total: int = 0
found_in_index: int = 0
missing: list[str] = field(default_factory=list)
details: list[ValidationDetail] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Scope-Konfiguration laden
# ---------------------------------------------------------------------------
def load_scope_config(scope_config_path: Path) -> ScopeConfig:
"""Lädt die Scope-Konfiguration aus einer YAML-Datei.
Die Datei definiert das Mapping Arbeitskontext → Scope für den
Wissensspeicher (Requirement 3.14).
Args:
scope_config_path: Pfad zur scopes.yaml Datei.
Returns:
ScopeConfig mit dem Scope-Mapping.
Raises:
FileNotFoundError: Wenn die Datei nicht existiert.
ValueError: Wenn das Format ungültig ist.
"""
if not scope_config_path.exists():
raise FileNotFoundError(
f"Scope-Konfiguration nicht gefunden: {scope_config_path}"
)
with open(scope_config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "scopes" not in data:
raise ValueError(
f"Ungültiges Scope-Konfigurationsformat in {scope_config_path}"
)
scopes: dict[str, dict[str, object]] = {}
for entry in data["scopes"]:
if isinstance(entry, dict) and "name" in entry:
context = str(entry.get("context", entry["name"]))
scopes[context] = {
"scope": str(entry["name"]),
"description": str(entry.get("description", "")),
"knowledge_path": str(entry.get("knowledge_path", "")),
}
return ScopeConfig(scopes=scopes)
# ---------------------------------------------------------------------------
# NoteGraph-Migrator
# ---------------------------------------------------------------------------
class NoteGraphMigrator:
"""Migriert NoteGraph-Verzeichnisstrukturen in den KnowledgeStore.
Der NoteGraph hat folgende Verzeichnisstruktur:
decisions/ → Typ: decision
inbox/ → Typ: note
meetings/ → Typ: meeting
people/ → Typ: reference
projects/ → Typ: project
Jedes Verzeichnis enthält .md-Dateien mit optionalem YAML-Frontmatter.
Der Migrator:
- Mappt NoteGraph-Verzeichnisse auf Artefakttypen
- Nutzt die ETLPipeline für die Ingestion
- Ergänzt fehlendes Frontmatter (Typ, source_context)
- Bewahrt vorhandene Metadaten
- Validiert, dass alle Artefakte im Zielsystem vorhanden und auffindbar sind
Requirements: 3.5, 3.14
"""
def __init__(self, store: KnowledgeStore, scope_config_path: Path) -> None:
"""Initialisiert den NoteGraphMigrator.
Args:
store: Der KnowledgeStore, in den migriert wird.
scope_config_path: Pfad zur scopes.yaml für Scope-Mapping.
"""
self.store = store
self.scope_config_path = scope_config_path
self.scope_config = load_scope_config(scope_config_path)
def migrate(self, source_path: Path, context: str) -> MigrationResult:
"""Migriert eine NoteGraph-Verzeichnisstruktur in den KnowledgeStore.
Durchsucht die NoteGraph-Verzeichnisse (decisions, inbox, meetings,
people, projects) und migriert alle .md-Dateien. Dateien ohne
YAML-Frontmatter werden mit generiertem Frontmatter versehen.
Args:
source_path: Pfad zum NoteGraph-Wurzelverzeichnis.
context: Arbeitskontext für die migrierten Artefakte
(z.B. "privat", "bahn").
Returns:
MigrationResult mit Statistiken und Fehlerdetails.
"""
result = MigrationResult()
if not source_path.exists():
result.errors.append(
f"NoteGraph-Verzeichnis existiert nicht: {source_path}"
)
return result
if not source_path.is_dir():
result.errors.append(
f"NoteGraph-Pfad ist kein Verzeichnis: {source_path}"
)
return result
# Jedes NoteGraph-Unterverzeichnis verarbeiten
for dir_name, artifact_type in NOTEGRAPH_DIR_TYPE_MAP.items():
sub_dir = source_path / dir_name
if not sub_dir.exists() or not sub_dir.is_dir():
logger.debug(
"NoteGraph-Unterverzeichnis existiert nicht, überspringe: %s",
sub_dir,
)
continue
sub_result = self._migrate_directory(sub_dir, context, artifact_type)
result.migrated_count += sub_result.migrated_count
result.skipped_count += sub_result.skipped_count
result.errors.extend(sub_result.errors)
return result
def _migrate_directory(
self, directory: Path, context: str, artifact_type: str
) -> MigrationResult:
"""Migriert alle .md-Dateien eines NoteGraph-Unterverzeichnisses.
Bereitet Dateien ohne Frontmatter vor und nutzt dann die ETLPipeline.
Args:
directory: Das zu migrierende Verzeichnis.
context: Arbeitskontext.
artifact_type: Der zugeordnete Artefakt-Typ.
Returns:
MigrationResult für dieses Verzeichnis.
"""
result = MigrationResult()
md_files = sorted(directory.rglob("*.md"))
if not md_files:
return result
# Dateien ohne Frontmatter vorbereiten (temporär Frontmatter hinzufügen)
prepared_files = self._prepare_files(md_files, context, artifact_type)
# ETLPipeline für die vorbereiteten Dateien verwenden
source = MarkdownSource(directory=directory)
# Wir nutzen den Store.ingest direkt, da die Dateien nun Frontmatter haben
ingest_result = self.store.ingest(source, context)
result.migrated_count = ingest_result.updated
result.skipped_count = ingest_result.skipped
for error in ingest_result.errors:
result.errors.append(f"{error.file_path}: {error.error}")
return result
def _prepare_files(
self,
md_files: list[Path],
context: str,
artifact_type: str,
) -> list[Path]:
"""Bereitet .md-Dateien für die Migration vor.
Dateien ohne YAML-Frontmatter erhalten generiertes Frontmatter
mit dem korrekten Typ und Kontext. Dateien mit existierendem
Frontmatter erhalten fehlende Metadaten (type, source_context).
Args:
md_files: Liste der zu verarbeitenden Markdown-Dateien.
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ basierend auf dem NoteGraph-Verzeichnis.
Returns:
Liste der vorbereiteten Dateipfade.
"""
prepared: list[Path] = []
for md_file in md_files:
try:
content = md_file.read_text(encoding="utf-8")
except OSError as e:
logger.warning("Kann Datei nicht lesen: %s %s", md_file, e)
continue
updated_content = self._ensure_frontmatter(
content, md_file.stem, context, artifact_type
)
if updated_content != content:
md_file.write_text(updated_content, encoding="utf-8")
prepared.append(md_file)
return prepared
def _ensure_frontmatter(
self,
content: str,
file_stem: str,
context: str,
artifact_type: str,
) -> str:
"""Stellt sicher, dass der Inhalt gültiges YAML-Frontmatter enthält.
Wenn kein Frontmatter vorhanden ist, wird eines generiert.
Wenn Frontmatter existiert, werden fehlende Felder ergänzt
(type, source_context).
Args:
content: Der aktuelle Dateiinhalt.
file_stem: Dateiname ohne Erweiterung (für den Titel).
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ.
Returns:
Der (möglicherweise aktualisierte) Dateiinhalt.
"""
if content.startswith("---"):
# Frontmatter existiert fehlende Felder ergänzen
return self._enrich_existing_frontmatter(
content, context, artifact_type
)
else:
# Kein Frontmatter generieren
return self._generate_frontmatter(
content, file_stem, context, artifact_type
)
def _enrich_existing_frontmatter(
self, content: str, context: str, artifact_type: str
) -> str:
"""Ergänzt fehlende Felder in existierendem YAML-Frontmatter.
Setzt 'type' und 'source_context' falls nicht vorhanden.
Args:
content: Dateiinhalt mit existierendem Frontmatter.
context: Arbeitskontext.
artifact_type: Der zu setzende Artefakt-Typ.
Returns:
Aktualisierter Dateiinhalt.
"""
import re
pattern = re.compile(
r"\A---\s*\n(.*?)^---\s*$\n?",
re.DOTALL | re.MULTILINE,
)
match = pattern.match(content)
if not match:
return content
yaml_text = match.group(1)
body = content[match.end():]
try:
metadata: dict[str, Any] = yaml.safe_load(yaml_text) or {}
except yaml.YAMLError:
return content
modified = False
# Typ ergänzen falls fehlend
if not metadata.get("type"):
metadata["type"] = artifact_type
modified = True
# source_context ergänzen falls fehlend
if not metadata.get("source_context"):
metadata["source_context"] = context
modified = True
if not modified:
return content
# Frontmatter neu generieren
new_yaml = yaml.dump(
metadata,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{new_yaml}---\n{body}"
def _generate_frontmatter(
self, content: str, file_stem: str, context: str, artifact_type: str
) -> str:
"""Generiert neues YAML-Frontmatter für eine Datei ohne Frontmatter.
Erstellt minimales Frontmatter mit Typ, Titel und Quellkontext.
Args:
content: Der Dateiinhalt (ohne Frontmatter).
file_stem: Dateiname ohne Erweiterung.
context: Arbeitskontext.
artifact_type: Der Artefakt-Typ.
Returns:
Dateiinhalt mit vorangestelltem Frontmatter.
"""
# Titel aus Dateiname ableiten (kebab-case → Title Case)
title = file_stem.replace("-", " ").replace("_", " ").title()
metadata = {
"type": artifact_type,
"title": title,
"tags": [],
"source_context": context,
"shareable": False,
}
yaml_str = yaml.dump(
metadata,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
)
return f"---\n{yaml_str}---\n{content}"
def validate(self, context: str) -> ValidationResult:
"""Validiert, dass alle migrierten Artefakte im Index auffindbar sind.
Prüft für den angegebenen Kontext:
- Alle erwarteten Artefakte sind im YAML-Index vorhanden
- Metadaten (Typ, Scope) sind korrekt gesetzt
- Verknüpfungen sind intakt
Args:
context: Der Arbeitskontext, für den validiert wird.
Returns:
ValidationResult mit detaillierten Ergebnissen.
"""
result = ValidationResult()
# Alle Einträge für diesen Scope aus dem Index holen
index_entries = self.store.get_index(scope=context)
index_ids = {entry.id for entry in index_entries}
result.total = len(index_entries)
result.found_in_index = len(index_entries)
# Prüfe ob jeder Index-Eintrag eine gültige Datei referenziert
for entry in index_entries:
artifact_path = self.store.base_path / entry.path if entry.path else None
file_exists = artifact_path.exists() if artifact_path else False
detail = ValidationDetail(
file_name=entry.path,
expected_type=entry.type,
found_in_index=True,
artifact_id=entry.id,
)
result.details.append(detail)
if not file_exists:
result.missing.append(entry.id)
result.valid = False
# Zusätzlich prüfen: Haben alle Dateien einen korrekten Scope?
for entry in index_entries:
if entry.scope != context:
result.missing.append(
f"{entry.id} (falscher Scope: {entry.scope}, erwartet: {context})"
)
result.valid = False
return result
@@ -0,0 +1,11 @@
"""Quellstrategien für die ETL-Pipeline des Wissensspeichers.
Jede Quellstrategie implementiert das Extrahieren von Wissensartefakten
aus einem bestimmten Format/Medium (Markdown, Confluence, PDF, etc.).
"""
from monorepo.knowledge.sources.markdown import MarkdownSource
__all__ = [
"MarkdownSource",
]
@@ -0,0 +1,111 @@
"""Markdown-Quellstrategie für die ETL-Pipeline.
Scannt ein Verzeichnis rekursiv nach .md-Dateien mit YAML-Frontmatter
und liefert eine Liste von Artifacts zurück.
Requirements: 3.2, 3.10, 3.12, 3.19
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from monorepo.knowledge.artifact import Artifact, parse_frontmatter
logger = logging.getLogger(__name__)
@dataclass
class SourceError:
"""Fehler bei der Verarbeitung einer einzelnen Quelldatei."""
file_path: str
error: str
retry: bool = True
@dataclass
class MarkdownSource:
"""Quellstrategie: Rekursives Scannen eines Verzeichnisses nach Markdown-Dateien.
Findet alle .md-Dateien im angegebenen Verzeichnis (rekursiv),
parsed jede Datei mit parse_frontmatter und liefert die resultierenden
Artifacts. Dateien ohne gültiges YAML-Frontmatter werden übersprungen
und als Warning geloggt.
"""
directory: Path
errors: list[SourceError] = field(default_factory=list)
def extract(self) -> list[Artifact]:
"""Extrahiert alle Artefakte aus dem Quellverzeichnis.
Durchsucht das Verzeichnis rekursiv nach .md-Dateien,
parsed jede Datei und sammelt Fehler ohne abzubrechen.
Returns:
Liste erfolgreich geparster Artifacts.
"""
self.errors = []
artifacts: list[Artifact] = []
if not self.directory.exists():
logger.warning(
"Quellverzeichnis existiert nicht: %s", self.directory
)
self.errors.append(
SourceError(
file_path=str(self.directory),
error=f"Verzeichnis existiert nicht: {self.directory}",
retry=True,
)
)
return artifacts
if not self.directory.is_dir():
logger.warning(
"Quellpfad ist kein Verzeichnis: %s", self.directory
)
self.errors.append(
SourceError(
file_path=str(self.directory),
error=f"Pfad ist kein Verzeichnis: {self.directory}",
retry=False,
)
)
return artifacts
md_files = sorted(self.directory.rglob("*.md"))
for md_file in md_files:
try:
artifact = parse_frontmatter(md_file)
artifacts.append(artifact)
except ValueError as e:
logger.warning(
"Überspringe Datei ohne gültiges Frontmatter: %s %s",
md_file,
e,
)
self.errors.append(
SourceError(
file_path=str(md_file),
error=str(e),
retry=False,
)
)
except OSError as e:
logger.error(
"Fehler beim Lesen der Datei: %s %s", md_file, e
)
self.errors.append(
SourceError(
file_path=str(md_file),
error=str(e),
retry=True,
)
)
return artifacts
@@ -0,0 +1,352 @@
"""KnowledgeStore Zentraler Wissensspeicher mit ETL-Pipeline.
Basiert auf der DB-Wissensdatenbank-ETL-Architektur und bietet:
- Ingestion von Wissensartefakten aus verschiedenen Quellen
- Progressive-Disclosure-Index (kompakte YAML-Metadaten, Schicht 1)
- Volltextsuche mit Scope-basierter Zugriffskontrolle und Relevanz-Sortierung
- Graph-Verknüpfungen zwischen Artefakten
- Timeout-Handling für Suchanfragen (5 Sekunden)
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from monorepo.knowledge.artifact import ArtifactLink, parse_frontmatter, write_artifact
from monorepo.knowledge.etl import ETLPipeline, IngestResult
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.sources.markdown import MarkdownSource
from monorepo.models import ScopeConfig
logger = logging.getLogger(__name__)
# Default-Timeout für Suchanfragen in Sekunden
SEARCH_TIMEOUT_SECONDS: float = 5.0
@dataclass
class SearchResult:
"""Ergebnis einer Volltextsuche im Wissensspeicher.
Enthält den gefundenen Index-Eintrag, einen Relevanz-Score und
eine Information, ob die Suche wegen Timeout abgebrochen wurde.
Attributes:
entry: Der gefundene IndexEntry mit kompakten Metadaten.
relevance_score: Relevanz-Score zwischen 0.0 und 1.0.
Höhere Werte bedeuten bessere Treffer.
Scoring-Hierarchie: title > tags > summary > id.
partial_results: True wenn die Suche wegen Timeout abgebrochen
wurde und möglicherweise nicht alle Ergebnisse enthalten sind.
"""
entry: IndexEntry
relevance_score: float = 0.0
partial_results: bool = False
def _compute_relevance(query_lower: str, entry: IndexEntry) -> float:
"""Berechnet den Relevanz-Score für einen Index-Eintrag.
Scoring-Hierarchie (von hoch nach niedrig):
1. Title-Match (0.8 - 1.0): Query kommt im Titel vor
2. Tag-Match (0.6 - 0.79): Query kommt in einem Tag vor
3. Summary-Match (0.4 - 0.59): Query kommt im Summary vor
4. ID-Match (0.2 - 0.39): Query kommt in der ID vor
Innerhalb jeder Kategorie wird ein Bonus für exakte Übereinstimmung
oder einen höheren Anteil des Matchs vergeben.
Args:
query_lower: Suchbegriff in Kleinbuchstaben.
entry: Der zu bewertende IndexEntry.
Returns:
Relevanz-Score zwischen 0.0 und 1.0.
"""
title_lower = entry.title.lower()
tags_lower = [t.lower() for t in entry.tags]
summary_lower = entry.summary.lower()
id_lower = entry.id.lower()
score = 0.0
# 1. Title match (highest priority: 0.8 - 1.0)
if query_lower in title_lower:
# Bonus for exact match or high coverage
if title_lower == query_lower:
score = max(score, 1.0)
else:
coverage = len(query_lower) / max(len(title_lower), 1)
score = max(score, 0.8 + coverage * 0.19)
# 2. Tag match (0.6 - 0.79)
for tag in tags_lower:
if query_lower in tag:
if tag == query_lower:
score = max(score, 0.79)
else:
score = max(score, 0.6 + len(query_lower) / max(len(tag), 1) * 0.18)
# 3. Summary match (0.4 - 0.59)
if query_lower in summary_lower:
coverage = len(query_lower) / max(len(summary_lower), 1)
score = max(score, 0.4 + coverage * 0.19)
# 4. ID match (0.2 - 0.39)
if query_lower in id_lower:
coverage = len(query_lower) / max(len(id_lower), 1)
score = max(score, 0.2 + coverage * 0.19)
return min(score, 1.0)
class KnowledgeStore:
"""Zentraler Wissensspeicher mit ETL-Pipeline.
Der KnowledgeStore verwaltet Wissensartefakte aus allen Arbeitskontexten,
indexiert sie im YAML-Index und stellt Suchmechanismen bereit.
Progressive Disclosure:
- Schicht 1 (Index): Kompakte Metadaten (Titel, Tags, Pfade, Summary)
- Schicht 2 (Dokument): Vollständige Markdown-Datei mit YAML-Frontmatter
Agenten lesen zuerst den Index und laden nur bei Bedarf einzelne Dokumente.
"""
INDEX_FILENAME = "_index.yaml"
def __init__(self, base_path: Path, scope_config: ScopeConfig) -> None:
"""Initialisiert den KnowledgeStore.
Args:
base_path: Basispfad des Wissensspeichers
(z.B. shared/knowledge-store/).
scope_config: Konfiguration des Scope-Mappings
(Arbeitskontext → Scope).
"""
self.base_path = base_path
self.scope_config = scope_config
self.index = YAMLIndex(base_path / self.INDEX_FILENAME)
# Index beim Start laden, sofern vorhanden
self.index.load()
def ingest(self, source: MarkdownSource, context: str) -> IngestResult:
"""Verarbeitet eine Quelle und legt Artefakte im Wissensspeicher ab.
Delegiert an die ETLPipeline, die:
- Artefakte aus der Quelle extrahiert
- Metadaten anreichert (Scope, Datum, Content-Hash)
- Inkrementell verarbeitet (nur geänderte Artefakte aktualisiert)
- Den YAML-Index im selben Durchlauf aktualisiert
- Bei Fehlern erfolgreiche Artefakte behält und Fehler protokolliert
Args:
source: Die Markdown-Quellstrategie mit dem zu scannenden Verzeichnis.
context: Der Arbeitskontext, aus dem die Quelle stammt
(z.B. "bahn", "privat").
Returns:
IngestResult mit Statistiken (processed, updated, skipped, errors).
"""
pipeline = ETLPipeline(index=self.index, store_path=self.base_path)
return pipeline.ingest(source, context)
def search(
self,
query: str,
allowed_scopes: list[str],
timeout: float | None = None,
) -> list[SearchResult]:
"""Volltextsuche über den Index mit Relevanz-Sortierung und Timeout.
Durchsucht den YAML-Index nach dem Suchbegriff und liefert
nur Ergebnisse aus autorisierten Scopes, sortiert nach Relevanz.
Nicht-autorisierte Artefakte werden weder zurückgegeben noch
wird deren Existenz offengelegt.
Relevanz-Scoring-Hierarchie (höchste zuerst):
1. Title-Match
2. Tag-Match
3. Summary-Match
4. ID-Match
Timeout-Handling:
Die Suche bricht nach dem konfigurierten Timeout (Standard:
5 Sekunden) ab und liefert die bis dahin gefundenen
Teilergebnisse mit partial_results=True.
Args:
query: Suchbegriff für die Volltextsuche.
allowed_scopes: Liste der Scopes, aus denen Ergebnisse
geliefert werden dürfen.
timeout: Timeout in Sekunden (None = SEARCH_TIMEOUT_SECONDS).
Returns:
Liste von SearchResult-Objekten, sortiert nach Relevanz
(höchster Score zuerst). Jedes Ergebnis enthält nur Pfade
und Metadaten kein vollständiger Inhalt (Progressive Disclosure).
Note:
Requirements: 3.3, 3.7, 3.9
"""
if not query:
return []
effective_timeout = timeout if timeout is not None else SEARCH_TIMEOUT_SECONDS
query_lower = query.lower()
results: list[SearchResult] = []
timed_out = False
start_time = time.monotonic()
for entry in self.index.entries.values():
# Timeout-Check
elapsed = time.monotonic() - start_time
if elapsed >= effective_timeout:
timed_out = True
break
# Scope-Filter: Nur autorisierte Scopes (niemals Existenz
# nicht-autorisierter Artefakte offenlegen)
if entry.scope not in allowed_scopes:
continue
# Textsuche über relevante Felder
searchable_text = " ".join([
entry.title,
entry.id,
entry.summary,
" ".join(entry.tags),
]).lower()
if query_lower in searchable_text:
relevance = _compute_relevance(query_lower, entry)
results.append(SearchResult(
entry=entry,
relevance_score=relevance,
partial_results=False,
))
# Bei Timeout: Alle bisherigen Ergebnisse als partial markieren
if timed_out:
for result in results:
result.partial_results = True
# Nach Relevanz sortieren (höchster Score zuerst)
results.sort(key=lambda r: r.relevance_score, reverse=True)
return results
def get_index(self, scope: str | None = None) -> list[IndexEntry]:
"""Liefert den kompakten YAML-Index (Progressive Disclosure Schicht 1).
Gibt nur Metadaten-Einträge zurück niemals den vollständigen
Dokumentinhalt. Agenten können über die Pfade in den Einträgen
bei Bedarf einzelne Dokumente nachladen.
Args:
scope: Optionaler Scope-Filter. Wenn None, werden alle
Einträge zurückgegeben.
Returns:
Liste von IndexEntry-Objekten mit kompakten Metadaten.
"""
if scope is not None:
return self.index.get_by_scope(scope)
return list(self.index.entries.values())
def link_artifacts(self, source_id: str, target_id: str, relation: str) -> None:
"""Verknüpft zwei Artefakte als gerichtete Graph-Kante.
Erstellt eine bidirektionale Verknüpfung zwischen zwei Artefakten:
- Im Quell-Artefakt wird die Beziehung zum Ziel eingetragen
- Im Ziel-Artefakt wird die inverse Beziehung zur Quelle eingetragen
- Der YAML-Index wird mit den neuen Link-Informationen aktualisiert
- Die YAML-Frontmatter beider Artefakt-Dateien werden aktualisiert
Args:
source_id: ID des Quell-Artefakts.
target_id: ID des Ziel-Artefakts.
relation: Art der Beziehung (z.B. "implements", "references").
Raises:
ValueError: Wenn source_id oder target_id nicht im Index existiert.
"""
source_entry = self.index.get_entry(source_id)
target_entry = self.index.get_entry(target_id)
if source_entry is None:
raise ValueError(f"Quell-Artefakt nicht im Index gefunden: {source_id}")
if target_entry is None:
raise ValueError(f"Ziel-Artefakt nicht im Index gefunden: {target_id}")
# Bidirektionale Verlinkung im Index
if target_id not in source_entry.links:
source_entry.links.append(target_id)
self.index.update_entry(source_entry)
if source_id not in target_entry.links:
target_entry.links.append(source_id)
self.index.update_entry(target_entry)
# YAML-Frontmatter beider Artefakt-Dateien aktualisieren
self._update_artifact_frontmatter_link(
source_entry, target_entry.path, relation
)
self._update_artifact_frontmatter_link(
target_entry, source_entry.path, relation
)
def _update_artifact_frontmatter_link(
self, entry: IndexEntry, link_target_path: str, relation: str
) -> None:
"""Aktualisiert das YAML-Frontmatter einer Artefakt-Datei mit einem neuen Link.
Liest die Artefakt-Datei, prüft ob der Link bereits vorhanden ist,
fügt ihn bei Bedarf hinzu und schreibt die Datei zurück.
Wenn die Datei nicht existiert, wird nur der Index aktualisiert
(kein Fehler die Datei wird ggf. später erstellt).
Args:
entry: Der IndexEntry des Artefakts, dessen Datei aktualisiert werden soll.
link_target_path: Der Pfad des Ziel-Artefakts (für den links-Eintrag).
relation: Art der Beziehung.
"""
if not entry.path:
return
artifact_path = self.base_path / entry.path
if not artifact_path.exists():
logger.debug(
"Artefakt-Datei existiert nicht, überspringe Frontmatter-Update: %s",
artifact_path,
)
return
try:
artifact = parse_frontmatter(artifact_path)
except (ValueError, FileNotFoundError) as e:
logger.warning(
"Konnte Artefakt-Datei nicht parsen, überspringe Frontmatter-Update: %s (%s)",
artifact_path,
e,
)
return
# Prüfe ob Link bereits vorhanden (Idempotenz)
for existing_link in artifact.metadata.links:
if existing_link.target == link_target_path and existing_link.relation == relation:
return # Link existiert bereits
# Neuen Link hinzufügen
artifact.metadata.links.append(
ArtifactLink(target=link_target_path, relation=relation)
)
# Datei zurückschreiben (mit aktualisiertem Frontmatter)
write_artifact(artifact, self.base_path)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
"""Gemeinsame Datenmodelle für das Monorepo-CLI.
Definiert Enums und Dataclasses für alle Komponenten:
- Ordnerstruktur (ProjectInfo)
- Repo-Verwaltung (RepoEntry, MigrationPlan)
- Sicherheit (SecurityEvent)
- Verschlüsselung (MachineContext, EncryptionKey, PasswordManagerConfig)
- Wissensspeicher (ScopeConfig)
- Föderation (TeamRepoEntry, SharedMirrorConfig, SyncResult, ConflictInfo, IsolationReport, IsolationLeak)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Literal
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class Context(str, Enum):
"""Arbeitskontexte im Monorepo."""
PRIVAT = "privat"
DHIVE = "dhive"
BAHN = "bahn"
SHARED = "shared"
class RepoMode(str, Enum):
"""Einbindungsmodus für externe Repositories."""
READ_ONLY = "read-only"
UPSTREAM = "upstream"
class ArtifactType(str, Enum):
"""Typen von Wissensartefakten im Knowledge Store."""
DECISION = "decision"
NOTE = "note"
MEETING = "meeting"
REFERENCE = "reference"
PATTERN = "pattern"
class KeySource(str, Enum):
"""Quelle für Verschlüsselungsschlüssel."""
KEYRING = "keyring"
FILE = "file"
PASSWORD_MANAGER = "password-manager"
class SyncDirection(str, Enum):
"""Synchronisationsrichtung für Team-Repos."""
BIDIRECTIONAL = "bidirectional"
HUB_TO_SPOKE = "hub-to-spoke"
SPOKE_TO_HUB = "spoke-to-hub"
class SyncFrequency(str, Enum):
"""Frequenz der Synchronisation mit Team-Repos."""
ON_PUSH = "on-push"
HOURLY = "hourly"
DAILY = "daily"
MANUAL = "manual"
class ConflictStrategy(str, Enum):
"""Strategie zur Konfliktauflösung bei Synchronisation."""
TEAM_WINS = "team-wins"
HUB_WINS = "hub-wins"
MANUAL = "manual"
# ---------------------------------------------------------------------------
# Dataclasses Ordnerstruktur
# ---------------------------------------------------------------------------
@dataclass
class ProjectInfo:
"""Informationen über ein Projekt im Monorepo."""
name: str
context: str
path: Path
# ---------------------------------------------------------------------------
# Dataclasses Repo-Verwaltung
# ---------------------------------------------------------------------------
@dataclass
class RepoEntry:
"""Konfigurationseintrag für ein eingebundenes externes Repository."""
name: str
url: str
mode: Literal["read-only", "upstream"]
target: str
pinned: str
mechanism: Literal["subtree", "submodule"] = "subtree"
@dataclass
class MigrationPlan:
"""Plan für die Migration eines Repositories ins Monorepo."""
source_repo: str
target_context: str
target_name: str
mode: Literal["direct", "subtree", "upstream"]
dependencies: list[str] = field(default_factory=list)
order: int = 0
# ---------------------------------------------------------------------------
# Dataclasses Sicherheit
# ---------------------------------------------------------------------------
@dataclass
class SecurityEvent:
"""Protokolleintrag für einen Sicherheitsvorfall (Zugriffsverletzung)."""
timestamp: datetime
requesting_context: str
target_context: str
resource: str
action: Literal["read", "write", "execute"]
outcome: Literal["denied", "allowed"]
# ---------------------------------------------------------------------------
# Dataclasses Wissensspeicher
# ---------------------------------------------------------------------------
@dataclass
class ScopeConfig:
"""Mapping von Arbeitskontext zu Scope im Wissensspeicher.
Beispiel: {"privat": {"scope": "privat", "paths": ["privat/"]}}
"""
scopes: dict[str, dict[str, object]] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Dataclasses Verschlüsselung
# ---------------------------------------------------------------------------
@dataclass
class PasswordManagerConfig:
"""Konfiguration für die Passwort-Manager-Integration."""
type: Literal["bitwarden", "1password", "keepass"]
vault: str
entry_prefix: str = "monorepo-key-"
@dataclass
class MachineContext:
"""Maschinenkontext: Zuordnung eines Rechners zu autorisierten Kontexten."""
name: str
description: str
authorized_contexts: list[str] = field(default_factory=list)
key_source: Literal["keyring", "file", "password-manager"] = "keyring"
password_manager: PasswordManagerConfig | None = None
@dataclass
class EncryptionKey:
"""Verschlüsselungsschlüssel für einen Arbeitskontext."""
context: str
key_id: str
key_type: Literal["gpg", "symmetric"]
source: Literal["keyring", "file", "password-manager"]
@dataclass
class OnboardingResult:
"""Ergebnis der Maschineneinrichtung (Onboarding).
Beschreibt welche Schlüssel erfolgreich installiert und welche
Kontexte für die Maschine freigeschaltet wurden.
"""
success: bool
machine_name: str
authorized_contexts: list[str] = field(default_factory=list)
installed_keys: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Dataclasses Föderation (Team-Repos)
# ---------------------------------------------------------------------------
@dataclass
class SharedMirrorConfig:
"""Konfiguration für gespiegelte shared-Dateien in einem Team-Repo."""
enabled: bool
paths: list[str] = field(default_factory=list)
mode: Literal["read-only"] = "read-only"
@dataclass
class TeamRepoEntry:
"""Konfigurationseintrag für ein Team-Repository (Spoke)."""
context: str
url: str
branch: str
sync_direction: Literal["bidirectional", "hub-to-spoke", "spoke-to-hub"]
sync_frequency: Literal["on-push", "hourly", "daily", "manual"]
shared_mirror: SharedMirrorConfig | None = None
@dataclass
class SyncResult:
"""Ergebnis einer Synchronisationsoperation."""
success: bool
context: str
direction: Literal["pull", "push", "full"]
commits_synced: int
conflicts: list[ConflictInfo] = field(default_factory=list)
timestamp: datetime = field(default_factory=datetime.now)
@dataclass
class ConflictInfo:
"""Details zu einem Merge-Konflikt bei der Synchronisation."""
file_path: str
conflict_type: Literal["content", "rename", "delete-modify"]
source: Literal["monorepo", "team-repo"]
details: str
@dataclass
class IsolationReport:
"""Ergebnis der Isolationsprüfung eines Team-Repos."""
context: str
is_isolated: bool
leaks: list[IsolationLeak] = field(default_factory=list)
@dataclass
class IsolationLeak:
"""Gefundene Referenz auf einen fremden Kontext in einem Team-Repo."""
file_path: str
line_number: int
leaked_context: str
leak_type: Literal["path", "env_var", "config_ref", "comment"]
@@ -0,0 +1,552 @@
"""OrchestratorAdapter Integration des AI-Orchestrators mit der Monorepo-Struktur.
Adaptiert den bestehenden AI-Orchestrator für kontextgebundenes Arbeiten:
- Ermittlung des Arbeitskontexts aus Task-Metadaten (Labels/Projektzuordnung)
- Erstellung kontextgebundener Workspaces
- Prompt-Aufbau mit Wissenskontext aus dem YAML-Index
- Env-Loading mit optionaler Entschlüsselung via SecretEncryptionManager
- Workspace-Root-Auflösung aus WORKFLOW.md-Konfiguration
- Fehlerbehandlung bei fehlendem Kontext oder Out-of-Context-Zugriff
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING
from monorepo.audit import AuditLogger
from monorepo.models import Context, SecurityEvent
from monorepo.security import ContextGuard
if TYPE_CHECKING:
from monorepo.encryption import SecretEncryptionManager
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class ContextResolutionError(Exception):
"""Kein Arbeitskontext konnte aus den Task-Metadaten ermittelt werden.
Requirement 7.6: Task wird nicht gestartet, Fehlermeldung an den Nutzer.
"""
def __init__(self, task_id: str, message: str) -> None:
self.task_id = task_id
self.message = message
super().__init__(f"[{task_id}] {message}")
class ContextViolationError(Exception):
"""Zugriff außerhalb des zugewiesenen Kontexts erkannt.
Requirement 7.5: Task wird abgebrochen, Vorfall protokolliert,
Nutzer wird benachrichtigt.
"""
def __init__(self, task_id: str, context: str, access_path: str) -> None:
self.task_id = task_id
self.context = context
self.access_path = access_path
super().__init__(
f"[{task_id}] Kontextverletzung: Zugriff auf '{access_path}' "
f"aus Kontext '{context}' nicht erlaubt"
)
# ---------------------------------------------------------------------------
# Task-Datenmodell
# ---------------------------------------------------------------------------
@dataclass
class Task:
"""Repräsentiert einen Task aus dem Orchestrator.
Attributes:
id: Eindeutiger Task-Identifier.
title: Titel/Zusammenfassung des Tasks.
labels: Labels zur Kontextzuordnung (z.B. ["dhive"], ["bahn"]).
project: Projektzuordnung (z.B. "dhive/my-project").
description: Optionale Aufgabenbeschreibung.
"""
id: str
title: str
labels: list[str] = field(default_factory=list)
project: str = ""
description: str = ""
# ---------------------------------------------------------------------------
# ExecutionEnvironment Vollständige Ausführungsumgebung für einen Task
# ---------------------------------------------------------------------------
@dataclass
class ExecutionEnvironment:
"""Vollständige Ausführungsumgebung für einen kontextgebundenen Task.
Kombiniert Workspace-Pfad, entschlüsselte Umgebungsvariablen,
relevante Wissensartefakte und den zusammengesetzten Prompt.
Attributes:
workspace_path: Pfad zum kontextgebundenen Arbeitsverzeichnis.
context: Der zugewiesene Arbeitskontext (z.B. "dhive", "bahn").
env_vars: Entschlüsselte Umgebungsvariablen des Kontexts.
knowledge_artifacts: Pfade zu relevanten Wissensartefakten.
prompt: Vollständiger Agenten-Prompt mit Wissenskontext.
"""
workspace_path: Path
context: str
env_vars: dict[str, str] = field(default_factory=dict)
knowledge_artifacts: list[str] = field(default_factory=list)
prompt: str = ""
# ---------------------------------------------------------------------------
# Kontextmapping
# ---------------------------------------------------------------------------
# Bekannte Kontexte für die Auflösung aus Labels/Projektzuordnung
KNOWN_CONTEXTS: set[str] = {ctx.value for ctx in Context if ctx != Context.SHARED}
# Shared ist ebenfalls ein gültiger Kontext für Tasks
ALL_CONTEXTS: set[str] = {ctx.value for ctx in Context}
# ---------------------------------------------------------------------------
# OrchestratorAdapter
# ---------------------------------------------------------------------------
class OrchestratorAdapter:
"""Adaptiert den AI-Orchestrator für die Monorepo-Struktur.
Stellt kontextgebundene Arbeitsumgebungen bereit:
- Workspace-Erstellung unter dem korrekten Kontextordner
- Prompt-Aufbau mit relevantem Wissenskontext aus dem YAML-Index
- Env-Loading mit optionaler Entschlüsselung via SecretEncryptionManager
- Workspace-Root-Auflösung aus WORKFLOW.md-Konfiguration
- Sicherheitsprüfung bei Dateizugriffen über ContextGuard
Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6
"""
def __init__(
self,
root_path: Path,
context_guard: ContextGuard,
audit_logger: AuditLogger | None = None,
knowledge_index_path: Path | None = None,
encryption_manager: SecretEncryptionManager | None = None,
workflow_config_path: Path | None = None,
) -> None:
"""Initialisiert den OrchestratorAdapter.
Args:
root_path: Wurzelverzeichnis des Monorepos.
context_guard: ContextGuard-Instanz für Zugriffskontrolle.
audit_logger: Optionaler AuditLogger für Verletzungs-Protokollierung.
Falls None, wird ein Standard-Logger mit Default-Pfad erzeugt.
knowledge_index_path: Pfad zur YAML-Index-Datei des Wissensspeichers.
Falls None, wird `root_path / shared/knowledge-store/_index.yaml` verwendet.
encryption_manager: Optionaler SecretEncryptionManager für die
Entschlüsselung von .env-Dateien. Falls None, werden .env-Dateien
direkt gelesen (unverschlüsselt oder bereits entschlüsselt).
workflow_config_path: Optionaler Pfad zur WORKFLOW.md-Konfiguration.
Falls None, wird `root_path / WORKFLOW.md` verwendet.
"""
self.root_path = root_path.resolve()
self.context_guard = context_guard
self.audit_logger = audit_logger or AuditLogger(
self.root_path / ".audit" / "access.log"
)
self.encryption_manager = encryption_manager
if knowledge_index_path is None:
knowledge_index_path = (
self.root_path / "shared" / "knowledge-store" / "_index.yaml"
)
self._knowledge_index_path = knowledge_index_path
if workflow_config_path is None:
workflow_config_path = self.root_path / "WORKFLOW.md"
self._workflow_config_path = workflow_config_path
def resolve_context(self, task: Task) -> str:
"""Ermittelt den Arbeitskontext aus Task-Metadaten.
Prüft in folgender Reihenfolge:
1. Labels: Sucht nach bekannten Kontextnamen in den Task-Labels.
2. Projektzuordnung: Extrahiert den Kontext aus dem Projektpfad
(erster Pfadteil, z.B. "dhive/my-project""dhive").
Requirement 7.1: Kontext aus Task-Metadaten ermitteln.
Requirement 7.6: Kein Kontext → Task nicht starten, Fehlermeldung.
Args:
task: Der zu verarbeitende Task.
Returns:
Name des ermittelten Arbeitskontexts (z.B. "dhive", "bahn", "privat").
Raises:
ContextResolutionError: Wenn kein Kontext ermittelt werden kann.
"""
# 1. Labels prüfen
for label in task.labels:
label_lower = label.lower().strip()
if label_lower in ALL_CONTEXTS:
logger.info(
"Task '%s': Kontext '%s' aus Label ermittelt", task.id, label_lower
)
return label_lower
# 2. Projektzuordnung prüfen
if task.project:
# Projekt kann als "kontext/projektname" oder direkt als Kontextname vorliegen
project_parts = task.project.strip().split("/")
first_part = project_parts[0].lower().strip()
if first_part in ALL_CONTEXTS:
logger.info(
"Task '%s': Kontext '%s' aus Projektzuordnung ermittelt",
task.id,
first_part,
)
return first_part
# Kein Kontext ermittelbar → Req 7.6
raise ContextResolutionError(
task_id=task.id,
message=(
f"Kein Arbeitskontext aus Task-Metadaten ermittelbar. "
f"Labels: {task.labels}, Projekt: '{task.project}'. "
f"Erwartet wird ein Label oder Projektpräfix aus: {sorted(ALL_CONTEXTS)}"
),
)
def create_workspace(self, task: Task, context: str) -> Path:
"""Erstellt ein kontextgebundenes Arbeitsverzeichnis.
Erstellt das Workspace-Verzeichnis unter dem ermittelten Kontextordner:
`<monorepo_root>/<context>/.workspaces/<task_id>/`
Requirement 7.2: Workspace-Root unter dem Kontextordner.
Args:
task: Der Task, für den das Workspace erstellt wird.
context: Der ermittelte Arbeitskontext.
Returns:
Pfad zum erstellten Workspace-Verzeichnis.
Raises:
ContextResolutionError: Wenn der Kontext ungültig ist.
"""
if context not in ALL_CONTEXTS:
raise ContextResolutionError(
task_id=task.id,
message=f"Ungültiger Kontext '{context}'. Gültig: {sorted(ALL_CONTEXTS)}",
)
# Workspace-Verzeichnis: <root>/<kontext>/.workspaces/<task_id>/
# Sanitize task ID für Verzeichnisnamen
safe_task_id = self._sanitize_dirname(task.id)
workspace_path = self.root_path / context / ".workspaces" / safe_task_id
workspace_path.mkdir(parents=True, exist_ok=True)
logger.info(
"Task '%s': Workspace erstellt unter '%s'", task.id, workspace_path
)
return workspace_path
def build_prompt(self, task: Task, context: str) -> str:
"""Erstellt den Agenten-Prompt mit Wissenskontext.
Der Prompt enthält:
- Workspace-Pfad und Kontextinformationen
- Verfügbare Wissensartefakte aus dem YAML-Index (für den Kontext)
- Sicherheitsbeschränkungen (nur Zugriff innerhalb des Kontexts)
- Aufgabenbeschreibung aus dem Task
Requirement 7.1: Arbeitsverzeichnis auf Kontextordner beschränken.
Requirement 7.3: Nur .env des eigenen Kontexts laden.
Args:
task: Der zu bearbeitende Task.
context: Der ermittelte Arbeitskontext.
Returns:
Vollständiger Agenten-Prompt als String.
"""
workspace_path = self.root_path / context / ".workspaces" / self._sanitize_dirname(task.id)
context_root = self.root_path / context
# Basis-Prompt aufbauen
prompt_parts: list[str] = [
f"# Task: {task.title}",
"",
"## Arbeitskontext",
f"- Kontext: {context}",
f"- Workspace: {workspace_path}",
f"- Kontextordner: {context_root}",
"",
"## Sicherheitsbeschränkungen",
f"- Du darfst NUR auf Dateien innerhalb von '{context_root}' und "
f"'{self.root_path / 'shared'}' zugreifen.",
f"- Lade ausschließlich die .env-Datei des Kontexts '{context}'.",
"- Zugriff auf andere Kontextordner ist VERBOTEN und führt zum Task-Abbruch.",
"",
]
# Wissenskontext einfügen
knowledge_section = self.inject_knowledge(context)
if knowledge_section:
prompt_parts.append("## Verfügbare Wissensartefakte")
prompt_parts.append(knowledge_section)
prompt_parts.append("")
# Aufgabenbeschreibung
if task.description:
prompt_parts.append("## Aufgabe")
prompt_parts.append(task.description)
prompt_parts.append("")
return "\n".join(prompt_parts)
def inject_knowledge(self, context: str) -> str:
"""Fügt relevante Artefakt-Pfade aus dem YAML-Index hinzu.
Liest den YAML-Index und liefert Pfade zu Artefakten, die
im aktuellen Kontext oder im shared-Scope verfügbar sind.
Args:
context: Der aktive Arbeitskontext.
Returns:
Formatierter String mit verfügbaren Artefakt-Pfaden oder
leerer String wenn kein Index vorhanden ist.
"""
if not self._knowledge_index_path.exists():
logger.debug("Kein Wissens-Index gefunden unter: %s", self._knowledge_index_path)
return ""
try:
import yaml
with open(self._knowledge_index_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "artifacts" not in data:
return ""
# Nur Artefakte aus dem eigenen Kontext und shared anzeigen
allowed_scopes = [context, Context.SHARED.value]
relevant_artifacts: list[str] = []
for artifact in data.get("artifacts", []):
if not isinstance(artifact, dict):
continue
scope = artifact.get("scope", "")
if scope in allowed_scopes:
title = artifact.get("title", "Unbenannt")
path = artifact.get("path", "")
tags = artifact.get("tags", [])
tags_str = f" [{', '.join(tags)}]" if tags else ""
relevant_artifacts.append(f"- {title}{tags_str}: {path}")
if not relevant_artifacts:
return ""
return "\n".join(relevant_artifacts)
except Exception as e:
logger.warning("Fehler beim Lesen des Wissens-Index: %s", e)
return ""
def validate_access(self, task: Task, context: str, target_path: Path) -> None:
"""Prüft ob ein Dateizugriff innerhalb des zugewiesenen Kontexts liegt.
Verwendet den ContextGuard zur Zugriffsprüfung. Bei Verletzung:
- Task wird abgebrochen (Exception)
- Vorfall wird im AuditLog protokolliert
- Nutzer wird über die Exception informiert
Requirement 7.5: Out-of-Context → abbrechen, protokollieren, benachrichtigen.
Args:
task: Der laufende Task.
context: Der zugewiesene Arbeitskontext.
target_path: Der Pfad, auf den zugegriffen werden soll.
Raises:
ContextViolationError: Wenn der Zugriff außerhalb des Kontexts liegt.
"""
if not self.context_guard.check_access(context, target_path):
# Zugriffsverletzung protokollieren
target_path_str = str(target_path)
event = SecurityEvent(
timestamp=datetime.now(timezone.utc),
requesting_context=context,
target_context=self._detect_target_context(target_path),
resource=target_path_str,
action="read",
outcome="denied",
)
self.audit_logger.log_violation(event)
logger.error(
"Task '%s': Kontextverletzung! Zugriff auf '%s' aus Kontext '%s' verweigert.",
task.id,
target_path_str,
context,
)
raise ContextViolationError(
task_id=task.id,
context=context,
access_path=target_path_str,
)
def load_context_env(self, context: str) -> dict[str, str]:
"""Lädt ausschließlich die .env-Datei des zugewiesenen Kontexts.
Bei konfiguriertem SecretEncryptionManager wird die .env-Datei
entschlüsselt gelesen. Ohne Manager wird sie direkt gelesen
(unverschlüsselt oder bereits durch git-crypt entschlüsselt).
Requirement 7.3: Nur .env des eigenen Kontexts laden, keine
Umgebungsvariablen anderer Kontexte. Entschlüsselung via
SecretEncryptionManager.
Args:
context: Der zugewiesene Arbeitskontext.
Returns:
Dict mit den Umgebungsvariablen des Kontexts.
Raises:
ValueError: Wenn der Kontext ungültig ist.
FileNotFoundError: Wenn die .env-Datei nicht existiert.
PermissionError: Wenn die Entschlüsselung fehlschlägt
(Maschinenkontext nicht autorisiert).
"""
if self.encryption_manager is not None:
return self._load_env_with_decryption(context)
return self.context_guard.load_env(context)
# ---------------------------------------------------------------------------
# Private Hilfsmethoden
# ---------------------------------------------------------------------------
@staticmethod
def _sanitize_dirname(name: str) -> str:
"""Macht einen String sicher für die Verwendung als Verzeichnisname.
Ersetzt ungültige Zeichen durch Unterstriche.
Args:
name: Der zu bereinigende String.
Returns:
Sicherer Verzeichnisname.
"""
# Einfache Sanitierung: Nur alphanumerische Zeichen, Bindestrich, Unterstrich
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
return safe or "unnamed"
def _detect_target_context(self, target_path: Path) -> str:
"""Ermittelt den Kontext eines Zielpfads für die Protokollierung.
Args:
target_path: Der Pfad, dessen Kontext ermittelt werden soll.
Returns:
Name des Zielkontexts oder "unknown".
"""
try:
resolved = target_path.resolve() if target_path.is_absolute() else target_path
rel_path = resolved.relative_to(self.root_path)
parts = rel_path.parts
if parts and parts[0] in ALL_CONTEXTS:
return parts[0]
except (ValueError, OSError):
pass
return "unknown"
def _load_env_with_decryption(self, context: str) -> dict[str, str]:
"""Lädt die .env-Datei des Kontexts mit Entschlüsselung via SecretEncryptionManager.
Ermittelt den Pfad zur .env-Datei aus der ContextGuard-Konfiguration,
prüft die Autorisierung des Maschinenkontexts und entschlüsselt die
Datei bei Bedarf.
Requirement 7.3: Nur .env des eigenen Kontexts laden, entschlüsselt
via SecretEncryptionManager.
Requirement 9.3: Kontext-eigener Schlüssel für Entschlüsselung.
Requirement 9.4: Maschinenkontext-basierte Autorisierung.
Args:
context: Der Arbeitskontext, dessen .env geladen werden soll.
Returns:
Dict mit den entschlüsselten Umgebungsvariablen.
Raises:
ValueError: Wenn der Kontext ungültig ist.
FileNotFoundError: Wenn die .env-Datei nicht existiert.
PermissionError: Wenn die Entschlüsselung fehlschlägt
(Maschinenkontext nicht autorisiert).
"""
assert self.encryption_manager is not None # Caller garantiert
# Autorisierungsprüfung
if not self.encryption_manager.is_authorized(context):
raise PermissionError(
f"Maschinenkontext '{self.encryption_manager.machine_context.name}' "
f"ist nicht für Kontext '{context}' autorisiert. "
f"Env-Loading verweigert."
)
# .env-Pfad aus der ContextGuard-Konfiguration ermitteln
ctx_config = self.context_guard.get_context_config(context)
env_file_rel = ctx_config.get("env_file")
if not env_file_rel:
raise ValueError(
f"Kein env_file für Kontext '{context}' in der Konfiguration definiert."
)
env_path = self.root_path / env_file_rel
if not env_path.exists():
raise FileNotFoundError(
f".env-Datei für Kontext '{context}' nicht gefunden: {env_path}"
)
# Entschlüsselung via SecretEncryptionManager
result = self.encryption_manager.decrypt_file(env_path)
if result.success and result.content is not None:
# Entschlüsselter Inhalt als Text parsen
content_text = result.content.decode("utf-8", errors="replace")
return ContextGuard._parse_env_content(content_text)
# Entschlüsselung fehlgeschlagen Fallback auf ContextGuard.load_env
# (Datei könnte bereits im Klartext vorliegen nach git-crypt unlock)
logger.debug(
"Entschlüsselung für Kontext '%s' nicht erfolgreich (%s). "
"Fallback auf ContextGuard.load_env.",
context,
result.error or "unbekannter Fehler",
)
return self.context_guard.load_env(context)
@@ -0,0 +1,835 @@
"""Externes-Repo-Manager für das Monorepo.
Verwaltet die Einbindung externer Repositories via Git-Subtree (Standard)
oder Git-Submodule, sowie die Synchronisation mit Upstream-Remotes.
Verantwortung: Einbindung, Synchronisation und Schutzmechanismen für
externe Repositories (Read-Only und Upstream).
"""
from __future__ import annotations
import logging
import stat
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
import yaml
from monorepo.models import ConflictInfo, RepoEntry, SyncResult
logger = logging.getLogger(__name__)
# Template für den pre-commit Hook, der Read-Only-Verzeichnisse schützt.
# Platzhalter: {protected_paths} wird durch eine Shell-Array-Definition ersetzt.
_PRE_COMMIT_HOOK_TEMPLATE = """\
#!/bin/sh
# --- Monorepo Read-Only-Schutz (auto-generiert) ---
# Verhindert Commits, die Dateien in geschützten Read-Only-Verzeichnissen ändern.
{protected_paths}
staged_files=$(git diff --cached --name-only)
for file in $staged_files; do
for protected in "${{PROTECTED_PATHS[@]}}"; do
case "$file" in
"$protected"/*)
echo "FEHLER: Repo '$protected' ist read-only. Schreibzugriff auf '$file' nicht erlaubt." >&2
exit 1
;;
esac
done
done
# --- Ende Read-Only-Schutz ---
"""
# Marker-Kommentare für den Read-Only-Abschnitt im Hook
_HOOK_START_MARKER = "# --- Monorepo Read-Only-Schutz (auto-generiert) ---"
_HOOK_END_MARKER = "# --- Ende Read-Only-Schutz ---"
class RepoManager:
"""Verwaltet externe und Upstream-Repository-Einbindungen.
Unterstützt zwei Einbindungsmechanismen:
- Git-Subtree (Standard): Flache Checkouts, bidirektionale Sync möglich
- Git-Submodule: Separate Repository-Referenz
Und zwei Betriebsmodi:
- read-only: Nur Pull von Remote, lokale Änderungen werden blockiert
- upstream: Bidirektionale Synchronisation (Pull/Push)
"""
def __init__(self, config_path: Path, monorepo_root: Path | None = None) -> None:
"""Initialisiert den RepoManager.
Args:
config_path: Pfad zur repos.yaml Konfigurationsdatei.
monorepo_root: Wurzelverzeichnis des Monorepos. Wenn None,
wird das übergeordnete Verzeichnis der Config verwendet.
"""
self.config_path = config_path
self.monorepo_root = monorepo_root or config_path.parent.parent.parent
self.repos = self._load_repos_config(config_path)
def _load_repos_config(self, config_path: Path) -> list[RepoEntry]:
"""Lädt die Repo-Konfiguration aus repos.yaml.
Args:
config_path: Pfad zur repos.yaml.
Returns:
Liste aller konfigurierten RepoEntry-Objekte.
"""
if not config_path.exists():
logger.warning("Repo-Konfiguration nicht gefunden: %s", config_path)
return []
with open(config_path, encoding="utf-8") as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
entries: list[RepoEntry] = []
for item in data.get("repos", []):
entries.append(
RepoEntry(
name=item["name"],
url=item["url"],
mode=item.get("mode", "read-only"),
target=item["target"],
pinned=item.get("pinned", "main"),
mechanism=item.get("mechanism", "subtree"),
)
)
return entries
def _find_repo(self, repo_name: str) -> RepoEntry | None:
"""Findet einen Repo-Eintrag anhand des Namens.
Args:
repo_name: Name des gesuchten Repos.
Returns:
Der RepoEntry oder None wenn nicht gefunden.
"""
for entry in self.repos:
if entry.name == repo_name:
return entry
return None
def _run_git(
self, args: list[str], cwd: Path | None = None
) -> subprocess.CompletedProcess[str]:
"""Führt einen Git-Befehl aus.
Args:
args: Git-Argumente (ohne 'git' Präfix).
cwd: Arbeitsverzeichnis für den Befehl.
Returns:
CompletedProcess mit stdout/stderr.
Raises:
subprocess.CalledProcessError: Bei Fehler im Git-Befehl.
"""
cmd = ["git"] + args
logger.debug("Ausführen: %s (cwd=%s)", " ".join(cmd), cwd or self.monorepo_root)
return subprocess.run(
cmd,
cwd=cwd or self.monorepo_root,
capture_output=True,
text=True,
check=True,
)
def add_repo(self, entry: RepoEntry) -> None:
"""Bindet ein externes Repo via Git-Subtree oder Submodule ein.
Subtree (Standard):
git subtree add --prefix=<target> <url> <pinned> --squash
Submodule:
git submodule add <url> <target>
git -C <target> checkout <pinned>
Args:
entry: RepoEntry mit URL, Zielverzeichnis, Mechanismus, etc.
Raises:
ValueError: Wenn das Zielverzeichnis bereits existiert.
subprocess.CalledProcessError: Bei Fehler im Git-Befehl.
"""
target_path = self.monorepo_root / entry.target
if target_path.exists():
raise ValueError(
f"Zielverzeichnis '{entry.target}' existiert bereits. "
f"Repo '{entry.name}' kann nicht eingebunden werden."
)
logger.info(
"Binde Repo '%s' ein via %s%s",
entry.name,
entry.mechanism,
entry.target,
)
if entry.mechanism == "subtree":
self._add_subtree(entry)
elif entry.mechanism == "submodule":
self._add_submodule(entry)
else:
raise ValueError(
f"Unbekannter Mechanismus '{entry.mechanism}'. "
f"Erlaubt: 'subtree', 'submodule'."
)
# Repo in Konfiguration aufnehmen (falls noch nicht vorhanden)
if not self._find_repo(entry.name):
self.repos.append(entry)
self._save_repos_config()
logger.info("Repo '%s' erfolgreich eingebunden.", entry.name)
def _add_subtree(self, entry: RepoEntry) -> None:
"""Bindet ein Repo als Git-Subtree ein.
Args:
entry: RepoEntry mit den Subtree-Parametern.
"""
self._run_git([
"subtree",
"add",
f"--prefix={entry.target}",
entry.url,
entry.pinned,
"--squash",
])
def _add_submodule(self, entry: RepoEntry) -> None:
"""Bindet ein Repo als Git-Submodule ein.
Args:
entry: RepoEntry mit den Submodule-Parametern.
"""
self._run_git([
"submodule",
"add",
entry.url,
entry.target,
])
# Auf gepinnte Version auschecken
target_path = self.monorepo_root / entry.target
self._run_git(["checkout", entry.pinned], cwd=target_path)
def sync(self, repo_name: str) -> SyncResult:
"""Synchronisiert ein Repo mit seinem Remote.
Für Read-Only-Repos: Pull von Upstream auf gepinnte Version.
Für Upstream-Repos: Bidirektionale Synchronisation (Pull dann Push).
Bei Merge-Konflikten wird die Synchronisation abgebrochen,
der Konflikt protokolliert und manuelle Auflösung ermöglicht.
Args:
repo_name: Name des zu synchronisierenden Repos.
Returns:
SyncResult mit Status (Erfolg/Misserfolg/unbekannt),
Richtung, Anzahl synchronisierter Commits und ggf. Konflikte.
"""
entry = self._find_repo(repo_name)
if entry is None:
logger.error("Repo '%s' nicht in Konfiguration gefunden.", repo_name)
return SyncResult(
success=False,
context=repo_name,
direction="pull",
commits_synced=0,
conflicts=[
ConflictInfo(
file_path="",
conflict_type="content",
source="monorepo",
details=f"Repo '{repo_name}' nicht in Konfiguration gefunden.",
)
],
timestamp=datetime.now(),
)
if entry.mode == "upstream":
return self._sync_upstream(entry)
else:
return self._sync_readonly(entry)
def _sync_readonly(self, entry: RepoEntry) -> SyncResult:
"""Synchronisiert ein Read-Only-Repo (nur Pull).
Args:
entry: RepoEntry des zu synchronisierenden Repos.
Returns:
SyncResult mit Ergebnis der Pull-Operation.
"""
logger.info("Synchronisiere Read-Only-Repo '%s' auf '%s'.", entry.name, entry.pinned)
try:
if entry.mechanism == "subtree":
result = self._subtree_pull(entry)
else:
result = self._submodule_pull(entry)
commits = self._count_commits_from_output(result.stdout)
logger.info(
"Repo '%s' erfolgreich synchronisiert (%d Commits).",
entry.name,
commits,
)
return SyncResult(
success=True,
context=entry.name,
direction="pull",
commits_synced=commits,
conflicts=[],
timestamp=datetime.now(),
)
except subprocess.CalledProcessError as e:
return self._handle_sync_error(entry, e, direction="pull")
def _sync_upstream(self, entry: RepoEntry) -> SyncResult:
"""Synchronisiert ein Upstream-Repo bidirektional (Pull + Push).
Ablauf:
1. Pull von Upstream (Subtree-Pull oder Submodule-Update)
2. Bei Merge-Konflikt: Abbruch, Protokollierung, manuelle Auflösung
3. Push lokaler Änderungen zu Upstream
Args:
entry: RepoEntry des Upstream-Repos.
Returns:
SyncResult mit Gesamtergebnis der bidirektionalen Sync.
"""
logger.info("Bidirektionale Synchronisation für Upstream-Repo '%s'.", entry.name)
total_commits = 0
# Phase 1: Pull von Upstream
try:
if entry.mechanism == "subtree":
pull_result = self._subtree_pull(entry)
else:
pull_result = self._submodule_pull(entry)
total_commits += self._count_commits_from_output(pull_result.stdout)
logger.info("Pull von '%s' erfolgreich.", entry.name)
except subprocess.CalledProcessError as e:
# Prüfe ob es ein Merge-Konflikt ist
if self._is_merge_conflict(e):
return self._handle_merge_conflict(entry, e)
return self._handle_sync_error(entry, e, direction="full")
# Phase 2: Push lokaler Änderungen
try:
if entry.mechanism == "subtree":
push_result = self._subtree_push(entry)
else:
push_result = self._submodule_push(entry)
total_commits += self._count_commits_from_output(push_result.stdout)
logger.info("Push zu '%s' erfolgreich.", entry.name)
except subprocess.CalledProcessError as e:
if self._is_merge_conflict(e):
return self._handle_merge_conflict(entry, e)
return self._handle_sync_error(entry, e, direction="push")
return SyncResult(
success=True,
context=entry.name,
direction="full",
commits_synced=total_commits,
conflicts=[],
timestamp=datetime.now(),
)
def _subtree_pull(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
"""Führt einen git subtree pull aus.
Args:
entry: RepoEntry mit Subtree-Parametern.
Returns:
CompletedProcess mit Ergebnis.
"""
return self._run_git([
"subtree",
"pull",
f"--prefix={entry.target}",
entry.url,
entry.pinned,
"--squash",
])
def _subtree_push(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
"""Führt einen git subtree push aus.
Args:
entry: RepoEntry mit Subtree-Parametern.
Returns:
CompletedProcess mit Ergebnis.
"""
return self._run_git([
"subtree",
"push",
f"--prefix={entry.target}",
entry.url,
entry.pinned,
])
def _submodule_pull(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
"""Aktualisiert ein Submodule auf die gepinnte Version.
Args:
entry: RepoEntry mit Submodule-Parametern.
Returns:
CompletedProcess mit Ergebnis.
"""
target_path = self.monorepo_root / entry.target
# Fetch latest from remote
self._run_git(["fetch", "origin"], cwd=target_path)
# Checkout pinned version
return self._run_git(["checkout", entry.pinned], cwd=target_path)
def _submodule_push(self, entry: RepoEntry) -> subprocess.CompletedProcess[str]:
"""Pusht lokale Änderungen in einem Submodule zu Upstream.
Args:
entry: RepoEntry mit Submodule-Parametern.
Returns:
CompletedProcess mit Ergebnis.
"""
target_path = self.monorepo_root / entry.target
return self._run_git(["push", "origin", entry.pinned], cwd=target_path)
def _is_merge_conflict(self, error: subprocess.CalledProcessError) -> bool:
"""Prüft ob ein Git-Fehler ein Merge-Konflikt ist.
Args:
error: Der aufgetretene CalledProcessError.
Returns:
True wenn es sich um einen Merge-Konflikt handelt.
"""
conflict_indicators = [
"CONFLICT",
"merge conflict",
"Automatic merge failed",
"fix conflicts",
]
combined_output = (error.stdout or "") + (error.stderr or "")
return any(indicator in combined_output for indicator in conflict_indicators)
def _handle_merge_conflict(
self, entry: RepoEntry, error: subprocess.CalledProcessError
) -> SyncResult:
"""Behandelt einen Merge-Konflikt: Abbruch, Protokollierung.
Bei Merge-Konflikten wird:
1. Die Merge-Operation abgebrochen (git merge --abort)
2. Der Konflikt protokolliert
3. Ein SyncResult mit Konfliktdetails zurückgegeben
Args:
entry: RepoEntry des betroffenen Repos.
error: Der CalledProcessError mit Konfliktdetails.
Returns:
SyncResult mit success=False und Konfliktinformationen.
"""
logger.warning(
"Merge-Konflikt bei Synchronisation von '%s'. Breche ab.",
entry.name,
)
# Merge abbrechen um sauberen Zustand herzustellen
try:
self._run_git(["merge", "--abort"])
except subprocess.CalledProcessError:
# Falls kein Merge aktiv ist, ignorieren
logger.debug("Kein aktiver Merge zum Abbrechen.")
# Konfliktdetails aus Fehlerausgabe extrahieren
combined_output = (error.stdout or "") + (error.stderr or "")
conflict_files = self._extract_conflict_files(combined_output)
conflicts = [
ConflictInfo(
file_path=f,
conflict_type="content",
source="monorepo",
details=f"Merge-Konflikt in '{f}' bei Sync von '{entry.name}'.",
)
for f in conflict_files
] or [
ConflictInfo(
file_path="",
conflict_type="content",
source="monorepo",
details=f"Merge-Konflikt bei Synchronisation von '{entry.name}': "
f"{combined_output[:200]}",
)
]
return SyncResult(
success=False,
context=entry.name,
direction="full" if entry.mode == "upstream" else "pull",
commits_synced=0,
conflicts=conflicts,
timestamp=datetime.now(),
)
def _handle_sync_error(
self,
entry: RepoEntry,
error: subprocess.CalledProcessError,
direction: str,
) -> SyncResult:
"""Behandelt einen allgemeinen Sync-Fehler.
Protokolliert den Fehler in der Sync-Error-Log-Datei und gibt
ein SyncResult mit success=False zurück. Der lokale Stand wird
nicht verändert (Req 4.9).
Args:
entry: RepoEntry des betroffenen Repos.
error: Der aufgetretene Fehler.
direction: Richtung der fehlgeschlagenen Operation.
Returns:
SyncResult mit success=False und Fehlerdetails.
"""
combined_output = (error.stdout or "") + (error.stderr or "")
error_detail = combined_output.strip()[:300] or "Unbekannter Fehler"
logger.error(
"Synchronisation von '%s' fehlgeschlagen: %s",
entry.name,
error_detail,
)
# Fehler in Audit-Datei protokollieren (Req 4.9)
self.log_sync_error(
repo_name=entry.name,
error_reason=error_detail,
direction=direction,
)
return SyncResult(
success=False,
context=entry.name,
direction=direction, # type: ignore[arg-type]
commits_synced=0,
conflicts=[
ConflictInfo(
file_path="",
conflict_type="content",
source="monorepo",
details=f"Synchronisation fehlgeschlagen: {error_detail}",
)
],
timestamp=datetime.now(),
)
def _extract_conflict_files(self, output: str) -> list[str]:
"""Extrahiert Dateinamen aus Git-Merge-Konflikt-Ausgabe.
Args:
output: Die kombinierte stdout/stderr-Ausgabe von Git.
Returns:
Liste der Dateipfade mit Konflikten.
"""
conflict_files: list[str] = []
for line in output.splitlines():
# Git zeigt "CONFLICT (content): Merge conflict in <file>"
if "CONFLICT" in line and "Merge conflict in" in line:
parts = line.split("Merge conflict in")
if len(parts) > 1:
file_path = parts[1].strip().rstrip(".")
conflict_files.append(file_path)
# Git zeigt auch "CONFLICT (modify/delete): <file> deleted in ..."
elif "CONFLICT" in line and ":" in line:
parts = line.split(":")
if len(parts) > 1:
# Versuche Dateiname zu extrahieren
segment = parts[1].strip()
tokens = segment.split()
if tokens:
conflict_files.append(tokens[0])
return conflict_files
def _count_commits_from_output(self, output: str) -> int:
"""Zählt synchronisierte Commits anhand der Git-Ausgabe.
Sucht nach typischen Git-Meldungen wie "X commits" oder
Shortstat-Zeilen. Gibt 1 zurück wenn der Output auf
Aktivität hindeutet, sonst 0.
Args:
output: Die stdout-Ausgabe des Git-Befehls.
Returns:
Geschätzte Anzahl synchronisierter Commits.
"""
if not output:
return 0
# Suche nach "X files changed" als Indikator für Änderungen
for line in output.splitlines():
if "files changed" in line or "file changed" in line:
return 1
if "Already up to date" in line or "Already up-to-date" in line:
return 0
# Wenn es Output gibt aber keine klaren Indikatoren,
# gehen wir von mindestens einer Änderung aus
if output.strip():
return 1
return 0
def _save_repos_config(self) -> None:
"""Speichert die aktuelle Repo-Konfiguration in repos.yaml."""
data = {
"repos": [
{
"name": entry.name,
"url": entry.url,
"mode": entry.mode,
"target": entry.target,
"pinned": entry.pinned,
"mechanism": entry.mechanism,
}
for entry in self.repos
]
}
self.config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
logger.debug("Repo-Konfiguration gespeichert: %s", self.config_path)
# -----------------------------------------------------------------------
# Read-Only-Schutzmechanismus (Req 4.2, 4.5)
# -----------------------------------------------------------------------
def protect_readonly(self, repo_path: Path) -> None:
"""Installiert einen Git pre-commit Hook zum Schutz eines Read-Only-Verzeichnisses.
Der Hook prüft bei jedem Commit, ob gestagte Dateien innerhalb des
geschützten Verzeichnisses liegen. Ist das der Fall, wird der Commit
mit einer Fehlermeldung abgebrochen, die den Repo-Namen und den
Read-Only-Status benennt.
Wenn bereits ein pre-commit Hook existiert, wird der Schutzabschnitt
angehängt bzw. aktualisiert. Die geschützten Pfade werden als
Shell-Array in den Hook geschrieben.
Args:
repo_path: Absoluter Pfad zum geschützten Verzeichnis (Target-Pfad
relativ zum Monorepo-Root wird automatisch berechnet).
Raises:
ValueError: Wenn der repo_path nicht innerhalb des Monorepo-Roots liegt.
"""
# Berechne den relativen Pfad zum Monorepo-Root
try:
relative_target = repo_path.relative_to(self.monorepo_root)
except ValueError:
raise ValueError(
f"Pfad '{repo_path}' liegt nicht innerhalb des "
f"Monorepo-Roots '{self.monorepo_root}'."
)
target_str = str(relative_target).replace("\\", "/")
# Git-Hook-Verzeichnis ermitteln
hooks_dir = self.monorepo_root / ".git" / "hooks"
hooks_dir.mkdir(parents=True, exist_ok=True)
hook_path = hooks_dir / "pre-commit"
# Geschützte Pfade sammeln (aus bestehendem Hook + neuer Pfad)
protected_paths = self._get_existing_protected_paths(hook_path)
if target_str not in protected_paths:
protected_paths.append(target_str)
# Hook-Script generieren und schreiben
self._write_pre_commit_hook(hook_path, protected_paths)
logger.info(
"Read-Only-Schutz installiert für '%s' (Hook: %s).",
target_str,
hook_path,
)
def install_all_readonly_hooks(self) -> None:
"""Installiert Read-Only-Schutz für alle als read-only konfigurierten Repos.
Iteriert über alle konfigurierten Repos und installiert für jene mit
mode="read-only" den pre-commit-Hook-Schutz. Bereits geschützte Pfade
werden nicht doppelt eingetragen.
"""
readonly_repos = [entry for entry in self.repos if entry.mode == "read-only"]
if not readonly_repos:
logger.info("Keine Read-Only-Repos konfiguriert kein Hook notwendig.")
return
for entry in readonly_repos:
repo_path = self.monorepo_root / entry.target
self.protect_readonly(repo_path)
logger.info(
"Read-Only-Schutz für %d Repos installiert: %s",
len(readonly_repos),
", ".join(e.name for e in readonly_repos),
)
def _get_existing_protected_paths(self, hook_path: Path) -> list[str]:
"""Extrahiert bereits geschützte Pfade aus einem bestehenden Hook.
Args:
hook_path: Pfad zur pre-commit Hook-Datei.
Returns:
Liste der bereits geschützten Pfade (relativ zum Monorepo-Root).
"""
if not hook_path.exists():
return []
content = hook_path.read_text(encoding="utf-8")
# Suche nach PROTECTED_PATHS-Array im bestehenden Hook
paths: list[str] = []
in_array = False
for line in content.splitlines():
if "PROTECTED_PATHS=(" in line:
in_array = True
# Pfade in derselben Zeile: PROTECTED_PATHS=("path1" "path2")
inner = line.split("(", 1)[1].rstrip(")")
for token in inner.split():
cleaned = token.strip('"').strip("'")
if cleaned:
paths.append(cleaned)
if ")" in line:
in_array = False
continue
if in_array:
if ")" in line:
inner = line.split(")")[0]
for token in inner.split():
cleaned = token.strip('"').strip("'")
if cleaned:
paths.append(cleaned)
in_array = False
else:
for token in line.split():
cleaned = token.strip('"').strip("'")
if cleaned:
paths.append(cleaned)
return paths
def _write_pre_commit_hook(self, hook_path: Path, protected_paths: list[str]) -> None:
"""Schreibt oder aktualisiert den pre-commit Hook mit Schutzmechanismus.
Wenn bereits ein Hook existiert, wird der Read-Only-Abschnitt
(zwischen Start- und End-Marker) ersetzt. Andernfalls wird ein
neuer Hook erstellt.
Args:
hook_path: Pfad zur Hook-Datei.
protected_paths: Liste der zu schützenden relativen Pfade.
"""
# Generiere Shell-Array-Definition
quoted_paths = " ".join(f'"{p}"' for p in protected_paths)
paths_definition = f'PROTECTED_PATHS=({quoted_paths})'
# Generiere neuen Hook-Abschnitt
new_section = _PRE_COMMIT_HOOK_TEMPLATE.format(protected_paths=paths_definition)
if hook_path.exists():
existing_content = hook_path.read_text(encoding="utf-8")
if _HOOK_START_MARKER in existing_content:
# Ersetze den bestehenden Abschnitt
start_idx = existing_content.index(_HOOK_START_MARKER)
end_idx = existing_content.index(_HOOK_END_MARKER) + len(_HOOK_END_MARKER)
updated_content = (
existing_content[:start_idx]
+ new_section.strip()
+ existing_content[end_idx:]
)
else:
# Hänge den Abschnitt an den bestehenden Hook an
updated_content = existing_content.rstrip() + "\n\n" + new_section
else:
updated_content = new_section
hook_path.write_text(updated_content, encoding="utf-8")
# Hook ausführbar machen (wichtig für Unix-Systeme)
current_mode = hook_path.stat().st_mode
hook_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
# -----------------------------------------------------------------------
# Sync-Fehlerprotokollierung (Req 4.9)
# -----------------------------------------------------------------------
def log_sync_error(
self,
repo_name: str,
error_reason: str,
direction: str = "pull",
) -> None:
"""Protokolliert einen Synchronisationsfehler in der Audit-Datei.
Schreibt einen strukturierten Eintrag (Zeitstempel, Repo-Name,
Richtung, Fehlergrund) in `.audit/sync-errors.log`. Der lokale
Stand des Repos wird dabei nicht verändert.
Args:
repo_name: Name des betroffenen Repos.
error_reason: Beschreibung des Fehlergrunds (Netzwerk, Auth, Merge).
direction: Richtung der fehlgeschlagenen Operation (pull/push/full).
"""
audit_dir = self.monorepo_root / ".audit"
audit_dir.mkdir(parents=True, exist_ok=True)
log_path = audit_dir / "sync-errors.log"
timestamp = datetime.now().isoformat(timespec="seconds")
log_entry = (
f"[{timestamp}] repo={repo_name} direction={direction} "
f"error={error_reason}\n"
)
with open(log_path, "a", encoding="utf-8") as f:
f.write(log_entry)
logger.error(
"Sync-Fehler protokolliert: repo=%s, direction=%s, error=%s",
repo_name,
direction,
error_reason,
)
@@ -0,0 +1,361 @@
"""Sicherheits-Guard für die Zugriffskontrolle zwischen Arbeitskontexten.
Implementiert den ContextGuard, der Sicherheitsgrenzen zwischen den
Arbeitskontexten (privat, dhive, bahn, shared) erzwingt. Die Zugriffskontrolle
basiert auf der zentralen access-config.yaml.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import yaml
from monorepo.models import Context
logger = logging.getLogger(__name__)
class ContextGuard:
"""Erzwingt Sicherheitsgrenzen zwischen Arbeitskontexten.
Prüft ob ein anfragender Kontext auf einen gegebenen Pfad zugreifen darf,
basierend auf den Regeln in access-config.yaml:
- Ein Kontext darf immer auf eigene Dateien zugreifen.
- Ein Kontext darf auf shared-Pfade zugreifen, die in allowed_shared gelistet sind.
- Der shared-Kontext mit ["*"] darf auf alles in shared zugreifen.
- Alle anderen kontextübergreifenden Zugriffe werden verweigert.
"""
def __init__(
self,
root_path: Path,
access_config_path: Path | None = None,
encryption_manager: Any | None = None,
) -> None:
"""Initialisiert den ContextGuard.
Args:
root_path: Wurzelverzeichnis des Monorepos.
access_config_path: Pfad zur access-config.yaml.
Falls None, wird `root_path / shared/config/access-config.yaml` verwendet.
encryption_manager: Optionaler SecretEncryptionManager für die
Entschlüsselung von .env-Dateien. Falls None, werden .env-Dateien
direkt gelesen (unverschlüsselt oder bereits durch git-crypt entschlüsselt).
"""
self.root_path = root_path.resolve()
if access_config_path is None:
access_config_path = self.root_path / "shared" / "config" / "access-config.yaml"
self.access_config_path = access_config_path.resolve()
self.encryption_manager = encryption_manager
self._config: dict[str, Any] = self._load_access_config()
def _load_access_config(self) -> dict[str, Any]:
"""Lädt die access-config.yaml.
Returns:
Dict mit der Zugriffskonfiguration pro Kontext.
Raises:
FileNotFoundError: Wenn die Konfigurationsdatei nicht existiert.
ValueError: Wenn das YAML-Format ungültig ist.
"""
if not self.access_config_path.exists():
raise FileNotFoundError(
f"Access-Konfiguration nicht gefunden: {self.access_config_path}"
)
with open(self.access_config_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "contexts" not in data:
raise ValueError(
f"Ungültiges access-config.yaml-Format: "
f"Erwartet dict mit 'contexts'-Schlüssel in {self.access_config_path}"
)
return data["contexts"]
def check_access(self, requesting_context: str, target_path: Path) -> bool:
"""Prüft ob der Zugriff auf target_path vom requesting_context erlaubt ist.
Regeln:
1. Ein Kontext darf immer auf eigene Dateien zugreifen (Pfad unter eigenem Kontextordner).
2. Ein Kontext darf auf shared-Pfade zugreifen, wenn diese in allowed_shared gelistet sind.
3. Der shared-Kontext mit allowed_shared: ["*"] darf auf alles in shared zugreifen.
4. Alle anderen kontextübergreifenden Zugriffe werden verweigert.
Args:
requesting_context: Name des anfragenden Kontexts (z.B. "privat", "dhive", "bahn", "shared").
target_path: Pfad auf den zugegriffen werden soll (absolut oder relativ zum root_path).
Returns:
True wenn der Zugriff erlaubt ist, False wenn verweigert.
"""
# Pfad relativ zum Root normalisieren
rel_path = self._resolve_relative_path(target_path)
rel_path_str = rel_path.as_posix()
# Kontext des Zielpfads ermitteln
target_context = self._get_path_context(rel_path_str)
# Regel 1: Zugriff auf eigenen Kontext immer erlaubt
if target_context == requesting_context:
return True
# Regel 2+3: Zugriff auf shared-Bereich prüfen
if target_context == Context.SHARED.value:
return self._check_shared_access(requesting_context, rel_path_str)
# Regel 4: Kontextübergreifender Zugriff verweigert
return False
def load_env(self, context: str) -> dict[str, str]:
"""Lädt die .env-Datei des gegebenen Kontexts (mit optionaler Entschlüsselung).
Wenn ein SecretEncryptionManager konfiguriert ist und der Maschinenkontext
für den angefragten Kontext autorisiert ist, wird die .env-Datei via
decrypt_file entschlüsselt. Andernfalls wird die Datei direkt gelesen
(unverschlüsselt oder bereits durch git-crypt entschlüsselt im Working Tree).
Parst eine Standard-.env-Datei (KEY=VALUE-Format) und gibt die
Umgebungsvariablen als Dict zurück. Kommentare (#) und Leerzeilen
werden übersprungen.
Args:
context: Name des Kontexts, dessen .env geladen werden soll.
Returns:
Dict mit den Umgebungsvariablen (Schlüssel → Wert).
Raises:
ValueError: Wenn der Kontext nicht in der Konfiguration definiert ist.
FileNotFoundError: Wenn die .env-Datei nicht existiert.
PermissionError: Wenn die Entschlüsselung fehlschlägt
(Maschinenkontext nicht autorisiert).
"""
if context not in self._config:
raise ValueError(
f"Unbekannter Kontext '{context}'. "
f"Gültige Kontexte: {list(self._config.keys())}"
)
env_file_rel = self._config[context].get("env_file")
if not env_file_rel:
raise ValueError(
f"Kein env_file für Kontext '{context}' in der Konfiguration definiert."
)
env_path = self.root_path / env_file_rel
if not env_path.exists():
raise FileNotFoundError(
f".env-Datei für Kontext '{context}' nicht gefunden: {env_path}"
)
# Integration mit SecretEncryptionManager für Entschlüsselung (Req 2.2, 9.3).
if self.encryption_manager is not None:
return self._load_env_with_decryption(env_path, context)
# Fallback: Datei direkt lesen (unverschlüsselt oder bereits entschlüsselt)
return self._parse_env_file(env_path)
def _load_env_with_decryption(
self, env_path: Path, context: str
) -> dict[str, str]:
"""Lädt und entschlüsselt eine .env-Datei via SecretEncryptionManager.
Prüft zuerst die Autorisierung des Maschinenkontexts. Wenn autorisiert,
wird die Datei entschlüsselt und geparst. Andernfalls wird ein
PermissionError ausgelöst.
Args:
env_path: Absoluter Pfad zur .env-Datei.
context: Arbeitskontext der .env-Datei.
Returns:
Dict mit den entschlüsselten Umgebungsvariablen.
Raises:
PermissionError: Wenn der Maschinenkontext nicht autorisiert ist.
"""
encryption_mgr = self.encryption_manager
# Autorisierungsprüfung
if not encryption_mgr.is_authorized(context):
raise PermissionError(
f"Maschinenkontext '{encryption_mgr.machine_context.name}' ist nicht "
f"für Kontext '{context}' autorisiert. Entschlüsselung verweigert."
)
# Entschlüsselung versuchen
result = encryption_mgr.decrypt_file(env_path)
if result.success and result.content is not None:
# Entschlüsselter Inhalt als Text parsen
content_text = result.content.decode("utf-8", errors="replace")
return self._parse_env_content(content_text)
# Entschlüsselung fehlgeschlagen Fallback auf direkte Leseoperation.
# Dies tritt auf wenn die Datei bereits im Klartext vorliegt (z.B.
# nach git-crypt unlock) oder wenn git-crypt nicht verfügbar ist.
logger.debug(
"Entschlüsselung für '%s' nicht erfolgreich (%s). "
"Fallback auf direktes Lesen.",
env_path,
result.error or "unbekannter Fehler",
)
return self._parse_env_file(env_path)
def get_context_config(self, context: str) -> dict[str, Any]:
"""Gibt die Konfiguration für einen bestimmten Kontext zurück.
Args:
context: Name des Kontexts.
Returns:
Dict mit env_file und allowed_shared für den Kontext.
Raises:
ValueError: Wenn der Kontext nicht in der Konfiguration definiert ist.
"""
if context not in self._config:
raise ValueError(
f"Unbekannter Kontext '{context}'. "
f"Gültige Kontexte: {list(self._config.keys())}"
)
return self._config[context]
def _resolve_relative_path(self, target_path: Path) -> Path:
"""Löst einen Pfad relativ zum Monorepo-Root auf.
Args:
target_path: Absoluter oder relativer Pfad.
Returns:
Pfad relativ zum Root (immer mit Forward-Slashes via as_posix).
"""
resolved = Path(target_path).resolve() if target_path.is_absolute() else target_path
try:
return resolved.relative_to(self.root_path)
except ValueError:
# Pfad ist bereits relativ oder liegt außerhalb des Roots
return target_path
def _get_path_context(self, rel_path_str: str) -> str:
"""Ermittelt den Kontext eines relativen Pfads.
Der Kontext wird anhand des obersten Verzeichnisses bestimmt.
Gültige Kontexte: privat, dhive, bahn, shared.
Args:
rel_path_str: Relativer Pfad als String (mit Forward-Slashes).
Returns:
Name des Kontexts oder leerer String wenn nicht zuordenbar.
"""
parts = rel_path_str.split("/")
if not parts:
return ""
first_part = parts[0]
valid_contexts = {ctx.value for ctx in Context}
if first_part in valid_contexts:
return first_part
return ""
def _check_shared_access(self, requesting_context: str, rel_path_str: str) -> bool:
"""Prüft ob ein Kontext auf einen shared-Pfad zugreifen darf.
Args:
requesting_context: Name des anfragenden Kontexts.
rel_path_str: Relativer Pfad (beginnt mit "shared/").
Returns:
True wenn der Zugriff erlaubt ist.
"""
if requesting_context not in self._config:
return False
allowed_shared = self._config[requesting_context].get("allowed_shared", [])
# Wildcard: Zugriff auf alles in shared erlaubt
if allowed_shared == ["*"]:
return True
# Prüfe ob der Pfad unter einem der erlaubten shared-Pfade liegt
for allowed_path in allowed_shared:
# Normalisiere: Stelle sicher dass der Pfad mit / endet für Prefix-Matching
allowed_normalized = allowed_path.rstrip("/") + "/"
# Pfad liegt unter dem erlaubten Verzeichnis oder ist es selbst
if rel_path_str.startswith(allowed_normalized) or rel_path_str.rstrip("/") + "/" == allowed_normalized:
return True
return False
@staticmethod
def _parse_env_file(env_path: Path) -> dict[str, str]:
"""Parst eine .env-Datei im Standard-Format.
Format:
- KEY=VALUE (ein Paar pro Zeile)
- Zeilen die mit # beginnen sind Kommentare
- Leerzeilen werden übersprungen
- Führende/nachfolgende Leerzeichen werden entfernt
- Werte in Anführungszeichen (einfach oder doppelt) werden entquotet
Args:
env_path: Pfad zur .env-Datei.
Returns:
Dict mit geparsten Umgebungsvariablen.
"""
with open(env_path, encoding="utf-8") as f:
content = f.read()
return ContextGuard._parse_env_content(content)
@staticmethod
def _parse_env_content(content: str) -> dict[str, str]:
"""Parst .env-Inhalt im Standard-Format aus einem String.
Format:
- KEY=VALUE (ein Paar pro Zeile)
- Zeilen die mit # beginnen sind Kommentare
- Leerzeilen werden übersprungen
- Führende/nachfolgende Leerzeichen werden entfernt
- Werte in Anführungszeichen (einfach oder doppelt) werden entquotet
Args:
content: Textinhalt der .env-Datei.
Returns:
Dict mit geparsten Umgebungsvariablen.
"""
env_vars: dict[str, str] = {}
for line in content.splitlines():
line = line.strip()
# Leerzeilen und Kommentare überspringen
if not line or line.startswith("#"):
continue
# KEY=VALUE aufteilen (nur beim ersten = splitten)
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
# Anführungszeichen entfernen
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
if key:
env_vars[key] = value
return env_vars
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
"""Ordnerstruktur-Manager für das Monorepo.
Verwaltet die 3-Ebenen-Ordnerhierarchie:
Ebene 1 (Kontextordner) → Ebene 2 (Projektordner) → Ebene 3 (Modulordner)
Verantwortung: Anlegen, Validieren und Verwalten der Monorepo-Ordnerstruktur.
"""
from __future__ import annotations
import re
from pathlib import Path
from monorepo.models import Context, ProjectInfo
class StructureManager:
"""Verwaltet die 3-Ebenen-Ordnerhierarchie des Monorepos."""
CONTEXTS = ("privat", "dhive", "bahn", "shared")
NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$")
def __init__(self, root_path: Path) -> None:
"""Initialisiert den StructureManager.
Args:
root_path: Wurzelverzeichnis des Monorepos.
"""
self.root_path = root_path
def validate_name(self, name: str) -> bool:
"""Prüft ob ein Name der kebab-case-Konvention entspricht.
Regeln:
- Ausschließlich Kleinbuchstaben, Ziffern und Bindestriche
- Zwischen 2 und 50 Zeichen lang
- Beginnt und endet nicht mit einem Bindestrich
Args:
name: Der zu prüfende Projektname.
Returns:
True wenn der Name gültig ist, False sonst.
"""
if not name:
return False
return self.NAME_PATTERN.match(name) is not None
def create_project(self, context: str, name: str) -> Path:
"""Erstellt ein neues Projekt im gegebenen Kontext.
Validiert den Namen, prüft auf Namenskonflikte und erstellt
den Projektordner im korrekten Kontextordner.
Args:
context: Der Arbeitskontext (privat, dhive, bahn, shared).
name: Der Projektname in kebab-case.
Returns:
Pfad zum erstellten Projektordner.
Raises:
ValueError: Wenn der Kontext ungültig ist, der Name die
Namenskonvention verletzt, oder ein Projekt mit
diesem Namen bereits existiert.
"""
# Kontext validieren
if context not in self.CONTEXTS:
raise ValueError(
f"Ungültiger Kontext '{context}'. "
f"Erlaubte Kontexte: {', '.join(self.CONTEXTS)}"
)
# Name validieren
if not self.validate_name(name):
raise ValueError(
f"Ungültiger Projektname '{name}'. "
f"Der Name muss kebab-case sein (Kleinbuchstaben, Ziffern, "
f"Bindestriche), zwischen 2 und 50 Zeichen lang, und darf "
f"nicht mit einem Bindestrich beginnen oder enden."
)
# Namenskonflikt prüfen
project_path = self.root_path / context / name
if project_path.exists():
raise ValueError(
f"Namenskonflikt: Ein Projekt mit dem Namen '{name}' "
f"existiert bereits im Kontext '{context}' "
f"(Pfad: {project_path})."
)
# Projektordner erstellen
project_path.mkdir(parents=True, exist_ok=False)
return project_path
def list_projects(self, context: str | None = None) -> list[ProjectInfo]:
"""Listet alle Projekte, optional gefiltert nach Kontext.
Args:
context: Optionaler Kontext-Filter. Wenn None, werden alle
Projekte aus allen Kontexten aufgelistet.
Returns:
Liste von ProjectInfo-Objekten für alle gefundenen Projekte.
Raises:
ValueError: Wenn ein ungültiger Kontext angegeben wird.
"""
if context is not None and context not in self.CONTEXTS:
raise ValueError(
f"Ungültiger Kontext '{context}'. "
f"Erlaubte Kontexte: {', '.join(self.CONTEXTS)}"
)
contexts_to_scan = (context,) if context else self.CONTEXTS
projects: list[ProjectInfo] = []
for ctx in contexts_to_scan:
context_dir = self.root_path / ctx
if not context_dir.exists():
continue
for entry in sorted(context_dir.iterdir()):
if entry.is_dir() and not entry.name.startswith("."):
projects.append(
ProjectInfo(
name=entry.name,
context=ctx,
path=entry,
)
)
return projects
def resolve_context(self, project_path: Path) -> str:
"""Ermittelt den Arbeitskontext eines Projekts anhand seines Pfads.
Analysiert den Pfad relativ zum Monorepo-Root und extrahiert
den Kontextordner (erste Ebene).
Args:
project_path: Absoluter oder relativer Pfad zum Projekt.
Returns:
Der ermittelte Arbeitskontext als String.
Raises:
ValueError: Wenn der Pfad nicht innerhalb eines bekannten
Kontextordners liegt.
"""
# Pfad auflösen (absolut machen)
resolved = project_path.resolve()
root_resolved = self.root_path.resolve()
# Prüfen ob der Pfad innerhalb des Monorepos liegt
try:
relative = resolved.relative_to(root_resolved)
except ValueError:
raise ValueError(
f"Der Pfad '{project_path}' liegt nicht innerhalb "
f"des Monorepos ({self.root_path})."
)
# Ersten Pfadteil (Kontextordner) extrahieren
parts = relative.parts
if not parts:
raise ValueError(
f"Der Pfad '{project_path}' zeigt auf das Monorepo-Root, "
f"nicht auf ein Projekt in einem Kontext."
)
context_name = parts[0]
if context_name not in self.CONTEXTS:
raise ValueError(
f"Der Pfad '{project_path}' liegt nicht in einem bekannten "
f"Kontextordner. Gefunden: '{context_name}', "
f"erwartet: {', '.join(self.CONTEXTS)}."
)
return context_name